diff --git a/.gitignore b/.gitignore index a77cef31..b0664d06 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,3 @@ out/ ### VS Code ### .vscode/ - -### dev.yml -application-dev.yml - diff --git a/Dockerfile b/Dockerfile index 9aaf971b..d6a50f70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,4 +28,4 @@ COPY --from=stage1 /app/build/libs/*.jar app.jar COPY files ./files # 실행 : CMD 또는 ENTRYPOINT를 통해 컨테이너를 배열 형태의 명령어로 실행 -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index 7b2da959..801316b5 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.0' id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' } group = 'com.sudo' @@ -30,6 +31,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-batch' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' @@ -37,6 +39,15 @@ dependencies { testImplementation 'io.projectreactor:reactor-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' + testImplementation 'com.github.codemonstur:embedded-redis:1.4.3' + testImplementation "com.navercorp.fixturemonkey:fixture-monkey-starter:1.1.14" + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.batch:spring-batch-test' + testImplementation 'com.icegreen:greenmail:2.1.3' + testImplementation 'com.icegreen:greenmail-junit5:2.1.3' + /* JWT */ // https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api @@ -83,3 +94,47 @@ clean { delete file(querydslSrcDir) } +test { + useJUnitPlatform() + finalizedBy 'jacocoTestReport' +} + +jacoco { + toolVersion = '0.8.13' +} + +jacocoTestReport { + reports { + html.required = true + xml.required = true + csv.required = false + } + + afterEvaluate { + classDirectories.setFrom( + sourceSets.main.output.asFileTree.matching { + include "**/application/**" + include "**/infrastructure/*RepositoryCustomImpl.class" + include "**/security/jwt/Token*.class" + exclude "**/train/infrastructure/excel/**" + exclude "**/application/dto/**" + } + ) + } + + finalizedBy 'jacocoTestCoverageVerification' +} + +jacocoTestCoverageVerification { + violationRules { + rule { + element = 'CLASS' + + excludes = [ + '*.test.*', + '*.Q*' + ] + } + } +} + diff --git a/k8s/depl_svc.yml b/k8s/depl_svc.yml index 16db1fae..36fdc657 100644 --- a/k8s/depl_svc.yml +++ b/k8s/depl_svc.yml @@ -24,11 +24,11 @@ spec: # 컨테이너가 사용할수 있는 리소스의 최대치 limits: cpu: "1" - memory: "500Mi" + memory: "1Gi" # 컨테이너가 시작될떄 보장받아야 하는 최소 자원 requests: cpu: "0.5" - memory: "250Mi" + memory: "512Mi" env: # name값과 yml의 ${변수} 의 변수명과 일치해야함 - name: DB_URL diff --git a/lombok.config b/lombok.config new file mode 100644 index 00000000..7a21e880 --- /dev/null +++ b/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/src/main/java/com/sudo/railo/auth/application/AuthService.java b/src/main/java/com/sudo/railo/auth/application/AuthService.java new file mode 100644 index 00000000..4b00e0e0 --- /dev/null +++ b/src/main/java/com/sudo/railo/auth/application/AuthService.java @@ -0,0 +1,110 @@ +package com.sudo.railo.auth.application; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sudo.railo.auth.application.dto.request.LoginRequest; +import com.sudo.railo.auth.application.dto.request.SignUpRequest; +import com.sudo.railo.auth.application.dto.response.ReissueTokenResponse; +import com.sudo.railo.auth.application.dto.response.SignUpResponse; +import com.sudo.railo.auth.application.dto.response.TokenResponse; +import com.sudo.railo.auth.exception.TokenError; +import com.sudo.railo.auth.security.jwt.TokenExtractor; +import com.sudo.railo.auth.security.jwt.TokenGenerator; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.global.redis.AuthRedisRepository; +import com.sudo.railo.global.redis.LogoutToken; +import com.sudo.railo.member.application.MemberNoGenerator; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.domain.MemberDetail; +import com.sudo.railo.member.domain.Membership; +import com.sudo.railo.member.domain.Role; +import com.sudo.railo.member.exception.MemberError; +import com.sudo.railo.member.infrastructure.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final MemberNoGenerator memberNoGenerator; + private final AuthenticationManager authenticationManager; + private final TokenGenerator tokenGenerator; + private final TokenExtractor tokenExtractor; + private final AuthRedisRepository authRedisRepository; + + @Transactional + public SignUpResponse signUp(SignUpRequest request) { + + if (memberRepository.existsByMemberDetailEmail(request.email())) { + throw new BusinessException(MemberError.DUPLICATE_EMAIL); + } + + String memberNo = memberNoGenerator.generateMemberNo(); + LocalDate birthDate = LocalDate.parse(request.birthDate(), DateTimeFormatter.ISO_LOCAL_DATE); + + MemberDetail memberDetail = MemberDetail.create(memberNo, Membership.BUSINESS, request.email(), birthDate, + request.gender()); + Member member = Member.create(request.name(), request.phoneNumber(), passwordEncoder.encode(request.password()), + Role.MEMBER, memberDetail); + + memberRepository.save(member); + + return new SignUpResponse(memberNo); + } + + @Transactional + public TokenResponse login(LoginRequest request) { + + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + request.memberNo(), request.password()); + + Authentication authentication = authenticationManager.authenticate(authenticationToken); + TokenResponse tokenResponse = tokenGenerator.generateTokenDTO(authentication); + + // 레디스에 리프레시 토큰 저장 + authRedisRepository.saveRefreshToken(request.memberNo(), tokenResponse.refreshToken()); + + return tokenResponse; + } + + @Transactional + public void logout(String accessToken, String memberNo) { + + // Redis 에서 해당 memberNo 로 저장된 RefreshToken 이 있는지 여부 확인 후, 존재할 경우 삭제 + if (authRedisRepository.getRefreshToken(memberNo) != null) { + authRedisRepository.deleteRefreshToken(memberNo); + } + + // 해당 AccessToken 유효 시간을 가져와 BlackList 에 저장 + Duration expiration = tokenExtractor.getAccessTokenExpiration(accessToken); + LogoutToken logoutToken = new LogoutToken("logout", expiration); + authRedisRepository.saveLogoutToken(accessToken, logoutToken, expiration); + } + + @Transactional + public ReissueTokenResponse reissueAccessToken(String refreshToken) { + + String memberNo = tokenExtractor.getMemberNo(refreshToken); + + String restoredRefreshToken = authRedisRepository.getRefreshToken(memberNo); + + if (!refreshToken.equals(restoredRefreshToken)) { + throw new BusinessException(TokenError.NOT_EQUALS_REFRESH_TOKEN); + } + + return tokenGenerator.reissueAccessToken(refreshToken); + } + +} diff --git a/src/main/java/com/sudo/railo/auth/application/EmailAuthService.java b/src/main/java/com/sudo/railo/auth/application/EmailAuthService.java new file mode 100644 index 00000000..8713bc19 --- /dev/null +++ b/src/main/java/com/sudo/railo/auth/application/EmailAuthService.java @@ -0,0 +1,50 @@ +package com.sudo.railo.auth.application; + +import java.security.SecureRandom; + +import org.springframework.stereotype.Service; + +import com.sudo.railo.global.redis.AuthRedisRepository; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class EmailAuthService { + + private final EmailSendService emailSendService; + private final AuthRedisRepository authRedisRepository; + + /** + * 이메일 인증 코드 전송 + * */ + public SendCodeResponse sendAuthCode(String email) { + String code = createAuthCode(); + emailSendService.sendEmail(email, code); + authRedisRepository.saveAuthCode(email, code); + return new SendCodeResponse(email); + } + + /** + * 이메일 인증 코드 검증 + * */ + public boolean verifyAuthCode(String email, String authCode) { + String findCode = authRedisRepository.getAuthCode(email); + boolean isVerified = authCode.equals(findCode); + + if (isVerified) { + authRedisRepository.deleteAuthCode(email); + } + + return isVerified; + } + + /** + * 랜덤 인증 코드 생성 + * */ + private String createAuthCode() { + SecureRandom random = new SecureRandom(); + return String.format("%06d", random.nextInt(1000000)); + } +} diff --git a/src/main/java/com/sudo/railo/member/application/EmailAuthService.java b/src/main/java/com/sudo/railo/auth/application/EmailSendService.java similarity index 93% rename from src/main/java/com/sudo/railo/member/application/EmailAuthService.java rename to src/main/java/com/sudo/railo/auth/application/EmailSendService.java index fe3d5ccc..01c762fc 100644 --- a/src/main/java/com/sudo/railo/member/application/EmailAuthService.java +++ b/src/main/java/com/sudo/railo/auth/application/EmailSendService.java @@ -1,11 +1,11 @@ -package com.sudo.railo.member.application; +package com.sudo.railo.auth.application; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.member.exception.AuthError; +import com.sudo.railo.auth.exception.AuthError; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; @@ -15,7 +15,7 @@ @Slf4j @Service @RequiredArgsConstructor -public class EmailAuthService { +public class EmailSendService { private final JavaMailSender mailSender; diff --git a/src/main/java/com/sudo/railo/member/application/dto/request/MemberNoLoginRequest.java b/src/main/java/com/sudo/railo/auth/application/dto/request/LoginRequest.java similarity index 83% rename from src/main/java/com/sudo/railo/member/application/dto/request/MemberNoLoginRequest.java rename to src/main/java/com/sudo/railo/auth/application/dto/request/LoginRequest.java index 51b87558..de3f7a61 100644 --- a/src/main/java/com/sudo/railo/member/application/dto/request/MemberNoLoginRequest.java +++ b/src/main/java/com/sudo/railo/auth/application/dto/request/LoginRequest.java @@ -1,10 +1,10 @@ -package com.sudo.railo.member.application.dto.request; +package com.sudo.railo.auth.application.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @Schema(description = "회원번호 기반 로그인 요청 DTO") -public record MemberNoLoginRequest( +public record LoginRequest( @Schema(description = "회원의 고유 번호", example = "202506260001") @NotBlank(message = "회원번호는 필수입니다.") diff --git a/src/main/java/com/sudo/railo/member/application/dto/request/SendCodeRequest.java b/src/main/java/com/sudo/railo/auth/application/dto/request/SendCodeRequest.java similarity index 89% rename from src/main/java/com/sudo/railo/member/application/dto/request/SendCodeRequest.java rename to src/main/java/com/sudo/railo/auth/application/dto/request/SendCodeRequest.java index 7f158933..829fd794 100644 --- a/src/main/java/com/sudo/railo/member/application/dto/request/SendCodeRequest.java +++ b/src/main/java/com/sudo/railo/auth/application/dto/request/SendCodeRequest.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.application.dto.request; +package com.sudo.railo.auth.application.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/src/main/java/com/sudo/railo/member/application/dto/request/SignUpRequest.java b/src/main/java/com/sudo/railo/auth/application/dto/request/SignUpRequest.java similarity index 96% rename from src/main/java/com/sudo/railo/member/application/dto/request/SignUpRequest.java rename to src/main/java/com/sudo/railo/auth/application/dto/request/SignUpRequest.java index b95aa8a1..473cfa90 100644 --- a/src/main/java/com/sudo/railo/member/application/dto/request/SignUpRequest.java +++ b/src/main/java/com/sudo/railo/auth/application/dto/request/SignUpRequest.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.application.dto.request; +package com.sudo.railo.auth.application.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/src/main/java/com/sudo/railo/member/application/dto/request/VerifyCodeRequest.java b/src/main/java/com/sudo/railo/auth/application/dto/request/VerifyCodeRequest.java similarity index 93% rename from src/main/java/com/sudo/railo/member/application/dto/request/VerifyCodeRequest.java rename to src/main/java/com/sudo/railo/auth/application/dto/request/VerifyCodeRequest.java index bfe8470a..d573c88e 100644 --- a/src/main/java/com/sudo/railo/member/application/dto/request/VerifyCodeRequest.java +++ b/src/main/java/com/sudo/railo/auth/application/dto/request/VerifyCodeRequest.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.application.dto.request; +package com.sudo.railo.auth.application.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/src/main/java/com/sudo/railo/auth/application/dto/response/LoginResponse.java b/src/main/java/com/sudo/railo/auth/application/dto/response/LoginResponse.java new file mode 100644 index 00000000..9025daa2 --- /dev/null +++ b/src/main/java/com/sudo/railo/auth/application/dto/response/LoginResponse.java @@ -0,0 +1,17 @@ +package com.sudo.railo.auth.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "로그인 응답 DTO") +public record LoginResponse( + + @Schema(description = "토큰의 타입 (예: Bearer)", example = "Bearer") + String grantType, + + @Schema(description = "발급된 액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + String accessToken, + + @Schema(description = "액세스 토큰의 만료 시간", example = "1750508812329") + Long accessTokenExpiresIn +) { +} diff --git a/src/main/java/com/sudo/railo/member/application/dto/response/ReissueTokenResponse.java b/src/main/java/com/sudo/railo/auth/application/dto/response/ReissueTokenResponse.java similarity index 89% rename from src/main/java/com/sudo/railo/member/application/dto/response/ReissueTokenResponse.java rename to src/main/java/com/sudo/railo/auth/application/dto/response/ReissueTokenResponse.java index c19c5748..f7f08c5a 100644 --- a/src/main/java/com/sudo/railo/member/application/dto/response/ReissueTokenResponse.java +++ b/src/main/java/com/sudo/railo/auth/application/dto/response/ReissueTokenResponse.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.application.dto.response; +package com.sudo.railo.auth.application.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/sudo/railo/member/application/dto/response/SendCodeResponse.java b/src/main/java/com/sudo/railo/auth/application/dto/response/SendCodeResponse.java similarity index 82% rename from src/main/java/com/sudo/railo/member/application/dto/response/SendCodeResponse.java rename to src/main/java/com/sudo/railo/auth/application/dto/response/SendCodeResponse.java index 226dfdb6..4290a12e 100644 --- a/src/main/java/com/sudo/railo/member/application/dto/response/SendCodeResponse.java +++ b/src/main/java/com/sudo/railo/auth/application/dto/response/SendCodeResponse.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.application.dto.response; +package com.sudo.railo.auth.application.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/sudo/railo/member/application/dto/response/SignUpResponse.java b/src/main/java/com/sudo/railo/auth/application/dto/response/SignUpResponse.java similarity index 80% rename from src/main/java/com/sudo/railo/member/application/dto/response/SignUpResponse.java rename to src/main/java/com/sudo/railo/auth/application/dto/response/SignUpResponse.java index 849b23ef..c0dbd521 100644 --- a/src/main/java/com/sudo/railo/member/application/dto/response/SignUpResponse.java +++ b/src/main/java/com/sudo/railo/auth/application/dto/response/SignUpResponse.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.application.dto.response; +package com.sudo.railo.auth.application.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/sudo/railo/member/application/dto/response/TemporaryTokenResponse.java b/src/main/java/com/sudo/railo/auth/application/dto/response/TemporaryTokenResponse.java similarity index 82% rename from src/main/java/com/sudo/railo/member/application/dto/response/TemporaryTokenResponse.java rename to src/main/java/com/sudo/railo/auth/application/dto/response/TemporaryTokenResponse.java index a3c178be..81174b5d 100644 --- a/src/main/java/com/sudo/railo/member/application/dto/response/TemporaryTokenResponse.java +++ b/src/main/java/com/sudo/railo/auth/application/dto/response/TemporaryTokenResponse.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.application.dto.response; +package com.sudo.railo.auth.application.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/sudo/railo/member/application/dto/response/TokenResponse.java b/src/main/java/com/sudo/railo/auth/application/dto/response/TokenResponse.java similarity index 91% rename from src/main/java/com/sudo/railo/member/application/dto/response/TokenResponse.java rename to src/main/java/com/sudo/railo/auth/application/dto/response/TokenResponse.java index b81dfb7a..7f6a06dd 100644 --- a/src/main/java/com/sudo/railo/member/application/dto/response/TokenResponse.java +++ b/src/main/java/com/sudo/railo/auth/application/dto/response/TokenResponse.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.application.dto.response; +package com.sudo.railo.auth.application.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/sudo/railo/member/application/dto/response/VerifyCodeResponse.java b/src/main/java/com/sudo/railo/auth/application/dto/response/VerifyCodeResponse.java similarity index 81% rename from src/main/java/com/sudo/railo/member/application/dto/response/VerifyCodeResponse.java rename to src/main/java/com/sudo/railo/auth/application/dto/response/VerifyCodeResponse.java index 4c960350..ef24a250 100644 --- a/src/main/java/com/sudo/railo/member/application/dto/response/VerifyCodeResponse.java +++ b/src/main/java/com/sudo/railo/auth/application/dto/response/VerifyCodeResponse.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.application.dto.response; +package com.sudo.railo.auth.application.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/sudo/railo/auth/docs/AuthControllerDocs.java b/src/main/java/com/sudo/railo/auth/docs/AuthControllerDocs.java new file mode 100644 index 00000000..006ee574 --- /dev/null +++ b/src/main/java/com/sudo/railo/auth/docs/AuthControllerDocs.java @@ -0,0 +1,55 @@ +package com.sudo.railo.auth.docs; + +import com.sudo.railo.auth.application.dto.request.LoginRequest; +import com.sudo.railo.auth.application.dto.request.SignUpRequest; +import com.sudo.railo.auth.application.dto.response.LoginResponse; +import com.sudo.railo.auth.application.dto.response.ReissueTokenResponse; +import com.sudo.railo.auth.application.dto.response.SignUpResponse; +import com.sudo.railo.global.exception.error.ErrorResponse; +import com.sudo.railo.global.success.SuccessResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Tag(name = "Authentication", description = "🔐 인증 API - 회원 로그인, 회원가입, 토큰 관리 API") +public interface AuthControllerDocs { + + @Operation(method = "POST", summary = "회원가입", description = "사용자 정보를 받아 회원가입을 수행합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "회원가입에 성공하였습니다."), + @ApiResponse(responseCode = "409", description = "이미 사용중인 이메일입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 본문이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse signUp(SignUpRequest request); + + @Operation(method = "POST", summary = "회원번호 로그인", description = "회원번호와 비밀번호를 받아 로그인을 수행합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인에 성공하였습니다."), + @ApiResponse(responseCode = "401", description = "비밀번호가 일치하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse login(LoginRequest request, HttpServletResponse response); + + @Operation(method = "POST", summary = "로그아웃", description = "로그인 되어있는 회원을 로그아웃 처리합니다.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃에 성공하였습니다."), + @ApiResponse(responseCode = "401", description = "이미 로그아웃된 토큰입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse logout(HttpServletRequest request, HttpServletResponse response, String memberNo); + + @Operation(method = "POST", summary = "accessToken 재발급", description = "accessToken 이 만료되었을 때, 토큰을 재발급 받을 수 있도록 합니다.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "accessToken 이 성공적으로 재발급되었습니다."), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse reissue(String refreshToken); + +} diff --git a/src/main/java/com/sudo/railo/auth/docs/EmailAuthControllerDocs.java b/src/main/java/com/sudo/railo/auth/docs/EmailAuthControllerDocs.java new file mode 100644 index 00000000..a42299a4 --- /dev/null +++ b/src/main/java/com/sudo/railo/auth/docs/EmailAuthControllerDocs.java @@ -0,0 +1,42 @@ +package com.sudo.railo.auth.docs; + +import com.sudo.railo.global.exception.error.ErrorResponse; +import com.sudo.railo.global.success.SuccessResponse; +import com.sudo.railo.auth.application.dto.request.SendCodeRequest; +import com.sudo.railo.auth.application.dto.request.VerifyCodeRequest; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; +import com.sudo.railo.auth.application.dto.response.VerifyCodeResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Authentication", description = "🔐 인증 API - 회원 로그인, 회원가입, 토큰 관리 API") +public interface EmailAuthControllerDocs { + + @Operation(method = "POST", summary = "인증되지 않은 사용자용 이메일 인증코드 전송 요청", description = "회원번호 찾기, 비밀번호 찾기 등 로그인 할 수 없는 상황에서 사용되는 이메일 인증 요청입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송을 성공했습니다."), + @ApiResponse(responseCode = "400", description = "요청 본문이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse sendAuthCode(SendCodeRequest request); + + @Operation(method = "POST", summary = "인증된 사용자용 이메일 인증코드 전송 요청", description = "이메일 변경, 휴대폰 번호 변경 등 로그인 되어 있는 상태에서 사용되는 이메일 인증 요청입니다.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송을 성공했습니다."), + @ApiResponse(responseCode = "400", description = "요청 본문이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse sendAuthCodeWithMember(String memberNo); + + @Operation(method = "POST", summary = "이메일 인증 코드 검증", description = "인증된 사용자와 인증되지 않은 사용자 모두 이메일 인증 코드 검증 시 사용합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "인증 코드 검증에 성공했을 경우 true, 실패했을 경우 false"), + @ApiResponse(responseCode = "400", description = "요청 본문이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse verifyAuthCode(VerifyCodeRequest request); +} diff --git a/src/main/java/com/sudo/railo/member/exception/AuthError.java b/src/main/java/com/sudo/railo/auth/exception/AuthError.java similarity index 93% rename from src/main/java/com/sudo/railo/member/exception/AuthError.java rename to src/main/java/com/sudo/railo/auth/exception/AuthError.java index 38b4a812..40037467 100644 --- a/src/main/java/com/sudo/railo/member/exception/AuthError.java +++ b/src/main/java/com/sudo/railo/auth/exception/AuthError.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.exception; +package com.sudo.railo.auth.exception; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/sudo/railo/global/security/TokenError.java b/src/main/java/com/sudo/railo/auth/exception/TokenError.java similarity index 96% rename from src/main/java/com/sudo/railo/global/security/TokenError.java rename to src/main/java/com/sudo/railo/auth/exception/TokenError.java index a2ce28be..cc54e072 100644 --- a/src/main/java/com/sudo/railo/global/security/TokenError.java +++ b/src/main/java/com/sudo/railo/auth/exception/TokenError.java @@ -1,4 +1,4 @@ -package com.sudo.railo.global.security; +package com.sudo.railo.auth.exception; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/sudo/railo/auth/presentation/AuthController.java b/src/main/java/com/sudo/railo/auth/presentation/AuthController.java new file mode 100644 index 00000000..55d45f45 --- /dev/null +++ b/src/main/java/com/sudo/railo/auth/presentation/AuthController.java @@ -0,0 +1,90 @@ +package com.sudo.railo.auth.presentation; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sudo.railo.auth.application.AuthService; +import com.sudo.railo.auth.application.dto.request.LoginRequest; +import com.sudo.railo.auth.application.dto.request.SignUpRequest; +import com.sudo.railo.auth.application.dto.response.LoginResponse; +import com.sudo.railo.auth.application.dto.response.ReissueTokenResponse; +import com.sudo.railo.auth.application.dto.response.SignUpResponse; +import com.sudo.railo.auth.application.dto.response.TokenResponse; +import com.sudo.railo.auth.docs.AuthControllerDocs; +import com.sudo.railo.auth.exception.TokenError; +import com.sudo.railo.auth.security.jwt.TokenExtractor; +import com.sudo.railo.auth.success.AuthSuccess; +import com.sudo.railo.auth.util.CookieManager; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.global.success.SuccessResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController implements AuthControllerDocs { + + private final AuthService authService; + private final TokenExtractor tokenExtractor; + private final CookieManager cookieManager; + + private static final int REFRESH_TOKEN_MAX_AGE = 7 * 24 * 60 * 60; + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + + @PostMapping("/signup") + public SuccessResponse signUp(@RequestBody @Valid SignUpRequest request) { + + SignUpResponse response = authService.signUp(request); + + return SuccessResponse.of(AuthSuccess.SIGN_UP_SUCCESS, response); + } + + @PostMapping("/login") + public SuccessResponse login(@RequestBody @Valid LoginRequest request, + HttpServletResponse response) { + + TokenResponse tokenResponse = authService.login(request); + LoginResponse loginResponse = new LoginResponse(tokenResponse.grantType(), tokenResponse.accessToken(), + tokenResponse.accessTokenExpiresIn()); + + cookieManager.setCookie(response, REFRESH_TOKEN_COOKIE_NAME, tokenResponse.refreshToken(), + REFRESH_TOKEN_MAX_AGE); + + return SuccessResponse.of(AuthSuccess.LOGIN_SUCCESS, loginResponse); + } + + @PostMapping("/logout") + public SuccessResponse logout(HttpServletRequest request, HttpServletResponse response, + @AuthenticationPrincipal(expression = "username") String memberNo) { + + String accessToken = tokenExtractor.resolveToken(request); + + authService.logout(accessToken, memberNo); + + cookieManager.removeCookie(response, REFRESH_TOKEN_COOKIE_NAME); + + return SuccessResponse.of(AuthSuccess.LOGOUT_SUCCESS); + } + + @PostMapping("/reissue") + public SuccessResponse reissue( + @CookieValue(value = "refreshToken", required = false) String refreshToken) { + + if (refreshToken == null || refreshToken.isEmpty()) { + throw new BusinessException(TokenError.INVALID_REFRESH_TOKEN); + } + + ReissueTokenResponse tokenResponse = authService.reissueAccessToken(refreshToken); + + return SuccessResponse.of(AuthSuccess.REISSUE_TOKEN_SUCCESS, tokenResponse); + } + +} diff --git a/src/main/java/com/sudo/railo/auth/presentation/EmailAuthController.java b/src/main/java/com/sudo/railo/auth/presentation/EmailAuthController.java new file mode 100644 index 00000000..72c0f9a9 --- /dev/null +++ b/src/main/java/com/sudo/railo/auth/presentation/EmailAuthController.java @@ -0,0 +1,74 @@ +package com.sudo.railo.auth.presentation; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sudo.railo.global.success.SuccessResponse; +import com.sudo.railo.auth.application.EmailAuthService; +import com.sudo.railo.member.application.MemberService; +import com.sudo.railo.auth.application.dto.request.SendCodeRequest; +import com.sudo.railo.auth.application.dto.request.VerifyCodeRequest; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; +import com.sudo.railo.auth.application.dto.response.VerifyCodeResponse; +import com.sudo.railo.auth.docs.EmailAuthControllerDocs; +import com.sudo.railo.auth.success.AuthSuccess; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class EmailAuthController implements EmailAuthControllerDocs { + + private final EmailAuthService emailAuthService; + private final MemberService memberService; + + /** + * 이메일 코드 전송 - 인증되지 않은 사용자 + * */ + @PostMapping("/emails") + public SuccessResponse sendAuthCode(@RequestBody @Valid SendCodeRequest request) { + + String email = request.email(); + SendCodeResponse response = emailAuthService.sendAuthCode(email); + + return SuccessResponse.of(AuthSuccess.SEND_CODE_SUCCESS, response); + } + + /** + * 이메일 코드 전송 - 인증된 사용자 + * */ + @PostMapping("/members/emails") + public SuccessResponse sendAuthCodeWithMember( + @AuthenticationPrincipal(expression = "username") String memberNo) { + + log.info("memberNo: {}", memberNo); + + String email = memberService.getMemberEmail(memberNo); + SendCodeResponse response = emailAuthService.sendAuthCode(email); + + return SuccessResponse.of(AuthSuccess.SEND_CODE_SUCCESS, response); + } + + /** + * 이메일 코드 검증 - 인증된 사용자, 인증되지 않은 사용자 모두 사용 + * */ + @PostMapping("/emails/verify") + public SuccessResponse verifyAuthCode(@RequestBody @Valid VerifyCodeRequest request) { + + String email = request.email(); + String authCode = request.authCode(); + + boolean isVerified = emailAuthService.verifyAuthCode(email, authCode); + VerifyCodeResponse response = new VerifyCodeResponse(isVerified); + + return SuccessResponse.of(AuthSuccess.VERIFY_CODE_SUCCESS_FINISH, response); + } + +} diff --git a/src/main/java/com/sudo/railo/global/security/CustomUserDetailsService.java b/src/main/java/com/sudo/railo/auth/security/CustomUserDetailsService.java similarity index 93% rename from src/main/java/com/sudo/railo/global/security/CustomUserDetailsService.java rename to src/main/java/com/sudo/railo/auth/security/CustomUserDetailsService.java index f6d71897..a162448d 100644 --- a/src/main/java/com/sudo/railo/global/security/CustomUserDetailsService.java +++ b/src/main/java/com/sudo/railo/auth/security/CustomUserDetailsService.java @@ -1,4 +1,4 @@ -package com.sudo.railo.global.security; +package com.sudo.railo.auth.security; import java.util.Collections; @@ -12,7 +12,7 @@ import com.sudo.railo.global.exception.error.BusinessException; import com.sudo.railo.member.domain.Member; import com.sudo.railo.member.exception.MemberError; -import com.sudo.railo.member.infra.MemberRepository; +import com.sudo.railo.member.infrastructure.MemberRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/sudo/railo/global/security/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/sudo/railo/auth/security/jwt/JwtAccessDeniedHandler.java similarity index 94% rename from src/main/java/com/sudo/railo/global/security/jwt/JwtAccessDeniedHandler.java rename to src/main/java/com/sudo/railo/auth/security/jwt/JwtAccessDeniedHandler.java index 9055a463..e8a8177e 100644 --- a/src/main/java/com/sudo/railo/global/security/jwt/JwtAccessDeniedHandler.java +++ b/src/main/java/com/sudo/railo/auth/security/jwt/JwtAccessDeniedHandler.java @@ -1,4 +1,4 @@ -package com.sudo.railo.global.security.jwt; +package com.sudo.railo.auth.security.jwt; import java.io.IOException; diff --git a/src/main/java/com/sudo/railo/global/security/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/sudo/railo/auth/security/jwt/JwtAuthenticationEntryPoint.java similarity index 94% rename from src/main/java/com/sudo/railo/global/security/jwt/JwtAuthenticationEntryPoint.java rename to src/main/java/com/sudo/railo/auth/security/jwt/JwtAuthenticationEntryPoint.java index d810261d..4a1f9cbc 100644 --- a/src/main/java/com/sudo/railo/global/security/jwt/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/sudo/railo/auth/security/jwt/JwtAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package com.sudo.railo.global.security.jwt; +package com.sudo.railo.auth.security.jwt; import java.io.IOException; diff --git a/src/main/java/com/sudo/railo/global/security/jwt/JwtFilter.java b/src/main/java/com/sudo/railo/auth/security/jwt/JwtFilter.java similarity index 79% rename from src/main/java/com/sudo/railo/global/security/jwt/JwtFilter.java rename to src/main/java/com/sudo/railo/auth/security/jwt/JwtFilter.java index 8a9608bd..105d5253 100644 --- a/src/main/java/com/sudo/railo/global/security/jwt/JwtFilter.java +++ b/src/main/java/com/sudo/railo/auth/security/jwt/JwtFilter.java @@ -1,4 +1,4 @@ -package com.sudo.railo.global.security.jwt; +package com.sudo.railo.auth.security.jwt; import java.io.IOException; @@ -8,9 +8,9 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; +import com.sudo.railo.auth.exception.TokenError; import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.global.redis.RedisUtil; -import com.sudo.railo.global.security.TokenError; +import com.sudo.railo.global.redis.AuthRedisRepository; import io.jsonwebtoken.Claims; import jakarta.servlet.FilterChain; @@ -24,8 +24,8 @@ public class JwtFilter extends OncePerRequestFilter { private final TokenExtractor tokenExtractor; - private final TokenProvider tokenProvider; - private final RedisUtil redisUtil; + private final TokenValidator tokenValidator; + private final AuthRedisRepository authRedisRepository; // 각 요청에 대해 JWT 토큰을 검사하고 유효한 경우 SecurityContext에 인증 정보를 설정 @Override @@ -37,7 +37,7 @@ protected void doFilterInternal( String jwt = tokenExtractor.resolveToken(request); - if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { + if (StringUtils.hasText(jwt) && tokenValidator.validateToken(jwt)) { String tokenType = getTokenType(jwt); String requestUri = request.getRequestURI(); @@ -47,11 +47,11 @@ protected void doFilterInternal( } // 블랙리스트 형식으로 redis 에 해당 accessToken logout 여부 확인 - Object isLogout = redisUtil.getLogoutToken(jwt); + Object isLogout = authRedisRepository.getLogoutToken(jwt); // 로그아웃이 되어 있지 않은 경우 토큰 정상 작동 if (ObjectUtils.isEmpty(isLogout)) { - Authentication authentication = tokenProvider.getAuthentication(jwt); + Authentication authentication = tokenExtractor.getAuthentication(jwt); SecurityContextHolder.getContext().setAuthentication(authentication); } else { throw new BusinessException(TokenError.ALREADY_LOGOUT); @@ -62,7 +62,7 @@ protected void doFilterInternal( } private String getTokenType(String jwt) { - Claims claims = tokenProvider.parseClaims(jwt); + Claims claims = tokenExtractor.parseClaims(jwt); return claims.get("auth", String.class); } diff --git a/src/main/java/com/sudo/railo/auth/security/jwt/TokenExtractor.java b/src/main/java/com/sudo/railo/auth/security/jwt/TokenExtractor.java new file mode 100644 index 00000000..06997a83 --- /dev/null +++ b/src/main/java/com/sudo/railo/auth/security/jwt/TokenExtractor.java @@ -0,0 +1,121 @@ +package com.sudo.railo.auth.security.jwt; + +import java.security.Key; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.sudo.railo.auth.exception.TokenError; +import com.sudo.railo.global.exception.error.BusinessException; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; + +@Component +public class TokenExtractor { + + private final Key key; + + public static final String AUTHORITIES_KEY = "auth"; + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BEARER_PREFIX = "Bearer "; + + public TokenExtractor(@Value("${jwt.secret}") String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + /** + * 토큰 추출 + * */ + public String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + + return bearerToken.substring(BEARER_PREFIX.length()); + } + + return null; + } + + /** + * 회원번호 추출 + * */ + public String getMemberNo(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + /** + * AccessToken 유효시간 추출 + * */ + public Duration getAccessTokenExpiration(String accessToken) { + + Claims claims = parseClaims(accessToken); + + Date expiration = claims.getExpiration(); + + // 현재시간 기준으로 남은 유효시간 계산 + return Duration.ofMillis(expiration.getTime() - System.currentTimeMillis()); + } + + /** + * 권한 추출 + * */ + public Authentication getAuthentication(String accessToken) { + + Claims claims = parseClaims(accessToken); + + if (claims.get(AUTHORITIES_KEY) == null) { + throw new BusinessException(TokenError.AUTHORITY_NOT_FOUND); + } + + // 클레임에서 권한 정보 가져오기 + Collection authorities = + Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + UserDetails principal = new User(claims.getSubject(), "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } + + /** + * 클레임 추출 + * */ + public Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (ExpiredJwtException e) { + // 토큰이 만료되어 예외가 발생하더라도 클레임 값들은 뽑을 수 있음 + return e.getClaims(); + } + } + +} diff --git a/src/main/java/com/sudo/railo/global/security/jwt/TokenProvider.java b/src/main/java/com/sudo/railo/auth/security/jwt/TokenGenerator.java similarity index 53% rename from src/main/java/com/sudo/railo/global/security/jwt/TokenProvider.java rename to src/main/java/com/sudo/railo/auth/security/jwt/TokenGenerator.java index c7e166c1..3e5e03b7 100644 --- a/src/main/java/com/sudo/railo/global/security/jwt/TokenProvider.java +++ b/src/main/java/com/sudo/railo/auth/security/jwt/TokenGenerator.java @@ -1,55 +1,50 @@ -package com.sudo.railo.global.security.jwt; +package com.sudo.railo.auth.security.jwt; import java.security.Key; -import java.util.Arrays; -import java.util.Collection; import java.util.Date; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; +import com.sudo.railo.auth.application.dto.response.ReissueTokenResponse; +import com.sudo.railo.auth.application.dto.response.TokenResponse; +import com.sudo.railo.auth.exception.TokenError; import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.global.security.TokenError; -import com.sudo.railo.member.application.dto.response.ReissueTokenResponse; -import com.sudo.railo.member.application.dto.response.TokenResponse; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Header; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import lombok.extern.slf4j.Slf4j; -@Slf4j @Component -public class TokenProvider { +public class TokenGenerator { + + private final TokenValidator tokenValidator; + private final TokenExtractor tokenExtractor; + private final Key key; private static final String AUTHORITIES_KEY = "auth"; private static final String BEARER_TYPE = "Bearer"; private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분 private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 7일 private static final long TEMPORARY_TOKEN_EXPIRE_TIME = 1000 * 60 * 5; // 5분 - private final Key key; - public TokenProvider( - @Value("${jwt.secret}") String secretKey - ) { + public TokenGenerator(@Value("${jwt.secret}") String secretKey, TokenValidator tokenValidator, + TokenExtractor tokenExtractor) { byte[] keyBytes = Decoders.BASE64.decode(secretKey); this.key = Keys.hmacShaKeyFor(keyBytes); + this.tokenValidator = tokenValidator; + this.tokenExtractor = tokenExtractor; } - // 토큰 생성 메서드 + /** + * 토큰 생성 메서드 + * */ public TokenResponse generateTokenDTO(Authentication authentication) { // 권한들 가져오기 @@ -71,38 +66,16 @@ public TokenResponse generateTokenDTO(Authentication authentication) { ); } - private String generateAccessToken(String memberNo, String authorities) { - long now = System.currentTimeMillis(); - Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME); - return Jwts.builder() - .setHeaderParam(Header.TYPE, Header.JWT_TYPE) - .setSubject(memberNo) - .claim(AUTHORITIES_KEY, authorities) - .setExpiration(accessTokenExpiresIn) - .signWith(key, SignatureAlgorithm.HS512) - .compact(); - } - - private String generateRefreshToken(String memberNo, String authorities) { - long now = System.currentTimeMillis(); - Date refreshTokenExpiresIn = new Date(now + REFRESH_TOKEN_EXPIRE_TIME); - return Jwts.builder() - .setHeaderParam(Header.TYPE, Header.JWT_TYPE) - .setSubject(memberNo) - .claim(AUTHORITIES_KEY, authorities) - .setExpiration(refreshTokenExpiresIn) - .claim("isRefreshToken", true) // refreshToken 임을 나타내는 클레임 추가 - .signWith(key, SignatureAlgorithm.HS512) - .compact(); - } - + /** + * AccessToken 재발급 + * */ public ReissueTokenResponse reissueAccessToken(String refreshToken) { // 리프레시 토큰에서 사용자 정보 추출 -> 클레임 확인 - Claims claims = parseClaims(refreshToken); + Claims claims = tokenExtractor.parseClaims(refreshToken); // Refresh Token 검증 및 클레임에서 Refresh Token 여부 확인 - if (!validateToken(refreshToken) || claims.get("isRefreshToken") == null || !Boolean.TRUE.equals( + if (!tokenValidator.validateToken(refreshToken) || claims.get("isRefreshToken") == null || !Boolean.TRUE.equals( claims.get("isRefreshToken"))) { throw new BusinessException(TokenError.INVALID_REFRESH_TOKEN); } @@ -122,7 +95,9 @@ public ReissueTokenResponse reissueAccessToken(String refreshToken) { ); } - // 임시 토큰 발급 (5분) + /** + * 임시토큰 - TemporaryToken 생성 + * */ public String generateTemporaryToken(String memberNo) { long now = System.currentTimeMillis(); Date temporaryTokenExpiresIn = new Date(now + TEMPORARY_TOKEN_EXPIRE_TIME); @@ -135,79 +110,35 @@ public String generateTemporaryToken(String memberNo) { .compact(); } - // 토큰에 등록된 클레임의 sub에서 해당 회원 번호 추출 - public String getMemberNo(String accessToken) { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(accessToken) - .getBody() - .getSubject(); - } - - // accessToken 유효 시간 추출 - public Long getAccessTokenExpiration(String accessToken) { - - Claims claims = parseClaims(accessToken); - - Date expiration = claims.getExpiration(); - - // 현재시간 기준으로 남은 유효시간 계산 - return expiration.getTime() - System.currentTimeMillis(); - } - - // AccessToken으로 인증 객체 추출 - public Authentication getAuthentication(String accessToken) { - - Claims claims = parseClaims(accessToken); - - if (claims.get(AUTHORITIES_KEY) == null) { - throw new BusinessException(TokenError.AUTHORITY_NOT_FOUND); - } - - // 클레임에서 권한 정보 가져오기 - Collection authorities = - Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - - UserDetails principal = new User(claims.getSubject(), "", authorities); - - return new UsernamePasswordAuthenticationToken(principal, "", authorities); - } - - // 토큰 유효성 검사 - public boolean validateToken(String token) { - try { - Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token); - return true; - } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { - log.error("잘못된 JWT 서명입니다."); - } catch (ExpiredJwtException e) { - log.error("만료된 JWT 토큰입니다."); - } catch (UnsupportedJwtException e) { - log.error("지원되지 않는 JWT 토큰입니다."); - } catch (IllegalArgumentException e) { - log.error("JWT 토큰이 잘못되었습니다."); - } - return false; + /** + * AccessToken 생성 + * */ + private String generateAccessToken(String memberNo, String authorities) { + long now = System.currentTimeMillis(); + Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME); + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setSubject(memberNo) + .claim(AUTHORITIES_KEY, authorities) + .setExpiration(accessTokenExpiresIn) + .signWith(key, SignatureAlgorithm.HS512) + .compact(); } - // AccessToken에서 클레임을 추출하는 메서드 - protected Claims parseClaims(String accessToken) { - try { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(accessToken) - .getBody(); - } catch (ExpiredJwtException e) { - // 토큰이 만료되어 예외가 발생하더라도 클레임 값들은 뽑을 수 있음 - return e.getClaims(); - } + /** + * RefreshToken 생성 + * */ + private String generateRefreshToken(String memberNo, String authorities) { + long now = System.currentTimeMillis(); + Date refreshTokenExpiresIn = new Date(now + REFRESH_TOKEN_EXPIRE_TIME); + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setSubject(memberNo) + .claim(AUTHORITIES_KEY, authorities) + .setExpiration(refreshTokenExpiresIn) + .claim("isRefreshToken", true) // refreshToken 임을 나타내는 클레임 추가 + .signWith(key, SignatureAlgorithm.HS512) + .compact(); } } diff --git a/src/main/java/com/sudo/railo/auth/security/jwt/TokenValidator.java b/src/main/java/com/sudo/railo/auth/security/jwt/TokenValidator.java new file mode 100644 index 00000000..b1a40503 --- /dev/null +++ b/src/main/java/com/sudo/railo/auth/security/jwt/TokenValidator.java @@ -0,0 +1,48 @@ +package com.sudo.railo.auth.security.jwt; + +import java.security.Key; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class TokenValidator { + + private final Key key; + + public TokenValidator(@Value("${jwt.secret}") String secretKey) { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + /** + * 토큰 유효성 검사 + * */ + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.error("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + log.error("만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + log.error("지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + log.error("JWT 토큰이 잘못되었습니다."); + } + return false; + } +} diff --git a/src/main/java/com/sudo/railo/member/success/AuthSuccess.java b/src/main/java/com/sudo/railo/auth/success/AuthSuccess.java similarity index 86% rename from src/main/java/com/sudo/railo/member/success/AuthSuccess.java rename to src/main/java/com/sudo/railo/auth/success/AuthSuccess.java index 59b71cb1..62ac62d2 100644 --- a/src/main/java/com/sudo/railo/member/success/AuthSuccess.java +++ b/src/main/java/com/sudo/railo/auth/success/AuthSuccess.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.success; +package com.sudo.railo.auth.success; import org.springframework.http.HttpStatus; @@ -12,7 +12,7 @@ public enum AuthSuccess implements SuccessCode { SIGN_UP_SUCCESS(HttpStatus.CREATED, "회원가입이 성공적으로 완료되었습니다."), - MEMBER_NO_LOGIN_SUCCESS(HttpStatus.OK, "회원번호 로그인이 성공적으로 완료되었습니다."), + LOGIN_SUCCESS(HttpStatus.OK, "회원번호 로그인이 성공적으로 완료되었습니다."), LOGOUT_SUCCESS(HttpStatus.OK, "로그아웃이 성공적으로 완료되었습니다."), REISSUE_TOKEN_SUCCESS(HttpStatus.OK, "AccessToken 이 성공적으로 발급 되었습니다."), diff --git a/src/main/java/com/sudo/railo/auth/util/CookieManager.java b/src/main/java/com/sudo/railo/auth/util/CookieManager.java new file mode 100644 index 00000000..bc789df7 --- /dev/null +++ b/src/main/java/com/sudo/railo/auth/util/CookieManager.java @@ -0,0 +1,31 @@ +package com.sudo.railo.auth.util; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class CookieManager { + + private static final String COOKIE_PATH = "/"; + + @Value("${cookie.domain}") + private String cookieDomain; + + public void setCookie(HttpServletResponse response, String name, String value, int maxAge) { + String cookieHeader = String.format( + "%s=%s; Max-Age=%d; Path=%s; Domain=%s; Secure; HttpOnly; SameSite=None", + name, value, maxAge, COOKIE_PATH, cookieDomain + ); + response.setHeader("Set-Cookie", cookieHeader); // 중복 방지: addHeader → setHeader + } + + public void removeCookie(HttpServletResponse response, String name) { + String cookieHeader = String.format( + "%s=; Max-Age=0; Path=%s; Domain=%s; Secure; HttpOnly; SameSite=None", + name, COOKIE_PATH, cookieDomain + ); + response.setHeader("Set-Cookie", cookieHeader); // 중복 방지 + } +} diff --git a/src/main/java/com/sudo/railo/booking/application/CartReservationService.java b/src/main/java/com/sudo/railo/booking/application/CartReservationService.java index da30c11e..d9968aa1 100644 --- a/src/main/java/com/sudo/railo/booking/application/CartReservationService.java +++ b/src/main/java/com/sudo/railo/booking/application/CartReservationService.java @@ -11,13 +11,13 @@ import com.sudo.railo.booking.domain.CartReservation; import com.sudo.railo.booking.domain.Reservation; import com.sudo.railo.booking.exception.BookingError; -import com.sudo.railo.booking.infra.CartReservationRepository; -import com.sudo.railo.booking.infra.ReservationRepository; -import com.sudo.railo.booking.infra.ReservationRepositoryCustom; +import com.sudo.railo.booking.infrastructure.CartReservationRepository; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepository; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepositoryCustom; import com.sudo.railo.global.exception.error.BusinessException; import com.sudo.railo.member.domain.Member; import com.sudo.railo.member.exception.MemberError; -import com.sudo.railo.member.infra.MemberRepository; +import com.sudo.railo.member.infrastructure.MemberRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/sudo/railo/booking/application/FareCalculationService.java b/src/main/java/com/sudo/railo/booking/application/FareCalculationService.java index af3495ba..61964215 100644 --- a/src/main/java/com/sudo/railo/booking/application/FareCalculationService.java +++ b/src/main/java/com/sudo/railo/booking/application/FareCalculationService.java @@ -1,16 +1,27 @@ package com.sudo.railo.booking.application; import java.math.BigDecimal; +import java.util.List; import java.util.Map; import org.springframework.stereotype.Service; -import com.sudo.railo.booking.application.dto.request.FareCalculateRequest; -import com.sudo.railo.booking.domain.PassengerType; +import com.sudo.railo.booking.domain.type.PassengerSummary; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.train.domain.StationFare; +import com.sudo.railo.train.domain.type.CarType; +import com.sudo.railo.train.exception.TrainErrorCode; +import com.sudo.railo.train.infrastructure.StationFareRepository; + +import lombok.RequiredArgsConstructor; @Service +@RequiredArgsConstructor public class FareCalculationService { + private final StationFareRepository stationFareRepository; + private static final Map DISCOUNT_RATES = Map.of( PassengerType.ADULT, BigDecimal.valueOf(1.0), // 정상가 PassengerType.CHILD, BigDecimal.valueOf(0.6), // 10~40% 할인 @@ -21,13 +32,50 @@ public class FareCalculationService { PassengerType.VETERAN, BigDecimal.valueOf(0.5) // 연 6회 무임, 6회 초과 시 50% 할인 ); - /*** - * 승객 유형별로 내야 할 금액을 계산하는 메서드 - * @param request 승객 유형, 원래 운임을 포함하는 DTO - * @return 할인이 적용 된 운임 + /** + * 총 운임을 계산하는 메서드 + * @param departureStationId 출발역 ID + * @param arrivalStationId 도착역 ID + * @param passengers 승객 정보 + * @param carType 객차 타입 + * @return 할인이 적용 된 총 운임 + */ + public BigDecimal calculateFare( + Long departureStationId, + Long arrivalStationId, + List passengers, + CarType carType + ) { + // 요금 정보 조회 + StationFare stationFare = findStationFare(departureStationId, arrivalStationId); + + BigDecimal fare = getFareByCarType(stationFare, carType); + + return passengers.stream() + .filter(summary -> summary.getCount() > 0) + .map(summary -> fare + .multiply(DISCOUNT_RATES.get(summary.getPassengerType())) // 할인 적용 + .multiply(BigDecimal.valueOf(summary.getCount())) // 할인이 적용 된 운임 * 승객 수 + ) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * 구간 요금 정보 조회 + */ + private StationFare findStationFare(Long departureStationId, Long arrivalStationId) { + return stationFareRepository + .findByDepartureStationIdAndArrivalStationId(departureStationId, arrivalStationId) + .orElseThrow(() -> new BusinessException(TrainErrorCode.STATION_FARE_NOT_FOUND)); + } + + /** + * 객차 타입에 해당하는 운임 선택 */ - public BigDecimal calculateFare(FareCalculateRequest request) { - BigDecimal discountRate = DISCOUNT_RATES.get(request.passengerType()); - return request.fare().multiply(discountRate); + private static BigDecimal getFareByCarType(StationFare stationFare, CarType carType) { + return switch (carType) { + case STANDARD -> BigDecimal.valueOf(stationFare.getStandardFare()); + case FIRST_CLASS -> BigDecimal.valueOf(stationFare.getFirstClassFare()); + }; } } diff --git a/src/main/java/com/sudo/railo/booking/application/QrService.java b/src/main/java/com/sudo/railo/booking/application/QrService.java index 115b989f..77f38276 100644 --- a/src/main/java/com/sudo/railo/booking/application/QrService.java +++ b/src/main/java/com/sudo/railo/booking/application/QrService.java @@ -4,7 +4,7 @@ import com.sudo.railo.booking.domain.Qr; import com.sudo.railo.booking.exception.BookingError; -import com.sudo.railo.booking.infra.QrRepository; +import com.sudo.railo.booking.infrastructure.QrRepository; import com.sudo.railo.global.exception.error.BusinessException; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/sudo/railo/booking/application/ReservationApplicationService.java b/src/main/java/com/sudo/railo/booking/application/ReservationApplicationService.java index d00e6a1c..3cb79a80 100644 --- a/src/main/java/com/sudo/railo/booking/application/ReservationApplicationService.java +++ b/src/main/java/com/sudo/railo/booking/application/ReservationApplicationService.java @@ -4,21 +4,22 @@ import java.util.Comparator; import java.util.List; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.sudo.railo.booking.application.dto.request.ReservationCreateRequest; import com.sudo.railo.booking.application.dto.response.ReservationCreateResponse; -import com.sudo.railo.booking.domain.PassengerSummary; -import com.sudo.railo.booking.domain.PassengerType; import com.sudo.railo.booking.domain.Reservation; import com.sudo.railo.booking.domain.SeatReservation; +import com.sudo.railo.booking.domain.type.PassengerSummary; +import com.sudo.railo.booking.domain.type.PassengerType; import com.sudo.railo.booking.exception.BookingError; import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.train.domain.ScheduleStop; import com.sudo.railo.train.domain.Seat; import com.sudo.railo.train.infrastructure.SeatRepository; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @Service @@ -26,19 +27,20 @@ public class ReservationApplicationService { private final ReservationService reservationService; - private final TicketService ticketService; private final SeatReservationService seatReservationService; + private final TicketService ticketService; private final SeatRepository seatRepository; @Transactional - public ReservationCreateResponse createReservation(ReservationCreateRequest request, UserDetails userDetails) { + public ReservationCreateResponse createReservation(ReservationCreateRequest request, String memberNo) { // TODO: 요청 파라미터를 여기서 모두 검증할지, 각 서비스에서 검증할지 결정 필요 - Reservation reservation = reservationService.createReservation(request, userDetails); + Reservation reservation = reservationService.createReservation(request, memberNo); + validateStopSequence(reservation); // 승객 정보, 좌석 정보 정렬 (승객 정보는 PassengerType에 정의한 순서대로, 좌석 정보는 오름차순) - List passengers = request.passengers(); + List passengers = new ArrayList<>(request.passengers()); passengers.sort(Comparator.comparingInt(ps -> ps.getPassengerType().ordinal())); - List seatIds = request.seatIds(); + List seatIds = new ArrayList<>(request.seatIds()); seatIds.sort(Comparator.naturalOrder()); // 요청 승객 수와 선택한 좌석 수를 비교하여 좌석 수가 승객 수보다 많으면 오류 발생 @@ -58,12 +60,31 @@ public ReservationCreateResponse createReservation(ReservationCreateRequest requ for (int i = 0; i < passengerCnt && idx < seatIds.size(); i++, idx++) { Seat seat = seatRepository.findById(seatIds.get(idx)) .orElseThrow(() -> new BusinessException((BookingError.SEAT_NOT_FOUND))); - SeatReservation seatReservation = seatReservationService.reserveNewSeat(reservation, seat, - passengerType); - ticketService.createTicket(reservation, seatReservation, passengerType); + SeatReservation seatReservation = seatReservationService + .reserveNewSeat(reservation, seat, passengerType); seatReservationIds.add(seatReservation.getId()); } } return new ReservationCreateResponse(reservation.getId(), seatReservationIds); } + + @Transactional + public void cancelReservation(Reservation reservation) { + Long reservationId = reservation.getId(); + seatReservationService.deleteSeatReservationByReservationId(reservationId); + ticketService.deleteTicketByReservationId(reservationId); + } + + @Transactional + public void deleteReservationsByMember(Member member) { + reservationService.deleteAllByMemberId(member.getId()); + } + + private static void validateStopSequence(Reservation reservation) { + ScheduleStop departureStop = reservation.getDepartureStop(); + ScheduleStop arrivalStop = reservation.getArrivalStop(); + if (departureStop.getStopOrder() > arrivalStop.getStopOrder()) { + throw new BusinessException(BookingError.TRAIN_NOT_OPERATIONAL); + } + } } diff --git a/src/main/java/com/sudo/railo/booking/application/ReservationScheduler.java b/src/main/java/com/sudo/railo/booking/application/ReservationScheduler.java index 22a24a14..ab9b70bf 100644 --- a/src/main/java/com/sudo/railo/booking/application/ReservationScheduler.java +++ b/src/main/java/com/sudo/railo/booking/application/ReservationScheduler.java @@ -11,7 +11,7 @@ public class ReservationScheduler { private final ReservationService reservationService; - @Scheduled(cron = "0 * * * * *") // 매 분마다 실행 + @Scheduled(cron = "0 0 * * * *") // 매 시간마다 실행 public void expireReservations() { try { reservationService.expireReservations(); diff --git a/src/main/java/com/sudo/railo/booking/application/ReservationService.java b/src/main/java/com/sudo/railo/booking/application/ReservationService.java index eb0752fc..37fbae92 100644 --- a/src/main/java/com/sudo/railo/booking/application/ReservationService.java +++ b/src/main/java/com/sudo/railo/booking/application/ReservationService.java @@ -4,36 +4,40 @@ import java.security.SecureRandom; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; -import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.sudo.railo.booking.application.dto.ReservationInfo; import com.sudo.railo.booking.application.dto.projection.SeatReservationProjection; -import com.sudo.railo.booking.application.dto.request.FareCalculateRequest; import com.sudo.railo.booking.application.dto.request.ReservationCreateRequest; import com.sudo.railo.booking.application.dto.request.ReservationDeleteRequest; import com.sudo.railo.booking.application.dto.response.ReservationDetail; import com.sudo.railo.booking.application.dto.response.SeatReservationDetail; import com.sudo.railo.booking.config.BookingConfig; -import com.sudo.railo.booking.domain.PassengerSummary; import com.sudo.railo.booking.domain.Reservation; -import com.sudo.railo.booking.domain.ReservationStatus; +import com.sudo.railo.booking.domain.status.ReservationStatus; +import com.sudo.railo.booking.domain.type.PassengerSummary; import com.sudo.railo.booking.exception.BookingError; -import com.sudo.railo.booking.infra.ReservationRepository; -import com.sudo.railo.booking.infra.ReservationRepositoryCustom; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepository; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepositoryCustom; import com.sudo.railo.global.exception.error.BusinessException; import com.sudo.railo.member.domain.Member; -import com.sudo.railo.member.exception.MemberError; -import com.sudo.railo.member.infra.MemberRepository; -import com.sudo.railo.train.domain.Station; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.train.domain.ScheduleStop; import com.sudo.railo.train.domain.TrainSchedule; import com.sudo.railo.train.domain.status.OperationStatus; +import com.sudo.railo.train.domain.type.CarType; import com.sudo.railo.train.exception.TrainErrorCode; -import com.sudo.railo.train.infrastructure.StationRepository; +import com.sudo.railo.train.infrastructure.ScheduleStopRepository; +import com.sudo.railo.train.infrastructure.SeatRepository; import com.sudo.railo.train.infrastructure.TrainScheduleRepository; import lombok.RequiredArgsConstructor; @@ -47,151 +51,246 @@ public class ReservationService { private final FareCalculationService fareCalculationService; private final TrainScheduleRepository trainScheduleRepository; private final MemberRepository memberRepository; - private final StationRepository stationRepository; + private final ScheduleStopRepository scheduleStopRepository; private final ReservationRepository reservationRepository; private final ReservationRepositoryCustom reservationRepositoryCustom; + private final SeatRepository seatRepository; - /*** - * 고객용 예매번호를 생성하는 메서드 - * @return 고객용 예매번호 + /** + * 예약을 생성하는 메서드 + * @param request 예약 생성 요청 DTO + * @return 예약 레코드 */ - private String generateReservationCode() { - // yyyyMMddHHmmss<랜덤4자리> 형식 - LocalDateTime now = LocalDateTime.now(); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); - String dateTimeStr = now.format(formatter); + @Transactional + public Reservation createReservation(ReservationCreateRequest request, String memberNo) { + TrainSchedule trainSchedule = getTrainSchedule(request); + Member member = memberRepository.getMember(memberNo); + ScheduleStop departureStop = getStopStation(trainSchedule, request.departureStationId()); + ScheduleStop arrivalStop = getStopStation(trainSchedule, request.arrivalStationId()); + CarType carType = findCarType(request.seatIds()); + BigDecimal totalFare = getTotalFare(request, carType); - String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - StringBuilder randomStr = new StringBuilder(); - SecureRandom secureRandom = new SecureRandom(); - for (int i = 0; i < 4; i++) { - int idx = secureRandom.nextInt(chars.length()); - randomStr.append(chars.charAt(idx)); + validateTrainOperating(trainSchedule); + + Reservation reservation = generateReservation( + request, trainSchedule, member, departureStop, arrivalStop, totalFare + ); + return reservationRepository.save(reservation); + } + + /** + * 예약을 조회하는 메서드 + * @param memberNo 회원 번호 + * @param reservationId 예약 ID + * @return 예약 + */ + @Transactional + public ReservationDetail getReservation(String memberNo, Long reservationId) { + Member member = memberRepository.getMember(memberNo); + + List reservationInfos = reservationRepositoryCustom.findReservationDetail( + member.getId(), List.of(reservationId)); + + if (reservationInfos.isEmpty()) { + throw new BusinessException(BookingError.RESERVATION_NOT_FOUND); } - return dateTimeStr + randomStr; + + ReservationInfo reservationInfo = reservationInfos.get(0); + + // 만료된 예약이면 삭제 처리 + LocalDateTime now = LocalDateTime.now(); + if (isExpired(reservationInfo, now)) { + deleteReservation(reservationId); + throw new BusinessException(BookingError.RESERVATION_EXPIRED); + } + + return convertToReservationDetail(reservationInfo); } - /*** - * 예약을 생성하는 메서드 - * @param request 예약 생성 요청 DTO - * @return 예약 레코드 + /** + * 예약 목록을 조회하는 메서드 + * @param memberNo 회원 번호 + * @return 예약 목록 */ @Transactional - public Reservation createReservation(ReservationCreateRequest request, UserDetails userDetails) { - try { - TrainSchedule trainSchedule = trainScheduleRepository.findById(request.trainScheduleId()) - .orElseThrow(() -> new BusinessException((TrainErrorCode.TRAIN_SCHEDULE_NOT_FOUND))); + public List getReservations(String memberNo) { + Member member = memberRepository.getMember(memberNo); - if (trainSchedule.getOperationStatus() == OperationStatus.CANCELLED) { - throw new BusinessException(TrainErrorCode.TRAIN_OPERATION_CANCELLED); - } + // 예약 조회 + List reservationInfos = reservationRepositoryCustom.findReservationDetail(member.getId()); - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - - Station departureStation = stationRepository.findById(request.departureStationId()) - .orElseThrow(() -> new BusinessException(TrainErrorCode.STATION_NOT_FOUND)); - - Station arrivalStation = stationRepository.findById(request.arrivalStationId()) - .orElseThrow(() -> new BusinessException(TrainErrorCode.STATION_NOT_FOUND)); - - List passengerSummary = request.passengers(); - LocalDateTime now = LocalDateTime.now(); - Reservation reservation = Reservation.builder() - .trainSchedule(trainSchedule) - .member(member) - .reservationCode(generateReservationCode()) - .tripType(request.tripType()) - .totalPassengers(passengerSummary.stream().mapToInt(PassengerSummary::getCount).sum()) - .passengerSummary(objectMapper.writeValueAsString(passengerSummary)) - .reservationStatus(ReservationStatus.RESERVED) - .expiresAt(now.plusMinutes(bookingConfig.getExpiration().getReservation())) - .reservedAt(now) - .departureStation(departureStation) - .arrivalStation(arrivalStation) - .build(); - return reservationRepository.save(reservation); - } catch (Exception e) { - throw new BusinessException(BookingError.RESERVATION_CREATE_FAILED); + // 만료된 예약이면 삭제 처리 + LocalDateTime now = LocalDateTime.now(); + List expiredReservationIds = new ArrayList<>(); + List validReservations = reservationInfos.stream() + .filter(info -> { + if (isExpired(info, now)) { + expiredReservationIds.add(info.reservationId()); + return false; + } + return true; + }) + .toList(); + + if (!expiredReservationIds.isEmpty()) { + deleteReservation(expiredReservationIds); } + + return convertToReservationDetail(validReservations); } - /*** - * 예약 번호로 예약을 삭제하는 메서드 + /** + * 특정 예약을 삭제하는 메서드 - DTO 사용 * @param request 예약 삭제 요청 DTO */ @Transactional public void deleteReservation(ReservationDeleteRequest request) { try { - reservationRepository.deleteById(request.reservationId()); + deleteReservation(request.reservationId()); } catch (Exception e) { throw new BusinessException(BookingError.RESERVATION_DELETE_FAILED); } } - /*** + /** + * 특정 예약을 삭제하는 메서드 - 단수 예약 ID 사용 + * @param reservationId 삭제할 예약의 ID + */ + private void deleteReservation(Long reservationId) { + reservationRepository.deleteById(reservationId); + } + + /** + * 다수의 예약을 삭제하는 메서드 - 복수 예약 ID 사용 + * @param reservationIds 삭제할 예약의 ID를 원소로 하는 리스트 + */ + private void deleteReservation(List reservationIds) { + reservationRepository.deleteAllByIdInBatch(reservationIds); + } + + /** * 만료된 예약을 일괄삭제하는 메서드 */ @Transactional public void expireReservations() { LocalDateTime now = LocalDateTime.now(); - reservationRepository.deleteAllByExpiresAtBeforeAndReservationStatusNot(now, ReservationStatus.PAID); + int pageNumber = 0; + final int pageSize = 500; + Page expiredPage; + do { + Pageable pageable = PageRequest.of(pageNumber, pageSize); + expiredPage = reservationRepository + .findAllByExpiresAtBeforeAndReservationStatus(now, ReservationStatus.RESERVED, pageable); + if (expiredPage.hasContent()) { + List expiredList = expiredPage.getContent() + .stream() + .map(Reservation::getId) + .toList(); + reservationRepository.deleteAllByIdInBatch(expiredList); + } + pageNumber++; + } while (expiredPage.hasNext()); } /** - * 예약을 조회하는 메서드 - * @param memberNo 회원 번호 - * @param reservationId 예약 ID - * @return 예약 + * 예약 정보와 주어진 시간을 기준으로 예약이 만료되었는지 판단하는 메서드 + * @param reservationInfo 예약 정보 + * @param now 판단 기준이 될 시간 + * @return 만료 여부 */ - @Transactional(readOnly = true) - public ReservationDetail getReservation(String memberNo, Long reservationId) { - Member member = memberRepository.findByMemberNo(memberNo) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + private boolean isExpired(ReservationInfo reservationInfo, LocalDateTime now) { + return reservationInfo.expiresAt().isBefore(now); + } - List reservationInfos = reservationRepositoryCustom.findReservationDetail( - member.getId(), List.of(reservationId)); + /*** + * 고객용 예매번호를 생성하는 메서드 + * @return 고객용 예매번호 + */ + private String generateReservationCode() { + // yyyyMMddHHmmss<랜덤4자리> 형식 + LocalDateTime now = LocalDateTime.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + String dateTimeStr = now.format(formatter); - if (reservationInfos.isEmpty()) { - throw new BusinessException(BookingError.RESERVATION_NOT_FOUND); + String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + StringBuilder randomStr = new StringBuilder(); + SecureRandom secureRandom = new SecureRandom(); + for (int i = 0; i < 4; i++) { + int idx = secureRandom.nextInt(chars.length()); + randomStr.append(chars.charAt(idx)); } + return dateTimeStr + randomStr; + } + + private ScheduleStop getStopStation(TrainSchedule trainSchedule, Long request) { + return scheduleStopRepository.findByTrainScheduleIdAndStationId(trainSchedule.getId(), request) + .orElseThrow(() -> new BusinessException(TrainErrorCode.STATION_NOT_FOUND)); + } - return convertToReservationDetail(reservationInfos).get(0); + private TrainSchedule getTrainSchedule(ReservationCreateRequest request) { + return trainScheduleRepository.findById(request.trainScheduleId()) + .orElseThrow(() -> new BusinessException(TrainErrorCode.TRAIN_SCHEDULE_NOT_FOUND)); + } + + private BigDecimal getTotalFare(ReservationCreateRequest request, CarType carType) { + return fareCalculationService.calculateFare( + request.departureStationId(), + request.arrivalStationId(), + request.passengers(), + carType + ); } /** - * 예약 목록을 조회하는 메서드 - * @param memberNo 회원 번호 - * @return 예약 목록 + * 객차 타입 조회 */ - @Transactional(readOnly = true) - public List getReservations(String memberNo) { - Member member = memberRepository.findByMemberNo(memberNo) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + private CarType findCarType(List seatIds) { + List carTypes = seatRepository.findCarTypes(seatIds); - // 예약 조회 - List reservationInfos = reservationRepositoryCustom.findReservationDetail(member.getId()); - return convertToReservationDetail(reservationInfos); + // 입석 체크 + if (seatIds.isEmpty()) { + return CarType.STANDARD; + } + + if (carTypes.isEmpty()) { + throw new BusinessException(BookingError.SEAT_NOT_FOUND); + } + + if (carTypes.size() != 1) { + throw new BusinessException(BookingError.INVALID_CAR_TYPE); + } + return carTypes.get(0); + } + + private static void validateTrainOperating(TrainSchedule trainSchedule) { + if (trainSchedule.getOperationStatus() == OperationStatus.CANCELLED) { + throw new BusinessException(TrainErrorCode.TRAIN_OPERATION_CANCELLED); + } } public List convertToReservationDetail(List reservationInfos) { return reservationInfos.stream() - .map(info -> ReservationDetail.of( - info.reservationId(), - info.reservationCode(), - String.format("%03d", info.trainNumber()), - info.trainName(), - info.departureStationName(), - info.arrivalStationName(), - info.departureTime(), - info.arrivalTime(), - info.operationDate(), - info.expiresAt(), - convertToSeatReservationDetail(info.seats()) - )) + .map(this::convertToReservationDetail) .toList(); } + public ReservationDetail convertToReservationDetail(ReservationInfo reservationInfo) { + return ReservationDetail.of( + reservationInfo.reservationId(), + reservationInfo.reservationCode(), + String.format("%03d", reservationInfo.trainNumber()), + reservationInfo.trainName(), + reservationInfo.departureStationName(), + reservationInfo.arrivalStationName(), + reservationInfo.departureTime(), + reservationInfo.arrivalTime(), + reservationInfo.operationDate(), + reservationInfo.expiresAt(), + reservationInfo.fare(), + convertToSeatReservationDetail(reservationInfo.seats()) + ); + } + private List convertToSeatReservationDetail(List projection) { return projection.stream() .map(p -> SeatReservationDetail.of( @@ -199,14 +298,43 @@ private List convertToSeatReservationDetail(List new BusinessException(BookingError.SEAT_NOT_FOUND)); + + // 2. 락이 걸린 상태에서 해당 좌석의 기존 예약들을 비관적 락으로 조회 + List existingReservations = seatReservationRepository + .findByTrainScheduleAndSeatWithLock(trainScheduleId, seatId); + + // 3. 락이 걸린 상태에서 충돌 검증 (원자성 보장) + validateConflictWithExistingReservations(reservation, existingReservations); + SeatReservation seatReservation = SeatReservation.builder() .trainSchedule(reservation.getTrainSchedule()) - .seat(seat) + .seat(lockedSeat) .reservation(reservation) .passengerType(passengerType) - .seatStatus(seatStatus) - .reservedAt(reservedAt) - .departureStation(reservation.getDepartureStation()) - .arrivalStation(reservation.getArrivalStation()) .build(); return seatReservationRepository.save(seatReservation); } catch (OptimisticLockException | DataIntegrityViolationException e) { // 동시성 문제 및 유니크 제약 위반 발생 throw new BusinessException(BookingError.SEAT_ALREADY_RESERVED); - } catch (Exception e) { - // 알 수 없는 모든 경우는 실패 처리 - throw new BusinessException(BookingError.SEAT_RESERVATION_FAILED); } } + + @Transactional + public void deleteSeatReservation(Long seatReservationId) { + SeatReservation seatReservation = seatReservationRepository.findById(seatReservationId) + .orElseThrow(() -> new BusinessException(BookingError.SEAT_RESERVATION_NOT_FOUND)); + seatReservationRepository.delete(seatReservation); + } + + @Transactional + public void deleteSeatReservationByReservationId(Long reservationId) { + seatReservationRepository.deleteAllByReservationId(reservationId); + } + + /** + * 기존 예약들과 충돌 검증 (락이 걸린 상태에서 수행) + */ + private void validateConflictWithExistingReservations( + Reservation newReservation, + List existingReservations + ) { + int newDepartureOrder = newReservation.getDepartureStop().getStopOrder(); + int newArrivalOrder = newReservation.getArrivalStop().getStopOrder(); + + existingReservations.forEach(existingReservation -> { + int existingDepartureOrder = existingReservation.getReservation().getDepartureStop().getStopOrder(); + int existingArrivalOrder = existingReservation.getReservation().getArrivalStop().getStopOrder(); + if (existingDepartureOrder < newArrivalOrder && existingArrivalOrder > newDepartureOrder) { + throw new BusinessException(BookingError.SEAT_ALREADY_RESERVED); + } + }); + } } diff --git a/src/main/java/com/sudo/railo/booking/application/TicketService.java b/src/main/java/com/sudo/railo/booking/application/TicketService.java index 01277770..4dafcb6b 100644 --- a/src/main/java/com/sudo/railo/booking/application/TicketService.java +++ b/src/main/java/com/sudo/railo/booking/application/TicketService.java @@ -2,24 +2,23 @@ import java.util.List; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.sudo.railo.booking.application.dto.response.TicketReadResponse; -import com.sudo.railo.booking.domain.PassengerType; -import com.sudo.railo.booking.domain.PaymentStatus; import com.sudo.railo.booking.domain.Qr; import com.sudo.railo.booking.domain.Reservation; -import com.sudo.railo.booking.domain.SeatReservation; import com.sudo.railo.booking.domain.Ticket; -import com.sudo.railo.booking.domain.TicketStatus; +import com.sudo.railo.booking.domain.status.TicketStatus; +import com.sudo.railo.booking.domain.type.PassengerType; import com.sudo.railo.booking.exception.BookingError; -import com.sudo.railo.booking.infra.TicketRepository; -import com.sudo.railo.booking.infra.TicketRepositoryCustom; +import com.sudo.railo.booking.infrastructure.ticket.TicketRepository; +import com.sudo.railo.booking.infrastructure.ticket.TicketRepositoryCustom; import com.sudo.railo.global.exception.error.BusinessException; import com.sudo.railo.member.domain.Member; import com.sudo.railo.member.exception.MemberError; -import com.sudo.railo.member.infra.MemberRepository; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.train.domain.Seat; import lombok.RequiredArgsConstructor; @@ -37,15 +36,14 @@ public class TicketService { * @param reservation 예약 정보 * @param passengerType 승객 유형 */ - public void createTicket(Reservation reservation, SeatReservation seatReservation, PassengerType passengerType) { + public void createTicket(Reservation reservation, Seat seat, PassengerType passengerType) { Qr qr = qrService.createQr(); Ticket ticket = Ticket.builder() .reservation(reservation) - .seatReservation(seatReservation) + .seat(seat) .qr(qr) .passengerType(passengerType) - .paymentStatus(PaymentStatus.RESERVED) - .status(TicketStatus.ISSUED) + .ticketStatus(TicketStatus.ISSUED) .build(); try { ticketRepository.save(ticket); @@ -54,14 +52,25 @@ public void createTicket(Reservation reservation, SeatReservation seatReservatio } } - public List getMyTickets(UserDetails userDetails) { - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) + public List getMyTickets(String username) { + Member member = memberRepository.findByMemberNo(username) .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); try { - // List tickets = ticketRepository.findByReservationMemberId(member.getId()); return ticketRepositoryCustom.findPaidTicketResponsesByMemberId(member.getId()); } catch (Exception e) { throw new BusinessException(BookingError.TICKET_LIST_GET_FAILED); } } + + @Transactional + public void deleteTicketById(Long ticketId) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new BusinessException(BookingError.TICKET_NOT_FOUND)); + ticketRepository.delete(ticket); + } + + @Transactional + public void deleteTicketByReservationId(Long reservationId) { + ticketRepository.deleteAllByReservationId(reservationId); + } } diff --git a/src/main/java/com/sudo/railo/booking/application/dto/ReservationInfo.java b/src/main/java/com/sudo/railo/booking/application/dto/ReservationInfo.java index 61df280f..7fe2445e 100644 --- a/src/main/java/com/sudo/railo/booking/application/dto/ReservationInfo.java +++ b/src/main/java/com/sudo/railo/booking/application/dto/ReservationInfo.java @@ -19,6 +19,7 @@ public record ReservationInfo( LocalTime arrivalTime, LocalDate operationDate, LocalDateTime expiresAt, + int fare, List seats ) { @@ -37,6 +38,7 @@ public static ReservationInfo of( projection.getArrivalTime(), projection.getOperationDate(), projection.getExpiresAt(), + projection.getFare(), seats ); } diff --git a/src/main/java/com/sudo/railo/booking/application/dto/projection/ReservationProjection.java b/src/main/java/com/sudo/railo/booking/application/dto/projection/ReservationProjection.java index 6f2b28e7..fc341a88 100644 --- a/src/main/java/com/sudo/railo/booking/application/dto/projection/ReservationProjection.java +++ b/src/main/java/com/sudo/railo/booking/application/dto/projection/ReservationProjection.java @@ -21,8 +21,7 @@ public class ReservationProjection { private final LocalTime arrivalTime; private final LocalDate operationDate; private final LocalDateTime expiresAt; - private final int standardFare; - private final int firstClassFare; + private final int fare; @QueryProjection public ReservationProjection( @@ -36,8 +35,7 @@ public ReservationProjection( LocalTime arrivalTime, LocalDate operationDate, LocalDateTime expiresAt, - int standardFare, - int firstClassFare + int fare ) { this.reservationId = reservationId; this.reservationCode = reservationCode; @@ -49,7 +47,6 @@ public ReservationProjection( this.arrivalTime = arrivalTime; this.operationDate = operationDate; this.expiresAt = expiresAt; - this.standardFare = standardFare; - this.firstClassFare = firstClassFare; + this.fare = fare; } } diff --git a/src/main/java/com/sudo/railo/booking/application/dto/projection/SeatInfoProjection.java b/src/main/java/com/sudo/railo/booking/application/dto/projection/SeatInfoProjection.java new file mode 100644 index 00000000..2f0c0d52 --- /dev/null +++ b/src/main/java/com/sudo/railo/booking/application/dto/projection/SeatInfoProjection.java @@ -0,0 +1,20 @@ +package com.sudo.railo.booking.application.dto.projection; + +import com.querydsl.core.annotations.QueryProjection; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.train.domain.Seat; + +import lombok.Getter; + +@Getter +public class SeatInfoProjection { + + private final Seat seat; + private final PassengerType passengerType; + + @QueryProjection + public SeatInfoProjection(Seat seat, PassengerType passengerType) { + this.seat = seat; + this.passengerType = passengerType; + } +} diff --git a/src/main/java/com/sudo/railo/booking/application/dto/projection/SeatReservationProjection.java b/src/main/java/com/sudo/railo/booking/application/dto/projection/SeatReservationProjection.java index dbfdb128..925caab3 100644 --- a/src/main/java/com/sudo/railo/booking/application/dto/projection/SeatReservationProjection.java +++ b/src/main/java/com/sudo/railo/booking/application/dto/projection/SeatReservationProjection.java @@ -1,7 +1,7 @@ package com.sudo.railo.booking.application.dto.projection; import com.querydsl.core.annotations.QueryProjection; -import com.sudo.railo.booking.domain.PassengerType; +import com.sudo.railo.booking.domain.type.PassengerType; import com.sudo.railo.train.domain.type.CarType; import lombok.Getter; @@ -15,7 +15,6 @@ public class SeatReservationProjection { private final int carNumber; private final CarType carType; private final String seatNumber; - private final int fare; @QueryProjection public SeatReservationProjection( @@ -24,8 +23,7 @@ public SeatReservationProjection( PassengerType passengerType, int carNumber, CarType carType, - String seatNumber, - int fare + String seatNumber ) { this.seatReservationId = seatReservationId; this.reservationId = reservationId; @@ -33,18 +31,5 @@ public SeatReservationProjection( this.carNumber = carNumber; this.carType = carType; this.seatNumber = seatNumber; - this.fare = fare; - } - - public SeatReservationProjection withFare(int fare) { - return new SeatReservationProjection( - this.seatReservationId, - this.reservationId, - this.passengerType, - this.carNumber, - this.carType, - this.seatNumber, - fare - ); } } diff --git a/src/main/java/com/sudo/railo/booking/application/dto/request/FareCalculateRequest.java b/src/main/java/com/sudo/railo/booking/application/dto/request/FareCalculateRequest.java deleted file mode 100644 index 6edafc99..00000000 --- a/src/main/java/com/sudo/railo/booking/application/dto/request/FareCalculateRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.sudo.railo.booking.application.dto.request; - -import java.math.BigDecimal; - -import com.sudo.railo.booking.domain.PassengerType; - -import jakarta.validation.constraints.NotNull; - -public record FareCalculateRequest( - @NotNull(message = "승객 유형은 필수입니다") - PassengerType passengerType, - - @NotNull(message = "원래 운임은 필수입니다") - BigDecimal fare -) { -} diff --git a/src/main/java/com/sudo/railo/booking/application/dto/request/ReservationCreateRequest.java b/src/main/java/com/sudo/railo/booking/application/dto/request/ReservationCreateRequest.java index fcaa9f14..83384d81 100644 --- a/src/main/java/com/sudo/railo/booking/application/dto/request/ReservationCreateRequest.java +++ b/src/main/java/com/sudo/railo/booking/application/dto/request/ReservationCreateRequest.java @@ -2,8 +2,8 @@ import java.util.List; -import com.sudo.railo.booking.domain.PassengerSummary; -import com.sudo.railo.booking.domain.TripType; +import com.sudo.railo.booking.domain.type.PassengerSummary; +import com.sudo.railo.booking.domain.type.TripType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/sudo/railo/booking/application/dto/response/ReservationDetail.java b/src/main/java/com/sudo/railo/booking/application/dto/response/ReservationDetail.java index b292dbbe..e7c84e7d 100644 --- a/src/main/java/com/sudo/railo/booking/application/dto/response/ReservationDetail.java +++ b/src/main/java/com/sudo/railo/booking/application/dto/response/ReservationDetail.java @@ -40,6 +40,9 @@ public record ReservationDetail( @Schema(description = "만료시간", example = "2025-07-01 17:38:59.984686") LocalDateTime expiresAt, + @Schema(description = "운임 (원)", example = "29000") + int fare, + @Schema(description = "예약 좌석 정보") List seats ) { @@ -55,6 +58,7 @@ public static ReservationDetail of( LocalTime arrivalTime, LocalDate operationDate, LocalDateTime expiresAt, + int fare, List seats ) { return new ReservationDetail( @@ -68,6 +72,7 @@ public static ReservationDetail of( arrivalTime, operationDate, expiresAt, + fare, seats ); } diff --git a/src/main/java/com/sudo/railo/booking/application/dto/response/SeatReservationDetail.java b/src/main/java/com/sudo/railo/booking/application/dto/response/SeatReservationDetail.java index f9fbc9b1..55ae5a9f 100644 --- a/src/main/java/com/sudo/railo/booking/application/dto/response/SeatReservationDetail.java +++ b/src/main/java/com/sudo/railo/booking/application/dto/response/SeatReservationDetail.java @@ -1,6 +1,6 @@ package com.sudo.railo.booking.application.dto.response; -import com.sudo.railo.booking.domain.PassengerType; +import com.sudo.railo.booking.domain.type.PassengerType; import com.sudo.railo.train.domain.type.CarType; import io.swagger.v3.oas.annotations.media.Schema; @@ -21,13 +21,7 @@ public record SeatReservationDetail( CarType carType, @Schema(description = "좌석 번호 (행 + 열)", example = "1D") - String seatNumber, - - @Schema(description = "기본 요금 (원)", example = "59800") - int baseFare, - - @Schema(description = "요금 (원)", example = "29900") - int fare + String seatNumber ) { public static SeatReservationDetail of( @@ -35,18 +29,14 @@ public static SeatReservationDetail of( PassengerType passengerType, int carNumber, CarType carType, - String seatNumber, - int baseFare, - int fare + String seatNumber ) { return new SeatReservationDetail( seatReservationId, passengerType, carNumber, carType, - seatNumber, - baseFare, - fare + seatNumber ); } } diff --git a/src/main/java/com/sudo/railo/booking/application/dto/response/TicketReadResponse.java b/src/main/java/com/sudo/railo/booking/application/dto/response/TicketReadResponse.java index 470382d1..4f73cfcb 100644 --- a/src/main/java/com/sudo/railo/booking/application/dto/response/TicketReadResponse.java +++ b/src/main/java/com/sudo/railo/booking/application/dto/response/TicketReadResponse.java @@ -9,7 +9,6 @@ public record TicketReadResponse( Long ticketId, Long reservationId, - Long seatReservationId, LocalDate operationDate, Long departureStationId, String departureStationName, diff --git a/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentCancelledEvent.java b/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentCancelledEvent.java deleted file mode 100644 index 2677cfc8..00000000 --- a/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentCancelledEvent.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.sudo.railo.booking.application.event; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -/** - * Booking 도메인 전용 결제 취소 이벤트 - * - * Payment 도메인의 PaymentStateChangedEvent로부터 변환되어 생성됩니다. - * 결제 취소 시 예약 및 티켓 취소에 필요한 정보를 포함합니다. - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class BookingPaymentCancelledEvent { - - private String paymentId; - private Long reservationId; - private String reason; - private LocalDateTime cancelledAt; - - /** - * 간편 생성 팩토리 메서드 - */ - public static BookingPaymentCancelledEvent of(String paymentId, Long reservationId, String reason, LocalDateTime cancelledAt) { - return BookingPaymentCancelledEvent.builder() - .paymentId(paymentId) - .reservationId(reservationId) - .reason(reason) - .cancelledAt(cancelledAt) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentCompletedEvent.java b/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentCompletedEvent.java deleted file mode 100644 index c97f9fff..00000000 --- a/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentCompletedEvent.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.sudo.railo.booking.application.event; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -/** - * Booking 도메인 전용 결제 완료 이벤트 - * - * Payment 도메인의 PaymentStateChangedEvent로부터 변환되어 생성됩니다. - * Booking 도메인에서 필요한 최소한의 정보만 포함합니다. - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class BookingPaymentCompletedEvent { - - private String paymentId; - private Long reservationId; - private LocalDateTime completedAt; - - /** - * 간편 생성 팩토리 메서드 - */ - public static BookingPaymentCompletedEvent of(String paymentId, Long reservationId, LocalDateTime completedAt) { - return BookingPaymentCompletedEvent.builder() - .paymentId(paymentId) - .reservationId(reservationId) - .completedAt(completedAt) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentFailedEvent.java b/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentFailedEvent.java deleted file mode 100644 index 2b342533..00000000 --- a/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentFailedEvent.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.sudo.railo.booking.application.event; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -/** - * Booking 도메인 전용 결제 실패 이벤트 - * - * Payment 도메인의 PaymentStateChangedEvent로부터 변환되어 생성됩니다. - * 결제 실패 시 예약 상태 업데이트에 필요한 정보를 포함합니다. - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class BookingPaymentFailedEvent { - - private String paymentId; - private Long reservationId; - private String reason; - private LocalDateTime failedAt; - - /** - * 간편 생성 팩토리 메서드 - */ - public static BookingPaymentFailedEvent of(String paymentId, Long reservationId, String reason, LocalDateTime failedAt) { - return BookingPaymentFailedEvent.builder() - .paymentId(paymentId) - .reservationId(reservationId) - .reason(reason) - .failedAt(failedAt) - .build(); - } - - /** - * 실패 사유 조회 (하위 호환성) - * getReason()의 별칭 - */ - public String getFailureReason() { - return reason; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentRefundedEvent.java b/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentRefundedEvent.java deleted file mode 100644 index 9565979f..00000000 --- a/src/main/java/com/sudo/railo/booking/application/event/BookingPaymentRefundedEvent.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.sudo.railo.booking.application.event; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -/** - * Booking 도메인 전용 환불 이벤트 - * - * Payment 도메인의 PaymentStateChangedEvent로부터 변환되어 생성됩니다. - * 환불 시 예약 및 티켓 상태 업데이트에 필요한 정보를 포함합니다. - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class BookingPaymentRefundedEvent { - - private String paymentId; - private Long reservationId; - private String reason; - private LocalDateTime refundedAt; - - /** - * 간편 생성 팩토리 메서드 - */ - public static BookingPaymentRefundedEvent of(String paymentId, Long reservationId, String reason, LocalDateTime refundedAt) { - return BookingPaymentRefundedEvent.builder() - .paymentId(paymentId) - .reservationId(reservationId) - .reason(reason) - .refundedAt(refundedAt) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/booking/application/event/PaymentEventListener.java b/src/main/java/com/sudo/railo/booking/application/event/PaymentEventListener.java deleted file mode 100644 index 3bcae8d6..00000000 --- a/src/main/java/com/sudo/railo/booking/application/event/PaymentEventListener.java +++ /dev/null @@ -1,245 +0,0 @@ -package com.sudo.railo.booking.application.event; - -import java.util.Arrays; -import java.util.List; - -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -import com.sudo.railo.booking.domain.Reservation; -import com.sudo.railo.booking.domain.Ticket; -import com.sudo.railo.booking.infra.ReservationRepository; -import com.sudo.railo.booking.infra.TicketRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * 결제 이벤트 리스너 (Booking 도메인) - * - * PaymentEventTranslator가 변환한 Booking 전용 이벤트를 수신하여 - * Reservation과 Ticket 상태를 업데이트합니다. - * - * Event Translator 패턴을 통해 Payment 도메인과의 결합도를 낮추고, - * Booking 도메인에 필요한 정보만 전달받습니다. - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class PaymentEventListener { - - private final ReservationRepository reservationRepository; - private final TicketRepository ticketRepository; - - /** - * 결제 완료 이벤트 처리 - * Reservation: RESERVED -> PAID - * Ticket: RESERVED -> PAID - */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handlePaymentCompleted(BookingPaymentCompletedEvent event) { - log.info("결제 완료 이벤트 수신 - paymentId: {}, reservationId: {}", - event.getPaymentId(), event.getReservationId()); - - try { - Long reservationId = event.getReservationId(); - - log.info("예약 상태 업데이트 시작 - reservationId: {}", reservationId); - - // 1. Reservation 상태 업데이트: RESERVED -> PAID - updateReservationToPaid(reservationId); - - // 2. 관련 Ticket들 상태 업데이트: RESERVED -> PAID - updateTicketsToPaid(reservationId); - - log.info("예약 상태 업데이트 완료 - reservationId: {}, 상태: PAID", reservationId); - - } catch (Exception e) { - log.error("이벤트 처리 중 오류 발생 - paymentId: {}, reservationId: {}", - event.getPaymentId(), event.getReservationId(), e); - // 이벤트 처리 실패는 메인 결제 트랜잭션에 영향주지 않음 - // 별도 보상 작업이나 재시도 로직 필요 시 추가 - } - } - - /** - * 결제 취소 이벤트 처리 - * Reservation: RESERVED -> CANCELLED - * Ticket: RESERVED -> CANCELLED - */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handlePaymentCancelled(BookingPaymentCancelledEvent event) { - log.info("결제 취소 이벤트 수신 - paymentId: {}, reservationId: {}", - event.getPaymentId(), event.getReservationId()); - - try { - Long reservationId = event.getReservationId(); - - // 1. Reservation 상태 업데이트: RESERVED -> CANCELLED - updateReservationToCancelled(reservationId); - - // 2. 관련 Ticket들 상태 업데이트: RESERVED -> CANCELLED - updateTicketsToCancelled(reservationId); - - log.info("결제 취소 이벤트 처리 완료 - reservationId: {}", reservationId); - - } catch (Exception e) { - log.error("결제 취소 이벤트 처리 중 오류 발생 - paymentId: {}", event.getPaymentId(), e); - } - } - - /** - * 결제 실패 이벤트 처리 - * Reservation: RESERVED -> CANCELLED - * Ticket: RESERVED -> CANCELLED - */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handlePaymentFailed(BookingPaymentFailedEvent event) { - log.info("결제 실패 이벤트 수신 - paymentId: {}, reservationId: {}, 실패 사유: {}", - event.getPaymentId(), event.getReservationId(), event.getFailureReason()); - - try { - Long reservationId = event.getReservationId(); - - // 1. Reservation 상태 업데이트: RESERVED -> CANCELLED (실패한 예약은 취소 처리) - updateReservationToCancelled(reservationId); - - // 2. 관련 Ticket들 상태 업데이트: RESERVED -> CANCELLED - updateTicketsToCancelled(reservationId); - - log.info("결제 실패 이벤트 처리 완료 - reservationId: {}", reservationId); - - } catch (Exception e) { - log.error("결제 실패 이벤트 처리 중 오류 발생 - paymentId: {}", event.getPaymentId(), e); - } - } - - /** - * 결제 환불 이벤트 처리 - * Reservation: PAID -> REFUNDED - * Ticket: PAID -> REFUNDED - */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handlePaymentRefunded(BookingPaymentRefundedEvent event) { - log.info("결제 환불 이벤트 수신 - paymentId: {}, reservationId: {}", - event.getPaymentId(), event.getReservationId()); - - try { - Long reservationId = event.getReservationId(); - - // 1. Reservation 상태 업데이트: PAID -> REFUNDED - updateReservationToRefunded(reservationId); - - // 2. 관련 Ticket들 상태 업데이트: PAID -> REFUNDED - updateTicketsToRefunded(reservationId); - - log.info("결제 환불 이벤트 처리 완료 - reservationId: {}", reservationId); - - } catch (Exception e) { - log.error("결제 환불 이벤트 처리 중 오류 발생 - paymentId: {}", event.getPaymentId(), e); - } - } - - /** - * Reservation 상태를 PAID로 업데이트 - */ - private void updateReservationToPaid(Long reservationId) { - // JPA 리포지토리의 findById 메서드 직접 사용 - Reservation reservation = reservationRepository.findById(reservationId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 예약입니다: " + reservationId)); - - // 디버깅: 예약 객체 타입 및 메서드 확인 - log.debug("예약 객체 타입: {}", reservation.getClass().getName()); - log.debug("예약 객체 메서드 목록: {}", Arrays.toString(reservation.getClass().getMethods())); - - reservation.markAsPaid(); - reservationRepository.save(reservation); - - log.debug("예약 상태 업데이트 완료 - reservationId: {}, status: PAID", reservationId); - } - - /** - * 관련 Ticket들 상태를 PAID로 업데이트 (개별 저장) - */ - private void updateTicketsToPaid(Long reservationId) { - List tickets = ticketRepository.findByReservationId(reservationId); - - for (Ticket ticket : tickets) { - ticket.markAsPaid(); - ticketRepository.save(ticket); // 개별 저장 - } - - log.debug("티켓 상태 업데이트 완료 - reservationId: {}, ticketCount: {}, status: PAID", - reservationId, tickets.size()); - } - - /** - * Reservation 상태를 CANCELLED로 업데이트 - */ - private void updateReservationToCancelled(Long reservationId) { - // JPA 리포지토리의 findById 메서드 직접 사용 - Reservation reservation = reservationRepository.findById(reservationId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 예약입니다: " + reservationId)); - - reservation.markAsCancelled(); - reservationRepository.save(reservation); - - log.debug("예약 상태 업데이트 완료 - reservationId: {}, status: CANCELLED", reservationId); - } - - /** - * 관련 Ticket들 상태를 CANCELLED로 업데이트 (개별 저장) - */ - private void updateTicketsToCancelled(Long reservationId) { - List tickets = ticketRepository.findByReservationId(reservationId); - - for (Ticket ticket : tickets) { - ticket.markAsCancelled(); - ticketRepository.save(ticket); // 개별 저장 - } - - log.debug("티켓 상태 업데이트 완료 - reservationId: {}, ticketCount: {}, status: CANCELLED", - reservationId, tickets.size()); - } - - /** - * Reservation 상태를 REFUNDED로 업데이트 - */ - private void updateReservationToRefunded(Long reservationId) { - // JPA 리포지토리의 findById 메서드 직접 사용 - Reservation reservation = reservationRepository.findById(reservationId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 예약입니다: " + reservationId)); - - reservation.markAsRefunded(); - reservationRepository.save(reservation); - - log.debug("예약 상태 업데이트 완료 - reservationId: {}, status: REFUNDED", reservationId); - } - - /** - * 관련 Ticket들 상태를 REFUNDED로 업데이트 (개별 저장) - */ - private void updateTicketsToRefunded(Long reservationId) { - List tickets = ticketRepository.findByReservationId(reservationId); - - for (Ticket ticket : tickets) { - ticket.markAsRefunded(); - ticketRepository.save(ticket); // 개별 저장 - } - - log.debug("티켓 상태 업데이트 완료 - reservationId: {}, ticketCount: {}, status: REFUNDED", - reservationId, tickets.size()); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/booking/domain/CartReservation.java b/src/main/java/com/sudo/railo/booking/domain/CartReservation.java index 6ca82599..7c019f31 100644 --- a/src/main/java/com/sudo/railo/booking/domain/CartReservation.java +++ b/src/main/java/com/sudo/railo/booking/domain/CartReservation.java @@ -31,6 +31,7 @@ public class CartReservation extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) private Member member; @OneToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/com/sudo/railo/booking/domain/PaymentStatus.java b/src/main/java/com/sudo/railo/booking/domain/PaymentStatus.java deleted file mode 100644 index d5298e7d..00000000 --- a/src/main/java/com/sudo/railo/booking/domain/PaymentStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.sudo.railo.booking.domain; - -public enum PaymentStatus { - RESERVED, PAID, CANCELLED, REFUNDED -} diff --git a/src/main/java/com/sudo/railo/booking/domain/Qr.java b/src/main/java/com/sudo/railo/booking/domain/Qr.java index cfac9853..ee282515 100644 --- a/src/main/java/com/sudo/railo/booking/domain/Qr.java +++ b/src/main/java/com/sudo/railo/booking/domain/Qr.java @@ -36,5 +36,5 @@ public class Qr extends BaseEntity { private int scanCount; @Column(nullable = true) - private String qrUrl; + private String qrData; } diff --git a/src/main/java/com/sudo/railo/booking/domain/Reservation.java b/src/main/java/com/sudo/railo/booking/domain/Reservation.java index 037cfcc1..99e1bacc 100644 --- a/src/main/java/com/sudo/railo/booking/domain/Reservation.java +++ b/src/main/java/com/sudo/railo/booking/domain/Reservation.java @@ -2,15 +2,19 @@ import java.time.LocalDateTime; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.hibernate.annotations.Comment; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import com.sudo.railo.booking.domain.status.ReservationStatus; +import com.sudo.railo.booking.domain.type.TripType; +import com.sudo.railo.global.domain.BaseEntity; import com.sudo.railo.member.domain.Member; -import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.ScheduleStop; import com.sudo.railo.train.domain.TrainSchedule; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EntityListeners; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; @@ -27,105 +31,100 @@ @Entity @Getter -@AllArgsConstructor @Builder +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) -@EntityListeners(AuditingEntityListener.class) -public class Reservation { +public class Reservation extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "reservation_id") + @Comment("예약 ID") private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "train_schedule_id", nullable = false) + @Comment("운행 일정 ID") private TrainSchedule trainSchedule; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id") + @JoinColumn(name = "member_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @Comment("멤버 ID") private Member member; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "departure_station_id", nullable = false) - private Station departureStation; + @JoinColumn(name = "departure_stop_id", nullable = false) + @Comment("출발 정류장 ID") + private ScheduleStop departureStop; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "arrival_station_id", nullable = false) - private Station arrivalStation; + @JoinColumn(name = "arrival_stop_id", nullable = false) + @Comment("도착 정류장 ID") + private ScheduleStop arrivalStop; @Column(nullable = false) + @Comment("고객용 예매 코드") private String reservationCode; @Enumerated(EnumType.STRING) @Column(nullable = false) + @Comment("여행 타입") private TripType tripType; @Column(nullable = false) + @Comment("총 승객 수") private int totalPassengers; @Column(nullable = false) + @Comment("유형 별 승객 수") private String passengerSummary; @Enumerated(EnumType.STRING) @Column(nullable = false) + @Comment("예약 상태") private ReservationStatus reservationStatus; @Column(nullable = false) + @Comment("만료 시간") private LocalDateTime expiresAt; - @Column(nullable = false) - private LocalDateTime reservedAt; - - private LocalDateTime paidAt; + @Comment("결제 완료 시간") + private LocalDateTime purchaseAt; + @Comment("반환(취소) 시간") private LocalDateTime cancelledAt; - /** - * 예약 상태를 PAID로 변경 (결제 완료 시) - */ - public void markAsPaid() { - // 유효성 검증: RESERVED 상태에서만 PAID로 변경 가능 - if (this.reservationStatus != ReservationStatus.RESERVED) { - throw new IllegalStateException( - String.format("예약 상태가 RESERVED가 아닙니다. 현재 상태: %s (예약ID: %d)", - this.reservationStatus, this.id) - ); - } - + @Column(nullable = false) + @Comment("운임") + private int fare; + + public void approve() { this.reservationStatus = ReservationStatus.PAID; - this.paidAt = LocalDateTime.now(); + this.purchaseAt = LocalDateTime.now(); } - /** - * 예약 상태를 CANCELLED로 변경 (결제 취소 시) - */ - public void markAsCancelled() { - // 유효성 검증: RESERVED 상태에서만 CANCELLED로 변경 가능 - if (this.reservationStatus != ReservationStatus.RESERVED) { - throw new IllegalStateException( - String.format("예약 상태가 RESERVED가 아닙니다. 현재 상태: %s (예약ID: %d)", - this.reservationStatus, this.id) - ); - } - + public void cancel() { this.reservationStatus = ReservationStatus.CANCELLED; this.cancelledAt = LocalDateTime.now(); } - /** - * 예약 상태를 REFUNDED로 변경 (환불 완료 시) - */ - public void markAsRefunded() { - // 유효성 검증: PAID 상태에서만 REFUNDED로 변경 가능 - if (this.reservationStatus != ReservationStatus.PAID) { - throw new IllegalStateException( - String.format("예약 상태가 PAID가 아닙니다. 현재 상태: %s (예약ID: %d)", - this.reservationStatus, this.id) - ); - } - + public void refund() { this.reservationStatus = ReservationStatus.REFUNDED; - this.cancelledAt = LocalDateTime.now(); + } + + // 결제 가능 여부 확인 + public boolean canBePaid() { + return this.reservationStatus.isPayable(); + } + + // 취소 가능 여부 확인 + public boolean canBeCancelled() { + return this.reservationStatus.isCancellable(); + } + + // 환불 가능 여부 확인 + public boolean canBeRefunded() { + return this.purchaseAt != null && this.reservationStatus.isRefundable(); } } diff --git a/src/main/java/com/sudo/railo/booking/domain/SeasonTicket.java b/src/main/java/com/sudo/railo/booking/domain/SeasonTicket.java deleted file mode 100644 index cd15a68a..00000000 --- a/src/main/java/com/sudo/railo/booking/domain/SeasonTicket.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.sudo.railo.booking.domain; - -import java.time.LocalDate; - -import com.sudo.railo.member.domain.Member; - -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToOne; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class SeasonTicket { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "season_ticket_id") - private Long id; - - @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "qr_id", unique = true) - private Qr qr; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id") - private Member member; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private TicketType ticketType; - - @Column(nullable = false) - private LocalDate startAt; - - private LocalDate endAt; - - @Column(nullable = false) - private boolean isHolidayUsable; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private SeasonTicketStatus seasonTicketStatus; - -} diff --git a/src/main/java/com/sudo/railo/booking/domain/SeasonTicketStatus.java b/src/main/java/com/sudo/railo/booking/domain/SeasonTicketStatus.java deleted file mode 100644 index 79b83991..00000000 --- a/src/main/java/com/sudo/railo/booking/domain/SeasonTicketStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.sudo.railo.booking.domain; - -public enum SeasonTicketStatus { - VALID, INVALID -} diff --git a/src/main/java/com/sudo/railo/booking/domain/SeatReservation.java b/src/main/java/com/sudo/railo/booking/domain/SeatReservation.java index 0ee71d50..f7a02ea6 100644 --- a/src/main/java/com/sudo/railo/booking/domain/SeatReservation.java +++ b/src/main/java/com/sudo/railo/booking/domain/SeatReservation.java @@ -1,13 +1,12 @@ package com.sudo.railo.booking.domain; -import java.time.LocalDateTime; - +import org.hibernate.annotations.Comment; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; +import com.sudo.railo.booking.domain.type.PassengerType; import com.sudo.railo.global.domain.BaseEntity; import com.sudo.railo.train.domain.Seat; -import com.sudo.railo.train.domain.Station; import com.sudo.railo.train.domain.TrainSchedule; import jakarta.persistence.Column; @@ -23,7 +22,6 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; -import jakarta.persistence.Version; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -32,64 +30,44 @@ @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) @Builder @AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Table( name = "seat_reservation", - indexes = { - @Index(name = "idx_seat_reservation_schedule", columnList = "train_schedule_id"), - @Index(name = "idx_seat_reservation_section", columnList = "train_schedule_id, departure_station_id, arrival_station_id"), - @Index(name = "idx_seat_reservation_seat", columnList = "train_schedule_id, seat_id") - }, - uniqueConstraints = { - @UniqueConstraint( - columnNames = {"train_schedule_id", "seat_id"} - ) - } + indexes = {@Index(name = "idx_seat_reservation_seat", columnList = "train_schedule_id, seat_id")}, + uniqueConstraints = {@UniqueConstraint(columnNames = {"train_schedule_id", "seat_id", "reservation_id"})} ) public class SeatReservation extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "seat_reservation_id") + @Comment("예약 상태 ID") private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "train_schedule_id", nullable = false) + @Comment("운행 일정 ID") private TrainSchedule trainSchedule; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "seat_id", nullable = true) // 입석일 경우 true + @JoinColumn(name = "seat_id") + @Comment("좌석 ID") private Seat seat; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "reservation_id", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) + @Comment("예약 ID") private Reservation reservation; @Enumerated(EnumType.STRING) @Column(name = "passenger_type", nullable = false) + @Comment("승객 유형") private PassengerType passengerType; - @Enumerated(EnumType.STRING) - @Column(name = "seat_status", nullable = false) - private SeatStatus seatStatus; - - private LocalDateTime reservedAt; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "departure_station_id", nullable = false) - private Station departureStation; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "arrival_station_id", nullable = false) - private Station arrivalStation; - - @Column(name = "is_standing", nullable = false) - private boolean isStanding = false; - - // 낙관적 락을 위한 필드 - @Version - private Long version; + public boolean isStanding() { + return seat == null; + } } diff --git a/src/main/java/com/sudo/railo/booking/domain/SeatStatus.java b/src/main/java/com/sudo/railo/booking/domain/SeatStatus.java deleted file mode 100644 index 3bbc25d2..00000000 --- a/src/main/java/com/sudo/railo/booking/domain/SeatStatus.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sudo.railo.booking.domain; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum SeatStatus { - AVAILABLE("예약가능"), - RESERVED("예매 완료"), - LOCKED("잠금 상태"); - - private final String description; -} diff --git a/src/main/java/com/sudo/railo/booking/domain/Ticket.java b/src/main/java/com/sudo/railo/booking/domain/Ticket.java index af217d49..f6035e03 100644 --- a/src/main/java/com/sudo/railo/booking/domain/Ticket.java +++ b/src/main/java/com/sudo/railo/booking/domain/Ticket.java @@ -1,17 +1,17 @@ package com.sudo.railo.booking.domain; -import java.time.LocalDateTime; - +import org.hibernate.annotations.Comment; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import com.sudo.railo.booking.domain.status.TicketStatus; +import com.sudo.railo.booking.domain.type.PassengerType; import com.sudo.railo.global.domain.BaseEntity; +import com.sudo.railo.train.domain.Seat; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EntityListeners; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; @@ -29,108 +29,57 @@ @Entity @Getter -@AllArgsConstructor @Builder +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) -@EntityListeners(AuditingEntityListener.class) public class Ticket extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "ticket_id") + @Comment("승차권 ID") private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "reservation_id") - @OnDelete(action = OnDeleteAction.CASCADE) - private Reservation reservation; + @JoinColumn(name = "seat_id") + @OnDelete(action = OnDeleteAction.SET_NULL) + @Comment("좌석 ID") + private Seat seat; - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "seat_reservation_id", nullable = false, unique = true) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) - private SeatReservation seatReservation; + @Comment("예약 ID") + private Reservation reservation; - @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "qr_id", unique = true) + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) + @JoinColumn(name = "qr_id", nullable = false) + @Comment("QR ID") private Qr qr; @Enumerated(EnumType.STRING) @Column(nullable = false) - private PassengerType passengerType; + @Comment("승차권 상태") + private TicketStatus ticketStatus; @Enumerated(EnumType.STRING) @Column(nullable = false) - private PaymentStatus paymentStatus; - - private LocalDateTime paymentAt; - - @Enumerated(EnumType.STRING) - private TicketStatus status; + @Comment("승객 유형") + private PassengerType passengerType; - @Column(nullable = true, columnDefinition = "VARCHAR(255) COMMENT '승차권 발행 주체 코드 (웹, 모바일, 역 등 - 5자리)'") + @Comment("결제 위치 번호 (예: 온라인 (01), ~~역(02...))") private String vendorCode; - @Column(nullable = true, columnDefinition = "VARCHAR(255) COMMENT '승차권 결제 일자 (MMdd)'") + @Comment("결제 날짜 (MMdd)") private String purchaseDate; - @Column(nullable = true, columnDefinition = "VARCHAR(255) COMMENT '승차권 결제 순번 (10000~)'") + @Comment("승차권 결제 순번 (10000~)") private String purchaseSeq; - @Column(nullable = true, columnDefinition = "VARCHAR(255) COMMENT '승차권 고유번호 (2자리)'") + @Comment("결제 고유번호 (숫자 2자리)") private String purchaseUid; - /** - * 티켓 결제 상태를 PAID로 변경 (결제 완료 시) - */ - public void markAsPaid() { - // 유효성 검증: RESERVED 상태에서만 PAID로 변경 가능 - if (this.paymentStatus != PaymentStatus.RESERVED) { - throw new IllegalStateException( - String.format("티켓 결제 상태가 RESERVED가 아닙니다. 현재 상태: %s (티켓ID: %d)", - this.paymentStatus, this.id) - ); - } - - this.paymentStatus = PaymentStatus.PAID; - this.paymentAt = LocalDateTime.now(); - - // 티켓 상태도 함께 업데이트 (결제 완료 시 발급됨) - if (this.status == null) { - this.status = TicketStatus.ISSUED; - } - } - - /** - * 티켓 결제 상태를 CANCELLED로 변경 (결제 취소 시) - */ - public void markAsCancelled() { - // 유효성 검증: RESERVED 상태에서만 CANCELLED로 변경 가능 - if (this.paymentStatus != PaymentStatus.RESERVED) { - throw new IllegalStateException( - String.format("티켓 결제 상태가 RESERVED가 아닙니다. 현재 상태: %s (티켓ID: %d)", - this.paymentStatus, this.id) - ); - } - - this.paymentStatus = PaymentStatus.CANCELLED; - this.status = TicketStatus.CANCELLED; - this.paymentAt = null; // 결제 취소 시 결제 시간 초기화 - } - - /** - * 티켓 결제 상태를 REFUNDED로 변경 (환불 완료 시) - */ - public void markAsRefunded() { - // 유효성 검증: PAID 상태에서만 REFUNDED로 변경 가능 - if (this.paymentStatus != PaymentStatus.PAID) { - throw new IllegalStateException( - String.format("티켓 결제 상태가 PAID가 아닙니다. 현재 상태: %s (티켓ID: %d)", - this.paymentStatus, this.id) - ); - } - - this.paymentStatus = PaymentStatus.REFUNDED; - this.status = TicketStatus.REFUNDED; - this.paymentAt = null; // 환불 시 결제 시간 초기화 + public boolean isStanding() { + return seat == null; } } diff --git a/src/main/java/com/sudo/railo/booking/domain/TicketType.java b/src/main/java/com/sudo/railo/booking/domain/TicketType.java deleted file mode 100644 index 449a0c6a..00000000 --- a/src/main/java/com/sudo/railo/booking/domain/TicketType.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.sudo.railo.booking.domain; - -public enum TicketType { - FREE, NORMAL -} diff --git a/src/main/java/com/sudo/railo/booking/domain/TripType.java b/src/main/java/com/sudo/railo/booking/domain/TripType.java deleted file mode 100644 index 892a871b..00000000 --- a/src/main/java/com/sudo/railo/booking/domain/TripType.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.sudo.railo.booking.domain; - -public enum TripType { - OW, RT -} diff --git a/src/main/java/com/sudo/railo/booking/domain/ReservationStatus.java b/src/main/java/com/sudo/railo/booking/domain/status/ReservationStatus.java similarity index 88% rename from src/main/java/com/sudo/railo/booking/domain/ReservationStatus.java rename to src/main/java/com/sudo/railo/booking/domain/status/ReservationStatus.java index 5faac53d..234063ed 100644 --- a/src/main/java/com/sudo/railo/booking/domain/ReservationStatus.java +++ b/src/main/java/com/sudo/railo/booking/domain/status/ReservationStatus.java @@ -1,4 +1,4 @@ -package com.sudo.railo.booking.domain; +package com.sudo.railo.booking.domain.status; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -9,7 +9,6 @@ @Getter @RequiredArgsConstructor public enum ReservationStatus { - SELECTING("선택중", "좌석을 선택하고 있는 상태"), RESERVED("예약완료", "예약이 완료된 상태"), PAID("결제완료", "결제가 완료된 상태"), CANCELLED("취소", "예약이 취소된 상태"), @@ -44,6 +43,6 @@ public boolean isPayable() { * 환불 가능 여부 */ public boolean isRefundable() { - return this == PAID; + return this == CANCELLED; } } diff --git a/src/main/java/com/sudo/railo/booking/domain/TicketStatus.java b/src/main/java/com/sudo/railo/booking/domain/status/TicketStatus.java similarity index 58% rename from src/main/java/com/sudo/railo/booking/domain/TicketStatus.java rename to src/main/java/com/sudo/railo/booking/domain/status/TicketStatus.java index db07f015..06a22c40 100644 --- a/src/main/java/com/sudo/railo/booking/domain/TicketStatus.java +++ b/src/main/java/com/sudo/railo/booking/domain/status/TicketStatus.java @@ -1,4 +1,4 @@ -package com.sudo.railo.booking.domain; +package com.sudo.railo.booking.domain.status; public enum TicketStatus { ISSUED, USED, CANCELLED, REFUNDED diff --git a/src/main/java/com/sudo/railo/booking/domain/PassengerSummary.java b/src/main/java/com/sudo/railo/booking/domain/type/PassengerSummary.java similarity index 68% rename from src/main/java/com/sudo/railo/booking/domain/PassengerSummary.java rename to src/main/java/com/sudo/railo/booking/domain/type/PassengerSummary.java index 1d239483..e624bb99 100644 --- a/src/main/java/com/sudo/railo/booking/domain/PassengerSummary.java +++ b/src/main/java/com/sudo/railo/booking/domain/type/PassengerSummary.java @@ -1,11 +1,10 @@ -package com.sudo.railo.booking.domain; +package com.sudo.railo.booking.domain.type; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.NoArgsConstructor; @Getter -@NoArgsConstructor @Schema(description = "승객 유형과 인원수") public class PassengerSummary { @@ -14,4 +13,9 @@ public class PassengerSummary { @Schema(description = "승객 수", example = "2") private int count; + + public PassengerSummary(PassengerType passengerType, int count) { + this.passengerType = passengerType; + this.count = count; + } } diff --git a/src/main/java/com/sudo/railo/booking/domain/PassengerType.java b/src/main/java/com/sudo/railo/booking/domain/type/PassengerType.java similarity index 69% rename from src/main/java/com/sudo/railo/booking/domain/PassengerType.java rename to src/main/java/com/sudo/railo/booking/domain/type/PassengerType.java index 88e5676d..dcb22f1b 100644 --- a/src/main/java/com/sudo/railo/booking/domain/PassengerType.java +++ b/src/main/java/com/sudo/railo/booking/domain/type/PassengerType.java @@ -1,4 +1,4 @@ -package com.sudo.railo.booking.domain; +package com.sudo.railo.booking.domain.type; public enum PassengerType { ADULT, CHILD, INFANT, SENIOR, DISABLED_HEAVY, DISABLED_LIGHT, VETERAN diff --git a/src/main/java/com/sudo/railo/booking/domain/type/TripType.java b/src/main/java/com/sudo/railo/booking/domain/type/TripType.java new file mode 100644 index 00000000..35f11cc9 --- /dev/null +++ b/src/main/java/com/sudo/railo/booking/domain/type/TripType.java @@ -0,0 +1,5 @@ +package com.sudo.railo.booking.domain.type; + +public enum TripType { + OW, RT +} diff --git a/src/main/java/com/sudo/railo/booking/exception/BookingError.java b/src/main/java/com/sudo/railo/booking/exception/BookingError.java index 58201475..de27b169 100644 --- a/src/main/java/com/sudo/railo/booking/exception/BookingError.java +++ b/src/main/java/com/sudo/railo/booking/exception/BookingError.java @@ -26,6 +26,12 @@ public enum BookingError implements ErrorCode { QR_CREATE_FAILED("QR 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR, "B_013"), TICKET_CREATE_FAILED("티켓 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR, "B_014"), TICKET_LIST_GET_FAILED("티켓을 가져올 수 없습니다.", HttpStatus.INTERNAL_SERVER_ERROR, "B_015"), + RESERVATION_EXPIRED("예약이 만료되었습니다.", HttpStatus.GONE, "B_017"), + SEAT_RESERVATION_NOT_FOUND("좌석 예약 상태를 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "B_018"), + TICKET_NOT_FOUND("티켓을 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "B_019"), + + // 예약 요청 Request 관련 + INVALID_CAR_TYPE("좌석의 객차 타입은 동일해야 합니다.", HttpStatus.BAD_REQUEST, "B_016"), // 예약 조회 관련 RESERVATION_NOT_FOUND("예약 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "B_101"), diff --git a/src/main/java/com/sudo/railo/booking/infra/ReservationRepository.java b/src/main/java/com/sudo/railo/booking/infra/ReservationRepository.java deleted file mode 100644 index 50ea4c16..00000000 --- a/src/main/java/com/sudo/railo/booking/infra/ReservationRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.sudo.railo.booking.infra; - -import java.time.LocalDateTime; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.sudo.railo.booking.domain.Reservation; -import com.sudo.railo.booking.domain.ReservationStatus; - -public interface ReservationRepository extends JpaRepository { - void deleteAllByExpiresAtBeforeAndReservationStatusNot(LocalDateTime expiresAtBefore, - ReservationStatus reservationStatus); -} diff --git a/src/main/java/com/sudo/railo/booking/infra/SeatReservationRepository.java b/src/main/java/com/sudo/railo/booking/infra/SeatReservationRepository.java deleted file mode 100644 index da660f04..00000000 --- a/src/main/java/com/sudo/railo/booking/infra/SeatReservationRepository.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.sudo.railo.booking.infra; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.sudo.railo.booking.domain.SeatReservation; - -public interface SeatReservationRepository extends JpaRepository { - - /*** - * 스케줄 ID와 좌석 ID로 좌석 예약 상태를 조회하는 메서드 - * @param trainScheduleId 열차 스케줄 ID - * @param seatId 좌석 ID - * @return SeatReservation 엔티티 - */ - Optional findByTrainScheduleIdAndSeatId(Long trainScheduleId, Long seatId); - - /*** - * 예약 만료 시간을 기준으로 만료된 좌석 목록을 조회하는 메서드 - * @param reservedAtBefore 만료 기준 시간 - * @return 만료된 SeatReservation 엔티티 리스트 - */ - List findAllByReservedAtBefore(LocalDateTime reservedAtBefore); -} diff --git a/src/main/java/com/sudo/railo/booking/infra/CartReservationRepository.java b/src/main/java/com/sudo/railo/booking/infrastructure/CartReservationRepository.java similarity index 92% rename from src/main/java/com/sudo/railo/booking/infra/CartReservationRepository.java rename to src/main/java/com/sudo/railo/booking/infrastructure/CartReservationRepository.java index 159510bf..f19203c0 100644 --- a/src/main/java/com/sudo/railo/booking/infra/CartReservationRepository.java +++ b/src/main/java/com/sudo/railo/booking/infrastructure/CartReservationRepository.java @@ -1,4 +1,4 @@ -package com.sudo.railo.booking.infra; +package com.sudo.railo.booking.infrastructure; import java.util.List; diff --git a/src/main/java/com/sudo/railo/booking/infra/QrRepository.java b/src/main/java/com/sudo/railo/booking/infrastructure/QrRepository.java similarity index 78% rename from src/main/java/com/sudo/railo/booking/infra/QrRepository.java rename to src/main/java/com/sudo/railo/booking/infrastructure/QrRepository.java index 58c41e21..df5f9a46 100644 --- a/src/main/java/com/sudo/railo/booking/infra/QrRepository.java +++ b/src/main/java/com/sudo/railo/booking/infrastructure/QrRepository.java @@ -1,4 +1,4 @@ -package com.sudo.railo.booking.infra; +package com.sudo.railo.booking.infrastructure; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/sudo/railo/booking/infrastructure/SeatReservationRepository.java b/src/main/java/com/sudo/railo/booking/infrastructure/SeatReservationRepository.java new file mode 100644 index 00000000..b6a3f09e --- /dev/null +++ b/src/main/java/com/sudo/railo/booking/infrastructure/SeatReservationRepository.java @@ -0,0 +1,30 @@ +package com.sudo.railo.booking.infrastructure; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; + +import com.sudo.railo.booking.domain.SeatReservation; + +import jakarta.persistence.LockModeType; + +public interface SeatReservationRepository extends JpaRepository { + + /*** + * 스케줄 ID와 좌석 ID로 좌석 예약 상태를 조회하는 메서드 + * 비관적 락을 사용하여 해당 열차 스케줄과 좌석의 모든 예약을 조회 + * 동시성 제어를 위해 SeatReservation에 배타적 락을 걸어 다른 트랜잭션의 접근을 차단 + * @param trainScheduleId 열차 스케줄 ID + * @param seatId 좌석 ID + * @return SeatReservation 엔티티 + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT sr FROM SeatReservation sr WHERE sr.trainSchedule.id = :trainScheduleId AND sr.seat.id = :seatId") + List findByTrainScheduleAndSeatWithLock(Long trainScheduleId, Long seatId); + + List findByReservationId(Long reservationId); + + void deleteAllByReservationId(Long reservationId); +} diff --git a/src/main/java/com/sudo/railo/booking/infrastructure/reservation/ReservationRepository.java b/src/main/java/com/sudo/railo/booking/infrastructure/reservation/ReservationRepository.java new file mode 100644 index 00000000..595200a0 --- /dev/null +++ b/src/main/java/com/sudo/railo/booking/infrastructure/reservation/ReservationRepository.java @@ -0,0 +1,20 @@ +package com.sudo.railo.booking.infrastructure.reservation; + +import java.time.LocalDateTime; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sudo.railo.booking.domain.Reservation; +import com.sudo.railo.booking.domain.status.ReservationStatus; + +public interface ReservationRepository extends JpaRepository { + Page findAllByExpiresAtBeforeAndReservationStatus( + LocalDateTime expiresAtBefore, + ReservationStatus reservationStatus, + Pageable pageable + ); + + void deleteAllByMemberId(Long memberId); +} diff --git a/src/main/java/com/sudo/railo/booking/infra/ReservationRepositoryCustom.java b/src/main/java/com/sudo/railo/booking/infrastructure/reservation/ReservationRepositoryCustom.java similarity index 83% rename from src/main/java/com/sudo/railo/booking/infra/ReservationRepositoryCustom.java rename to src/main/java/com/sudo/railo/booking/infrastructure/reservation/ReservationRepositoryCustom.java index ec6b18d3..78b74d6c 100644 --- a/src/main/java/com/sudo/railo/booking/infra/ReservationRepositoryCustom.java +++ b/src/main/java/com/sudo/railo/booking/infrastructure/reservation/ReservationRepositoryCustom.java @@ -1,4 +1,4 @@ -package com.sudo.railo.booking.infra; +package com.sudo.railo.booking.infrastructure.reservation; import java.util.List; diff --git a/src/main/java/com/sudo/railo/booking/infra/ReservationRepositoryCustomImpl.java b/src/main/java/com/sudo/railo/booking/infrastructure/reservation/ReservationRepositoryCustomImpl.java similarity index 77% rename from src/main/java/com/sudo/railo/booking/infra/ReservationRepositoryCustomImpl.java rename to src/main/java/com/sudo/railo/booking/infrastructure/reservation/ReservationRepositoryCustomImpl.java index 2eabcd74..78c00d26 100644 --- a/src/main/java/com/sudo/railo/booking/infra/ReservationRepositoryCustomImpl.java +++ b/src/main/java/com/sudo/railo/booking/infrastructure/reservation/ReservationRepositoryCustomImpl.java @@ -1,9 +1,8 @@ -package com.sudo.railo.booking.infra; +package com.sudo.railo.booking.infrastructure.reservation; import static com.sudo.railo.booking.domain.QReservation.*; import static com.sudo.railo.booking.domain.QSeatReservation.*; import static com.sudo.railo.train.domain.QSeat.*; -import static com.sudo.railo.train.domain.QStationFare.*; import static com.sudo.railo.train.domain.QTrain.*; import static com.sudo.railo.train.domain.QTrainCar.*; import static com.sudo.railo.train.domain.QTrainSchedule.*; @@ -14,7 +13,6 @@ import org.springframework.stereotype.Repository; -import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import com.sudo.railo.booking.application.dto.ReservationInfo; @@ -22,10 +20,9 @@ import com.sudo.railo.booking.application.dto.projection.QSeatReservationProjection; import com.sudo.railo.booking.application.dto.projection.ReservationProjection; import com.sudo.railo.booking.application.dto.projection.SeatReservationProjection; -import com.sudo.railo.booking.domain.ReservationStatus; +import com.sudo.railo.booking.domain.status.ReservationStatus; import com.sudo.railo.train.domain.QScheduleStop; import com.sudo.railo.train.domain.QStation; -import com.sudo.railo.train.domain.type.CarType; import lombok.RequiredArgsConstructor; @@ -60,20 +57,15 @@ public List findReservationDetail(Long memberId, List res arrivalStop.arrivalTime, trainSchedule.operationDate, reservation.expiresAt, - stationFare.standardFare, - stationFare.firstClassFare + reservation.fare )) .from(reservation) .join(reservation.trainSchedule, trainSchedule) + .join(reservation.departureStop, departureStop) + .join(reservation.arrivalStop, arrivalStop) .join(trainSchedule.train, train) - .join(trainSchedule.scheduleStops, departureStop) - .join(trainSchedule.scheduleStops, arrivalStop) - .join(reservation.departureStation, departureStation) - .join(reservation.arrivalStation, arrivalStation) - .join(stationFare).on( - stationFare.departureStation.id.eq(reservation.departureStation.id) - .and(stationFare.arrivalStation.id.eq(reservation.arrivalStation.id)) - ) + .join(departureStop.station, departureStation) + .join(arrivalStop.station, arrivalStation) .where( reservation.member.id.eq(memberId), reservation.reservationStatus.eq(ReservationStatus.RESERVED), @@ -98,8 +90,7 @@ public List findReservationDetail(Long memberId, List res seatReservation.passengerType, trainCar.carNumber, trainCar.carType, - seat.seatRow.stringValue().concat(seat.seatColumn), - Expressions.constant(0) // 임시 운임 + seat.seatRow.stringValue().concat(seat.seatColumn) )) .from(seatReservation) .leftJoin(seatReservation.seat, seat) @@ -116,10 +107,7 @@ public List findReservationDetail(Long memberId, List res return reservations.stream() .map(reservation -> ReservationInfo.of( reservation, - seats.get(reservation.getReservationId()).stream() - .map(seat -> seat.withFare(CarType.STANDARD.equals(seat.getCarType()) - ? reservation.getStandardFare() : reservation.getFirstClassFare()) - ).toList() + seats.get(reservation.getReservationId()) )).toList(); } } diff --git a/src/main/java/com/sudo/railo/booking/infra/TicketRepository.java b/src/main/java/com/sudo/railo/booking/infrastructure/ticket/TicketRepository.java similarity index 71% rename from src/main/java/com/sudo/railo/booking/infra/TicketRepository.java rename to src/main/java/com/sudo/railo/booking/infrastructure/ticket/TicketRepository.java index f01a6ec8..7d317526 100644 --- a/src/main/java/com/sudo/railo/booking/infra/TicketRepository.java +++ b/src/main/java/com/sudo/railo/booking/infrastructure/ticket/TicketRepository.java @@ -1,4 +1,4 @@ -package com.sudo.railo.booking.infra; +package com.sudo.railo.booking.infrastructure.ticket; import java.util.List; @@ -8,5 +8,6 @@ public interface TicketRepository extends JpaRepository { List findByReservationMemberId(Long reservationMemberId); - List findByReservationId(Long reservationId); + + void deleteAllByReservationId(Long reservationId); } diff --git a/src/main/java/com/sudo/railo/booking/infra/TicketRepositoryCustom.java b/src/main/java/com/sudo/railo/booking/infrastructure/ticket/TicketRepositoryCustom.java similarity index 80% rename from src/main/java/com/sudo/railo/booking/infra/TicketRepositoryCustom.java rename to src/main/java/com/sudo/railo/booking/infrastructure/ticket/TicketRepositoryCustom.java index 82ed3826..bcf4f743 100644 --- a/src/main/java/com/sudo/railo/booking/infra/TicketRepositoryCustom.java +++ b/src/main/java/com/sudo/railo/booking/infrastructure/ticket/TicketRepositoryCustom.java @@ -1,4 +1,4 @@ -package com.sudo.railo.booking.infra; +package com.sudo.railo.booking.infrastructure.ticket; import java.util.List; diff --git a/src/main/java/com/sudo/railo/booking/infra/TicketRepositoryCustomImpl.java b/src/main/java/com/sudo/railo/booking/infrastructure/ticket/TicketRepositoryCustomImpl.java similarity index 81% rename from src/main/java/com/sudo/railo/booking/infra/TicketRepositoryCustomImpl.java rename to src/main/java/com/sudo/railo/booking/infrastructure/ticket/TicketRepositoryCustomImpl.java index feecb62b..b54a42ff 100644 --- a/src/main/java/com/sudo/railo/booking/infra/TicketRepositoryCustomImpl.java +++ b/src/main/java/com/sudo/railo/booking/infrastructure/ticket/TicketRepositoryCustomImpl.java @@ -1,7 +1,6 @@ -package com.sudo.railo.booking.infra; +package com.sudo.railo.booking.infrastructure.ticket; import static com.sudo.railo.booking.domain.QReservation.*; -import static com.sudo.railo.booking.domain.QSeatReservation.*; import static com.sudo.railo.booking.domain.QTicket.*; import static com.sudo.railo.train.domain.QSeat.*; import static com.sudo.railo.train.domain.QTrain.*; @@ -16,7 +15,7 @@ import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import com.sudo.railo.booking.application.dto.response.TicketReadResponse; -import com.sudo.railo.booking.domain.PaymentStatus; +import com.sudo.railo.booking.domain.status.TicketStatus; import com.sudo.railo.train.domain.QScheduleStop; import com.sudo.railo.train.domain.QStation; @@ -40,7 +39,6 @@ public List findPaidTicketResponsesByMemberId(Long memberId) TicketReadResponse.class, ticket.id, reservation.id, - seatReservation.id, trainSchedule.operationDate, departureStation.id, departureStation.stationName, @@ -57,19 +55,18 @@ public List findPaidTicketResponsesByMemberId(Long memberId) seat.seatType )) .from(ticket) + .join(ticket.seat, seat) .join(ticket.reservation, reservation) .join(reservation.trainSchedule, trainSchedule) .join(trainSchedule.train, train) - .join(trainSchedule.scheduleStops, departureStop) - .join(trainSchedule.scheduleStops, arrivalStop) - .join(ticket.seatReservation, seatReservation) - .join(seatReservation.seat, seat) + .join(reservation.departureStop, departureStop) + .join(reservation.arrivalStop, arrivalStop) .join(seat.trainCar, trainCar) - .join(seatReservation.departureStation, departureStation) - .join(seatReservation.arrivalStation, arrivalStation) + .join(departureStop.station, departureStation) + .join(arrivalStop.station, arrivalStation) .where( reservation.member.id.eq(memberId) - .and(ticket.paymentStatus.eq(PaymentStatus.PAID)) + .and(ticket.ticketStatus.eq(TicketStatus.ISSUED)) .and(arrivalStop.station.id.eq(arrivalStation.id)) .and(departureStop.station.id.eq(departureStation.id)) .and(departureStop.stopOrder.lt(arrivalStop.stopOrder)) diff --git a/src/main/java/com/sudo/railo/booking/presentation/BookingController.java b/src/main/java/com/sudo/railo/booking/presentation/BookingController.java deleted file mode 100644 index 7e5a3042..00000000 --- a/src/main/java/com/sudo/railo/booking/presentation/BookingController.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.sudo.railo.booking.presentation; - -import java.math.BigDecimal; - -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.sudo.railo.booking.application.FareCalculationService; -import com.sudo.railo.booking.application.dto.request.FareCalculateRequest; -import com.sudo.railo.booking.success.FareSuccess; -import com.sudo.railo.global.success.SuccessResponse; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/v1/booking") -@RequiredArgsConstructor -public class BookingController { - - private final FareCalculationService fareCalculationService; - - /*** - * 승객 유형과 운임을 입력받아 할인된 운임을 계산하는 메서드 - * @param request 승객 유형과 운임이 담긴 DTO - * @return 할인된 운임 가격 - */ - @PostMapping("/fare") - public SuccessResponse calculateFare(@RequestBody FareCalculateRequest request) { - BigDecimal newFare = fareCalculationService.calculateFare(request); - return SuccessResponse.of(FareSuccess.FARE_CALCULATE_SUCCESS, newFare); - } -} diff --git a/src/main/java/com/sudo/railo/booking/presentation/ReservationController.java b/src/main/java/com/sudo/railo/booking/presentation/ReservationController.java index 288babc2..33d162dd 100644 --- a/src/main/java/com/sudo/railo/booking/presentation/ReservationController.java +++ b/src/main/java/com/sudo/railo/booking/presentation/ReservationController.java @@ -42,7 +42,8 @@ public SuccessResponse createReservation( @RequestBody ReservationCreateRequest request, @AuthenticationPrincipal UserDetails userDetails ) { - ReservationCreateResponse response = reservationApplicationService.createReservation(request, userDetails); + ReservationCreateResponse response = reservationApplicationService + .createReservation(request, userDetails.getUsername()); return SuccessResponse.of(ReservationSuccess.RESERVATION_CREATE_SUCCESS, response); } diff --git a/src/main/java/com/sudo/railo/booking/presentation/TicketController.java b/src/main/java/com/sudo/railo/booking/presentation/TicketController.java index 57c8b2cc..d826db75 100644 --- a/src/main/java/com/sudo/railo/booking/presentation/TicketController.java +++ b/src/main/java/com/sudo/railo/booking/presentation/TicketController.java @@ -25,7 +25,8 @@ public class TicketController implements TicketControllerDocs { @GetMapping public SuccessResponse> getMyTickets(@AuthenticationPrincipal UserDetails userDetails) { - List tickets = ticketService.getMyTickets(userDetails); + String username = userDetails.getUsername(); + List tickets = ticketService.getMyTickets(username); return SuccessResponse.of(TicketSuccess.TICKET_LIST_GET_SUCCESS, tickets); } } diff --git a/src/main/java/com/sudo/railo/booking/success/FareSuccess.java b/src/main/java/com/sudo/railo/booking/success/FareSuccess.java deleted file mode 100644 index a03dcab0..00000000 --- a/src/main/java/com/sudo/railo/booking/success/FareSuccess.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.sudo.railo.booking.success; - -import org.springframework.http.HttpStatus; - -import com.sudo.railo.global.success.SuccessCode; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum FareSuccess implements SuccessCode { - - FARE_CALCULATE_SUCCESS(HttpStatus.OK, "정상적으로 계산되었습니다."); - - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/sudo/railo/global/config/AuthEmailConfig.java b/src/main/java/com/sudo/railo/global/config/AuthEmailConfig.java index fe95765d..0a8518d7 100644 --- a/src/main/java/com/sudo/railo/global/config/AuthEmailConfig.java +++ b/src/main/java/com/sudo/railo/global/config/AuthEmailConfig.java @@ -5,10 +5,12 @@ 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; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; @Configuration +@Profile("!test") public class AuthEmailConfig { @Value("${spring.mail.host}") @@ -52,7 +54,7 @@ public JavaMailSender javaMailSender() { mailSender.setJavaMailProperties(getMailProperties()); return mailSender; } - + private Properties getMailProperties() { Properties properties = new Properties(); properties.put("mail.smtp.auth", auth); diff --git a/src/main/java/com/sudo/railo/global/redis/RedisConfig.java b/src/main/java/com/sudo/railo/global/config/RedisConfig.java similarity index 87% rename from src/main/java/com/sudo/railo/global/redis/RedisConfig.java rename to src/main/java/com/sudo/railo/global/config/RedisConfig.java index 41850d67..332074c0 100644 --- a/src/main/java/com/sudo/railo/global/redis/RedisConfig.java +++ b/src/main/java/com/sudo/railo/global/config/RedisConfig.java @@ -1,4 +1,4 @@ -package com.sudo.railo.global.redis; +package com.sudo.railo.global.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -11,6 +11,9 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + import lombok.extern.slf4j.Slf4j; @Slf4j @@ -51,7 +54,10 @@ public RedisTemplate customObjectRedisTemplate() { RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory()); - GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); redisTemplate.setDefaultSerializer(serializer); redisTemplate.setValueSerializer(serializer); redisTemplate.setKeySerializer(new StringRedisSerializer()); diff --git a/src/main/java/com/sudo/railo/global/security/SecurityConfig.java b/src/main/java/com/sudo/railo/global/config/SecurityConfig.java similarity index 72% rename from src/main/java/com/sudo/railo/global/security/SecurityConfig.java rename to src/main/java/com/sudo/railo/global/config/SecurityConfig.java index f30c3c0a..555e419c 100644 --- a/src/main/java/com/sudo/railo/global/security/SecurityConfig.java +++ b/src/main/java/com/sudo/railo/global/config/SecurityConfig.java @@ -1,4 +1,4 @@ -package com.sudo.railo.global.security; +package com.sudo.railo.global.config; import static org.springframework.security.config.http.SessionCreationPolicy.*; @@ -16,12 +16,12 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfigurationSource; -import com.sudo.railo.global.redis.RedisUtil; -import com.sudo.railo.global.security.jwt.JwtAccessDeniedHandler; -import com.sudo.railo.global.security.jwt.JwtAuthenticationEntryPoint; -import com.sudo.railo.global.security.jwt.JwtFilter; -import com.sudo.railo.global.security.jwt.TokenExtractor; -import com.sudo.railo.global.security.jwt.TokenProvider; +import com.sudo.railo.auth.security.jwt.JwtAccessDeniedHandler; +import com.sudo.railo.auth.security.jwt.JwtAuthenticationEntryPoint; +import com.sudo.railo.auth.security.jwt.JwtFilter; +import com.sudo.railo.auth.security.jwt.TokenExtractor; +import com.sudo.railo.auth.security.jwt.TokenValidator; +import com.sudo.railo.global.redis.AuthRedisRepository; import lombok.RequiredArgsConstructor; @@ -31,9 +31,9 @@ @EnableMethodSecurity public class SecurityConfig { - private final RedisUtil redisUtil; - private final TokenProvider tokenProvider; + private final AuthRedisRepository authRedisRepository; private final TokenExtractor tokenExtractor; + private final TokenValidator tokenValidator; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; @@ -61,21 +61,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .cors(cors -> cors.configurationSource(corsConfigurationSource)) // HTTP 요청에 대한 접근 권한 설정 .authorizeHttpRequests(auth -> { - auth.requestMatchers("/", "/auth/signup", "/auth/login").permitAll() + auth.requestMatchers("/", "/auth/signup", "/auth/login", "/auth/reissue").permitAll() .requestMatchers(HttpMethod.POST, "/auth/emails/**").permitAll() .requestMatchers(HttpMethod.POST, "/auth/member-no/**", "/auth/password/**").permitAll() .requestMatchers("/api/v1/guest/register", "/api/v1/trains/**").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() .requestMatchers("/actuator/**", "/health").permitAll() - // 결제 관련 API (비회원도 접근 가능) - .requestMatchers(HttpMethod.POST, "/api/v1/payments/calculate").permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/payments/calculations/**").permitAll() - .requestMatchers(HttpMethod.POST, "/api/v1/payments/pg/approve").permitAll() - .requestMatchers(HttpMethod.POST, "/api/v1/payments/pg/request").permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/payment-history/reservation/**").permitAll() .anyRequest().authenticated(); }) - .addFilterBefore(new JwtFilter(tokenExtractor, tokenProvider, redisUtil), + .addFilterBefore(new JwtFilter(tokenExtractor, tokenValidator, authRedisRepository), UsernamePasswordAuthenticationFilter.class) .build(); } diff --git a/src/main/java/com/sudo/railo/global/exception/GlobalExceptionHandler.java b/src/main/java/com/sudo/railo/global/exception/GlobalExceptionHandler.java index 0c280e7b..014aaec7 100644 --- a/src/main/java/com/sudo/railo/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/sudo/railo/global/exception/GlobalExceptionHandler.java @@ -18,11 +18,11 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; +import com.sudo.railo.auth.exception.TokenError; import com.sudo.railo.global.exception.error.BusinessException; import com.sudo.railo.global.exception.error.ErrorResponse; import com.sudo.railo.global.exception.error.GlobalError; import com.sudo.railo.global.redis.RedisError; -import com.sudo.railo.global.security.TokenError; import io.jsonwebtoken.io.SerializationException; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/com/sudo/railo/global/redis/AuthRedisRepository.java b/src/main/java/com/sudo/railo/global/redis/AuthRedisRepository.java new file mode 100644 index 00000000..fb06d6fb --- /dev/null +++ b/src/main/java/com/sudo/railo/global/redis/AuthRedisRepository.java @@ -0,0 +1,77 @@ +package com.sudo.railo.global.redis; + +import java.time.Duration; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AuthRedisRepository { + + private final RedisTemplate stringRedisTemplate; + private final RedisTemplate objectRedisTemplate; + private final RedisKeyGenerator redisKeyGenerator; + + private static final Duration AUTH_EXPIRE_TIME = Duration.ofMinutes(5); + private static final Duration REFRESH_TOKEN_EXPIRE_TIME = Duration.ofDays(7); + + /** + * 이메일 인증 관련 + * Key = auth:email:{email} + * */ + public void saveAuthCode(String email, String authCode) { + String key = redisKeyGenerator.generateEmailAuthCodeKey(email); + stringRedisTemplate.opsForValue() + .set(key, authCode, AUTH_EXPIRE_TIME); + } + + public String getAuthCode(String email) { + String key = redisKeyGenerator.generateEmailAuthCodeKey(email); + return stringRedisTemplate.opsForValue().get(key); + } + + public void deleteAuthCode(String email) { + String key = redisKeyGenerator.generateEmailAuthCodeKey(email); + stringRedisTemplate.delete(key); + } + + /** + * RefreshToken 저장 + * Key = auth:refreshToken:memberNo:{memberNo} + * */ + public void saveRefreshToken(String memberNo, String refreshToken) { + String key = redisKeyGenerator.generateRefreshTokenKey(memberNo); + stringRedisTemplate.opsForValue() + .set(key, refreshToken, REFRESH_TOKEN_EXPIRE_TIME); + } + + public String getRefreshToken(String memberNo) { + String key = redisKeyGenerator.generateRefreshTokenKey(memberNo); + return stringRedisTemplate.opsForValue().get(key); + } + + public void deleteRefreshToken(String memberNo) { + String key = redisKeyGenerator.generateRefreshTokenKey(memberNo); + stringRedisTemplate.delete(key); + } + + /** + * Logout 관련 + * Key = auth:logout:accessToken:{accessToken} + * */ + public void saveLogoutToken(String accessToken, LogoutToken logoutToken, Duration expireTime) { + String key = redisKeyGenerator.generateLogoutTokenKey(accessToken); + objectRedisTemplate.opsForValue() + .set(key, logoutToken, expireTime); + } + + public LogoutToken getLogoutToken(String accessToken) { + String key = redisKeyGenerator.generateLogoutTokenKey(accessToken); + Object value = objectRedisTemplate.opsForValue().get(key); + return (LogoutToken)value; + } + +} diff --git a/src/main/java/com/sudo/railo/global/redis/LogoutRedis.java b/src/main/java/com/sudo/railo/global/redis/LogoutToken.java similarity index 76% rename from src/main/java/com/sudo/railo/global/redis/LogoutRedis.java rename to src/main/java/com/sudo/railo/global/redis/LogoutToken.java index a629551f..a085ea20 100644 --- a/src/main/java/com/sudo/railo/global/redis/LogoutRedis.java +++ b/src/main/java/com/sudo/railo/global/redis/LogoutToken.java @@ -1,5 +1,7 @@ package com.sudo.railo.global.redis; +import java.time.Duration; + import com.fasterxml.jackson.annotation.JsonTypeInfo; import lombok.AllArgsConstructor; @@ -12,8 +14,8 @@ @NoArgsConstructor @AllArgsConstructor @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, property = "@class") -public class LogoutRedis { +public class LogoutToken { private String value; // "logout" - private Long expireTime; // 토큰 만료 시간 + private Duration expireTime; // 토큰 만료 시간 } diff --git a/src/main/java/com/sudo/railo/global/redis/MemberRedis.java b/src/main/java/com/sudo/railo/global/redis/MemberRedis.java deleted file mode 100644 index 2e9b42a2..00000000 --- a/src/main/java/com/sudo/railo/global/redis/MemberRedis.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.sudo.railo.global.redis; - -import com.fasterxml.jackson.annotation.JsonTypeInfo; - -import jakarta.persistence.Id; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -// @RedisHash(value = "MemberToken", timeToLive = 3600 * 24 * 7) -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, property = "@class") -public class MemberRedis { - - @Id - private String memberNo; - private String refreshToken; -} diff --git a/src/main/java/com/sudo/railo/global/redis/MemberRedisRepository.java b/src/main/java/com/sudo/railo/global/redis/MemberRedisRepository.java new file mode 100644 index 00000000..dcfdbbc4 --- /dev/null +++ b/src/main/java/com/sudo/railo/global/redis/MemberRedisRepository.java @@ -0,0 +1,55 @@ +package com.sudo.railo.global.redis; + +import java.time.Duration; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class MemberRedisRepository { + + private final RedisTemplate stringRedisTemplate; + private final RedisKeyGenerator redisKeyGenerator; + + private static final Duration COMMON_EXPIRE_TIME = Duration.ofMinutes(5); + + /** + * 회원번호 관련 + * Key = member:no:email:{email} + * */ + public void saveMemberNo(String email, String memberNo) { + String key = redisKeyGenerator.generateMemberNoKey(email); + stringRedisTemplate.opsForValue() + .set(key, memberNo, COMMON_EXPIRE_TIME); + } + + public String getMemberNo(String email) { + String key = redisKeyGenerator.generateMemberNoKey(email); + return stringRedisTemplate.opsForValue().get(key); + } + + public void deleteMemberNo(String email) { + String key = redisKeyGenerator.generateMemberNoKey(email); + stringRedisTemplate.delete(key); + } + + /** + * 이메일 변경 관련 + * Key = member:update:email:{email} + * */ + public boolean handleUpdateEmailRequest(String email) { + + String key = redisKeyGenerator.generateUpdateEmailKey(email); + Boolean isSuccess = stringRedisTemplate.opsForValue() + .setIfAbsent(key, "REQUESTED", COMMON_EXPIRE_TIME); + return isSuccess != null && isSuccess; + } + + public void deleteUpdateEmailRequest(String email) { + String key = redisKeyGenerator.generateUpdateEmailKey(email); + stringRedisTemplate.delete(key); + } +} diff --git a/src/main/java/com/sudo/railo/global/redis/RedisKeyGenerator.java b/src/main/java/com/sudo/railo/global/redis/RedisKeyGenerator.java new file mode 100644 index 00000000..fa171026 --- /dev/null +++ b/src/main/java/com/sudo/railo/global/redis/RedisKeyGenerator.java @@ -0,0 +1,37 @@ +package com.sudo.railo.global.redis; + +import org.springframework.stereotype.Component; + +@Component +public class RedisKeyGenerator { + + /** + * Redis 키 Prefix + * */ + private static final String MEMBER_NO_KEY_PREFIX = "member:email:{email}:no"; + private static final String UPDATE_EMAIL_KEY_PREFIX = "member:email:{email}:update"; + private static final String EMAIL_AUTH_CODE_KEY_PREFIX = "auth:email:{email}"; + private static final String REFRESH_TOKEN_KEY_PREFIX = "auth:no:{memberNo}:refreshToken"; + private static final String LOGOUT_KEY_PREFIX = "auth:accessToken:{token}:logout"; + + public String generateMemberNoKey(String email) { + return MEMBER_NO_KEY_PREFIX.replace("{email}", email); + } + + public String generateUpdateEmailKey(String email) { + return UPDATE_EMAIL_KEY_PREFIX.replace("{email}", email); + } + + public String generateEmailAuthCodeKey(String email) { + return EMAIL_AUTH_CODE_KEY_PREFIX.replace("{email}", email); + } + + public String generateRefreshTokenKey(String memberNo) { + return REFRESH_TOKEN_KEY_PREFIX.replace("{memberNo}", memberNo); + } + + public String generateLogoutTokenKey(String accessToken) { + return LOGOUT_KEY_PREFIX.replace("{token}", accessToken); + } + +} diff --git a/src/main/java/com/sudo/railo/global/redis/RedisUtil.java b/src/main/java/com/sudo/railo/global/redis/RedisUtil.java deleted file mode 100644 index d75da90c..00000000 --- a/src/main/java/com/sudo/railo/global/redis/RedisUtil.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.sudo.railo.global.redis; - -import java.time.Instant; -import java.util.concurrent.TimeUnit; - -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class RedisUtil { - - private final RedisTemplate objectRedisTemplate; - private final RedisTemplate stringRedisTemplate; - - private static final int AUTH_EXPIRATION_MINUTES = 5; - private static final int MEMBER_NO_EXPIRATION_MINUTES = 5; - - private static final String UPDATE_EMAIL_PREFIX = "updateEmail:"; - - public void save(String key, String value) { - stringRedisTemplate.opsForValue().set(key, value); - } - - // Redis 키 값 증가 - public Long increment(String key) { - return stringRedisTemplate.opsForValue().increment(key); - } - - // Redis 키 만료 시간 설정 - public void expireAt(String key, Instant expireTime) { - stringRedisTemplate.expireAt(key, expireTime); - } - - // Member 리프레시 토큰 저장 - public void saveMemberToken(MemberRedis memberRedis) { - objectRedisTemplate.opsForValue() - .set(memberRedis.getMemberNo(), memberRedis, 3600 * 24 * 7, TimeUnit.SECONDS); // TTL 설정 임시 코드 - } - - // 리프레시 토큰 조회 - public String getRefreshToken(String memberNo) { - try { - MemberRedis memberRedis = (MemberRedis)objectRedisTemplate.opsForValue().get(memberNo); - - if (memberRedis == null) { - log.warn("memberNo : {} 멤버 정보를 찾을 수 없습니다.", memberNo); - return null; - } - - return memberRedis.getRefreshToken(); - } catch (Exception e) { - log.error("getRefreshToken error : {}", e.getMessage()); - return null; - } - } - - // 리프레시 토큰 삭제 - public void deleteRefreshToken(String memberNo) { - objectRedisTemplate.delete(memberNo); - } - - // 로그아웃 토큰 정보 저장 - public void saveLogoutToken(String accessToken, LogoutRedis logoutRedis, Long expireTime) { - objectRedisTemplate.opsForValue().set(accessToken, logoutRedis, expireTime, TimeUnit.MILLISECONDS); - } - - // 로그아웃 토큰 정보 조회 - public LogoutRedis getLogoutToken(String accessToken) { - Object value = objectRedisTemplate.opsForValue().get(accessToken); - return (LogoutRedis)value; - } - - /* 이메일 인증 관련 */ - public void saveAuthCode(String email, String authCode) { - String key = "authEmail:" + email; - stringRedisTemplate.opsForValue().set(key, authCode, AUTH_EXPIRATION_MINUTES, TimeUnit.MINUTES); - } - - public String getAuthCode(String email) { - String key = "authEmail:" + email; - return stringRedisTemplate.opsForValue().get(key); - } - - public void deleteAuthCode(String email) { - String key = "authEmail:" + email; - stringRedisTemplate.delete(key); - } - - /* 회원번호 찾기 관련 */ - public void saveMemberNo(String email, String memberNo) { - String key = "memberNo:" + email; - stringRedisTemplate.opsForValue().set(key, memberNo, MEMBER_NO_EXPIRATION_MINUTES, TimeUnit.MINUTES); - } - - public String getMemberNo(String email) { - String key = "memberNo:" + email; - return stringRedisTemplate.opsForValue().get(key); - } - - public void deleteMemberNo(String email) { - String key = "memberNo:" + email; - stringRedisTemplate.delete(key); - } - - /* 이메일 변경 중복 로직 처리 관련*/ - public boolean handleUpdateEmailRequest(String email) { - - String key = UPDATE_EMAIL_PREFIX + email; - Boolean isSuccess = stringRedisTemplate.opsForValue() - .setIfAbsent(key, "REQUESTED", AUTH_EXPIRATION_MINUTES, TimeUnit.MINUTES); - return isSuccess != null && isSuccess; - } - - public void deleteUpdateEmailRequest(String email) { - String key = UPDATE_EMAIL_PREFIX + email; - stringRedisTemplate.delete(key); - } - -} diff --git a/src/main/java/com/sudo/railo/global/redis/annotation/DistributedLock.java b/src/main/java/com/sudo/railo/global/redis/annotation/DistributedLock.java deleted file mode 100644 index 7feec00b..00000000 --- a/src/main/java/com/sudo/railo/global/redis/annotation/DistributedLock.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.sudo.railo.global.redis.annotation; - -import java.lang.annotation.*; -import java.util.concurrent.TimeUnit; - -/** - * 분산 락 어노테이션 - * - * AOP를 통해 메서드 레벨에서 분산 락을 적용 - * 주로 동시성 제어가 필요한 비즈니스 로직에 사용 - * - * 사용 예시: - * @DistributedLock(key = "#paymentId", prefix = "payment") - * public void processPayment(String paymentId) { ... } - */ -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -@Documented -public @interface DistributedLock { - - /** - * 락 키 (SpEL 표현식 지원) - * 예: "#paymentId", "#member.id", "#request.calculationId" - */ - String key(); - - /** - * 락 키 접두사 (도메인별 구분) - * 예: "payment", "mileage", "reservation" - */ - String prefix() default ""; - - /** - * 락 만료 시간 (기본: 30초) - */ - long expireTime() default 30; - - /** - * 락 대기 시간 (기본: 5초) - */ - long waitTime() default 5; - - /** - * 시간 단위 (기본: 초) - */ - TimeUnit timeUnit() default TimeUnit.SECONDS; - - /** - * 락 획득 실패 시 예외 발생 여부 (기본: true) - * false인 경우 null 반환 - */ - boolean throwExceptionOnFailure() default true; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/global/security/jwt/TokenExtractor.java b/src/main/java/com/sudo/railo/global/security/jwt/TokenExtractor.java deleted file mode 100644 index 3cb983a0..00000000 --- a/src/main/java/com/sudo/railo/global/security/jwt/TokenExtractor.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.sudo.railo.global.security.jwt; - -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; - -import jakarta.servlet.http.HttpServletRequest; - -@Component -public class TokenExtractor { - - public static final String AUTHORIZATION_HEADER = "Authorization"; - public static final String BEARER_PREFIX = "Bearer "; - - public String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader(AUTHORIZATION_HEADER); - - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { - - return bearerToken.substring(BEARER_PREFIX.length()); - } - - return null; - } -} diff --git a/src/main/java/com/sudo/railo/global/security/util/SecurityUtil.java b/src/main/java/com/sudo/railo/global/security/util/SecurityUtil.java deleted file mode 100644 index 46fcb8ac..00000000 --- a/src/main/java/com/sudo/railo/global/security/util/SecurityUtil.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.sudo.railo.global.security.util; - -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; - -import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.global.security.TokenError; - -public class SecurityUtil { - - private static Authentication getAuthentication() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || authentication.getName() == null) { - throw new BusinessException(TokenError.INVALID_TOKEN); - } - return authentication; - } - - public static String getCurrentMemberNo() { - Authentication authentication = getAuthentication(); - return authentication.getName(); - } -} diff --git a/src/main/java/com/sudo/railo/member/application/MemberAuthService.java b/src/main/java/com/sudo/railo/member/application/MemberAuthService.java deleted file mode 100644 index 7472104a..00000000 --- a/src/main/java/com/sudo/railo/member/application/MemberAuthService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.sudo.railo.member.application; - -import com.sudo.railo.member.application.dto.request.MemberNoLoginRequest; -import com.sudo.railo.member.application.dto.request.SignUpRequest; -import com.sudo.railo.member.application.dto.response.ReissueTokenResponse; -import com.sudo.railo.member.application.dto.response.SendCodeResponse; -import com.sudo.railo.member.application.dto.response.SignUpResponse; -import com.sudo.railo.member.application.dto.response.TokenResponse; - -public interface MemberAuthService { - - SignUpResponse signUp(SignUpRequest request); - - TokenResponse memberNoLogin(MemberNoLoginRequest request); - - void logout(String accessToken); - - ReissueTokenResponse reissueAccessToken(String refreshToken); - - SendCodeResponse sendAuthCode(String email); - - boolean verifyAuthCode(String email, String authCode); - -} diff --git a/src/main/java/com/sudo/railo/member/application/MemberAuthServiceImpl.java b/src/main/java/com/sudo/railo/member/application/MemberAuthServiceImpl.java deleted file mode 100644 index 44ebf953..00000000 --- a/src/main/java/com/sudo/railo/member/application/MemberAuthServiceImpl.java +++ /dev/null @@ -1,146 +0,0 @@ -package com.sudo.railo.member.application; - -import java.security.SecureRandom; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; - -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; -import org.springframework.security.core.Authentication; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.global.redis.LogoutRedis; -import com.sudo.railo.global.redis.MemberRedis; -import com.sudo.railo.global.redis.RedisUtil; -import com.sudo.railo.global.security.TokenError; -import com.sudo.railo.global.security.jwt.TokenProvider; -import com.sudo.railo.global.security.util.SecurityUtil; -import com.sudo.railo.member.application.dto.request.MemberNoLoginRequest; -import com.sudo.railo.member.application.dto.request.SignUpRequest; -import com.sudo.railo.member.application.dto.response.ReissueTokenResponse; -import com.sudo.railo.member.application.dto.response.SendCodeResponse; -import com.sudo.railo.member.application.dto.response.SignUpResponse; -import com.sudo.railo.member.application.dto.response.TokenResponse; -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.member.domain.MemberDetail; -import com.sudo.railo.member.domain.Membership; -import com.sudo.railo.member.domain.Role; -import com.sudo.railo.member.exception.MemberError; -import com.sudo.railo.member.infra.MemberRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class MemberAuthServiceImpl implements MemberAuthService { - - private final MemberRepository memberRepository; - private final PasswordEncoder passwordEncoder; - private final MemberNoGenerator memberNoGenerator; - private final EmailAuthService emailAuthService; - private final AuthenticationManagerBuilder authenticationManagerBuilder; - private final TokenProvider tokenProvider; - private final RedisUtil redisUtil; - - @Override - @Transactional - public SignUpResponse signUp(SignUpRequest request) { - - if (memberRepository.existsByMemberDetailEmail(request.email())) { - throw new BusinessException(MemberError.DUPLICATE_EMAIL); - } - - String memberNo = memberNoGenerator.generateMemberNo(); - LocalDate birthDate = LocalDate.parse(request.birthDate(), DateTimeFormatter.ISO_LOCAL_DATE); - - MemberDetail memberDetail = MemberDetail.create(memberNo, Membership.BUSINESS, request.email(), birthDate, - request.gender()); - Member member = Member.create(request.name(), request.phoneNumber(), passwordEncoder.encode(request.password()), - Role.MEMBER, memberDetail); - - memberRepository.save(member); - - return new SignUpResponse(memberNo); - } - - @Override - @Transactional - public TokenResponse memberNoLogin(MemberNoLoginRequest request) { - - UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( - request.memberNo(), request.password()); - - Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); - TokenResponse tokenResponse = tokenProvider.generateTokenDTO(authentication); - - // 레디스에 리프레시 토큰 저장 - MemberRedis memberRedis = new MemberRedis(request.memberNo(), tokenResponse.refreshToken()); - redisUtil.saveMemberToken(memberRedis); - - return tokenResponse; - } - - @Override - @Transactional - public void logout(String accessToken) { - - // 현재 로그인된 사용자의 회원번호를 가져옴 - String memberNo = SecurityUtil.getCurrentMemberNo(); - - // Redis 에서 해당 memberNo 로 저장된 RefreshToken 이 있는지 여부 확인 후, 존재할 경우 삭제 - if (redisUtil.getRefreshToken(memberNo) != null) { - redisUtil.deleteRefreshToken(memberNo); - } - - // 해당 AccessToken 유효 시간을 가져와 BlackList 에 저장 - Long expiration = tokenProvider.getAccessTokenExpiration(accessToken); - LogoutRedis logoutRedis = new LogoutRedis("logout", expiration); - redisUtil.saveLogoutToken(accessToken, logoutRedis, expiration); - } - - @Override - @Transactional - public ReissueTokenResponse reissueAccessToken(String refreshToken) { - - String memberNo = SecurityUtil.getCurrentMemberNo(); - String restoredRefreshToken = redisUtil.getRefreshToken(memberNo); - - if (!refreshToken.equals(restoredRefreshToken)) { - throw new BusinessException(TokenError.NOT_EQUALS_REFRESH_TOKEN); - } - - return tokenProvider.reissueAccessToken(refreshToken); - } - - /* 이메일 인증 관련 */ - @Override - public SendCodeResponse sendAuthCode(String email) { - String code = createAuthCode(); - emailAuthService.sendEmail(email, code); - // redis 에 유효시간 설정해서 인증코드 저장 - redisUtil.saveAuthCode(email, code); - - return new SendCodeResponse(email); - } - - @Override - public boolean verifyAuthCode(String email, String authCode) { - // redis 에서 저장해둔 인증 코드 get - String findCode = redisUtil.getAuthCode(email); - boolean isVerified = authCode.equals(findCode); - - if (isVerified) { - redisUtil.deleteAuthCode(email); - } - - return isVerified; - } - - private String createAuthCode() { - SecureRandom random = new SecureRandom(); - return String.format("%06d", random.nextInt(1000000)); - } -} diff --git a/src/main/java/com/sudo/railo/member/application/MemberFindService.java b/src/main/java/com/sudo/railo/member/application/MemberFindService.java new file mode 100644 index 00000000..3f5d7888 --- /dev/null +++ b/src/main/java/com/sudo/railo/member/application/MemberFindService.java @@ -0,0 +1,106 @@ +package com.sudo.railo.member.application; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sudo.railo.auth.application.EmailAuthService; +import com.sudo.railo.auth.application.dto.request.VerifyCodeRequest; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; +import com.sudo.railo.auth.application.dto.response.TemporaryTokenResponse; +import com.sudo.railo.auth.exception.AuthError; +import com.sudo.railo.auth.security.jwt.TokenGenerator; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.global.redis.MemberRedisRepository; +import com.sudo.railo.member.application.dto.request.FindMemberNoRequest; +import com.sudo.railo.member.application.dto.request.FindPasswordRequest; +import com.sudo.railo.member.application.dto.response.VerifyMemberNoResponse; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.exception.MemberError; +import com.sudo.railo.member.infrastructure.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberFindService { + + private final MemberRepository memberRepository; + private final TokenGenerator tokenGenerator; + private final EmailAuthService emailAuthService; + private final MemberRedisRepository memberRedisRepository; + + /** + * 이메일 인증을 통한 회원 번호 찾기 + * */ + @Transactional(readOnly = true) + public SendCodeResponse requestFindMemberNo(FindMemberNoRequest request) { + + Member member = memberRepository.findMemberByNameAndPhoneNumber(request.name(), request.phoneNumber()) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + + String memberEmail = member.getMemberDetail().getEmail(); + String memberNo = member.getMemberDetail().getMemberNo(); + + sendCodeAndSaveMemberNo(memberEmail, memberNo); + + return new SendCodeResponse(memberEmail); + } + + public VerifyMemberNoResponse verifyFindMemberNo(VerifyCodeRequest request) { + + String memberNo = verifyCodeAndGetMemberNo(request); + + return new VerifyMemberNoResponse(memberNo); + } + + /** + * 이메일 인증을 통한 비밀번호 찾기 + * */ + @Transactional(readOnly = true) + public SendCodeResponse requestFindPassword(FindPasswordRequest request) { + + Member member = memberRepository.findByMemberNo(request.memberNo()) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + + if (!member.getName().equals(request.name())) { + throw new BusinessException(MemberError.NAME_MISMATCH); + } + + String memberEmail = member.getMemberDetail().getEmail(); + String memberNo = member.getMemberDetail().getMemberNo(); + + sendCodeAndSaveMemberNo(memberEmail, memberNo); + + return new SendCodeResponse(memberEmail); + } + + public TemporaryTokenResponse verifyFindPassword(VerifyCodeRequest request) { + + String memberNo = verifyCodeAndGetMemberNo(request); + String temporaryToken = tokenGenerator.generateTemporaryToken(memberNo); + + return new TemporaryTokenResponse(temporaryToken); + } + + private String verifyCodeAndGetMemberNo(VerifyCodeRequest request) { + + String email = request.email(); + String authCode = request.authCode(); + boolean isVerified = emailAuthService.verifyAuthCode(email, authCode); + + if (!isVerified) { // 인증 실패 시 + throw new BusinessException(AuthError.INVALID_AUTH_CODE); + } + + String memberNo = memberRedisRepository.getMemberNo(request.email()); + memberRedisRepository.deleteMemberNo(request.email()); + + return memberNo; + } + + private void sendCodeAndSaveMemberNo(String email, String memberNo) { + memberRedisRepository.saveMemberNo(email, memberNo); // 레디스에 이메일 검증 후 보낼 회원번호 저장 + emailAuthService.sendAuthCode(email); // 찾아온 이메일로 인증 코드 전송 + } + +} diff --git a/src/main/java/com/sudo/railo/member/application/MemberNoGenerator.java b/src/main/java/com/sudo/railo/member/application/MemberNoGenerator.java index 1a07432d..1281944c 100644 --- a/src/main/java/com/sudo/railo/member/application/MemberNoGenerator.java +++ b/src/main/java/com/sudo/railo/member/application/MemberNoGenerator.java @@ -7,33 +7,33 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; -import com.sudo.railo.global.redis.RedisUtil; - import lombok.RequiredArgsConstructor; @Component @RequiredArgsConstructor public class MemberNoGenerator { + private final RedisTemplate stringRedisTemplate; + + private static final String KEY_PREFIX = "todayKey:"; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); - private final RedisUtil redisUtil; - public String generateMemberNo() { String today = LocalDate.now().format(DATE_FORMATTER); - String redisKey = "todayKey:" + today; + String redisKey = KEY_PREFIX + today; - Long counter = redisUtil.increment(redisKey); + Long counter = stringRedisTemplate.opsForValue().increment(redisKey); // 자정까지 남은 시간 계산 ZonedDateTime midnightToday = ZonedDateTime.of(LocalDate.now(ZONE_ID), LocalTime.MAX, ZONE_ID); Instant midnightInstant = midnightToday.toInstant(); // 자정 만료 - redisUtil.expireAt(redisKey, midnightInstant); + stringRedisTemplate.expireAt(redisKey, midnightInstant); String paddedCounter = String.format("%04d", counter); diff --git a/src/main/java/com/sudo/railo/member/application/MemberService.java b/src/main/java/com/sudo/railo/member/application/MemberService.java index 9721422f..87ba5274 100644 --- a/src/main/java/com/sudo/railo/member/application/MemberService.java +++ b/src/main/java/com/sudo/railo/member/application/MemberService.java @@ -1,50 +1,105 @@ package com.sudo.railo.member.application; -import com.sudo.railo.member.application.dto.request.FindMemberNoRequest; -import com.sudo.railo.member.application.dto.request.FindPasswordRequest; +import java.util.List; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sudo.railo.auth.application.AuthService; +import com.sudo.railo.booking.application.ReservationApplicationService; +import com.sudo.railo.global.exception.error.BusinessException; import com.sudo.railo.member.application.dto.request.GuestRegisterRequest; -import com.sudo.railo.member.application.dto.request.SendCodeRequest; -import com.sudo.railo.member.application.dto.request.UpdateEmailRequest; -import com.sudo.railo.member.application.dto.request.UpdatePasswordRequest; -import com.sudo.railo.member.application.dto.request.UpdatePhoneNumberRequest; -import com.sudo.railo.member.application.dto.request.VerifyCodeRequest; import com.sudo.railo.member.application.dto.response.GuestRegisterResponse; import com.sudo.railo.member.application.dto.response.MemberInfoResponse; -import com.sudo.railo.member.application.dto.response.SendCodeResponse; -import com.sudo.railo.member.application.dto.response.TemporaryTokenResponse; -import com.sudo.railo.member.application.dto.response.VerifyMemberNoResponse; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.domain.MemberDetail; +import com.sudo.railo.member.domain.Role; +import com.sudo.railo.member.exception.MemberError; +import com.sudo.railo.member.infrastructure.MemberRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; -public interface MemberService { +@Slf4j +@Service +@RequiredArgsConstructor +public class MemberService { - GuestRegisterResponse guestRegister(GuestRegisterRequest request); + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final AuthService authService; + private final ReservationApplicationService reservationApplicationService; - void memberDelete(String accessToken); + /** + * 비회원 등록 + * */ + @Transactional + public GuestRegisterResponse guestRegister(GuestRegisterRequest request) { - MemberInfoResponse getMemberInfo(); + // 중복 체크 + List foundMembers = memberRepository.findByNameAndPhoneNumber(request.name(), request.phoneNumber()); - SendCodeResponse requestUpdateEmail(SendCodeRequest request); + foundMembers.stream() + .filter(member -> passwordEncoder.matches(request.password(), member.getPassword())) + .findFirst() + .ifPresent(member -> { + throw new BusinessException(MemberError.DUPLICATE_GUEST_INFO); + }); - void verifyUpdateEmail(UpdateEmailRequest request); + String encodedPassword = passwordEncoder.encode(request.password()); - void updatePhoneNumber(UpdatePhoneNumberRequest request); + Member member = Member.guestCreate(request.name(), request.phoneNumber(), encodedPassword); + memberRepository.save(member); - void updatePassword(UpdatePasswordRequest request); + return new GuestRegisterResponse(request.name(), Role.GUEST); + } - String getMemberEmail(String memberNo); + /** + * 회원 삭제 + * */ + @Transactional + public void memberDelete(String accessToken, String memberNo) { + + Member currentMember = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + + try { + memberRepository.delete(currentMember); + reservationApplicationService.deleteReservationsByMember(currentMember); + } catch (Exception e) { + log.error("회원 삭제 실패 : {}", e.getMessage()); + throw new BusinessException(MemberError.MEMBER_DELETE_FAIL); + } + + // 로그아웃 수행 + authService.logout(accessToken, memberNo); + } - SendCodeResponse requestFindMemberNo(FindMemberNoRequest request); + /** + * 회원 정보 조회 + * */ + @Transactional(readOnly = true) + public MemberInfoResponse getMemberInfo(String memberNo) { - VerifyMemberNoResponse verifyFindMemberNo(VerifyCodeRequest request); + Member member = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - SendCodeResponse requestFindPassword(FindPasswordRequest request); + MemberDetail memberDetail = member.getMemberDetail(); - TemporaryTokenResponse verifyFindPassword(VerifyCodeRequest request); + return MemberInfoResponse.of(member.getName(), member.getPhoneNumber(), memberDetail); + } /** - * 회원의 마일리지 잔액 조회 - * @param memberId 회원 ID - * @return 마일리지 잔액 - */ - java.math.BigDecimal getMileageBalance(Long memberId); + * 회원 이메일 조회 + * */ + @Transactional(readOnly = true) + public String getMemberEmail(String memberNo) { + Member member = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + MemberDetail memberDetail = member.getMemberDetail(); + + return memberDetail.getEmail(); + } } diff --git a/src/main/java/com/sudo/railo/member/application/MemberServiceImpl.java b/src/main/java/com/sudo/railo/member/application/MemberServiceImpl.java deleted file mode 100644 index 05c79904..00000000 --- a/src/main/java/com/sudo/railo/member/application/MemberServiceImpl.java +++ /dev/null @@ -1,285 +0,0 @@ -package com.sudo.railo.member.application; - -import java.util.List; - -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.global.redis.RedisUtil; -import com.sudo.railo.global.security.jwt.TokenProvider; -import com.sudo.railo.global.security.util.SecurityUtil; -import com.sudo.railo.member.application.dto.request.FindMemberNoRequest; -import com.sudo.railo.member.application.dto.request.FindPasswordRequest; -import com.sudo.railo.member.application.dto.request.GuestRegisterRequest; -import com.sudo.railo.member.application.dto.request.SendCodeRequest; -import com.sudo.railo.member.application.dto.request.UpdateEmailRequest; -import com.sudo.railo.member.application.dto.request.UpdatePasswordRequest; -import com.sudo.railo.member.application.dto.request.UpdatePhoneNumberRequest; -import com.sudo.railo.member.application.dto.request.VerifyCodeRequest; -import com.sudo.railo.member.application.dto.response.GuestRegisterResponse; -import com.sudo.railo.member.application.dto.response.MemberInfoResponse; -import com.sudo.railo.member.application.dto.response.SendCodeResponse; -import com.sudo.railo.member.application.dto.response.TemporaryTokenResponse; -import com.sudo.railo.member.application.dto.response.VerifyMemberNoResponse; -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.member.domain.MemberDetail; -import com.sudo.railo.member.domain.Role; -import com.sudo.railo.member.exception.AuthError; -import com.sudo.railo.member.exception.MemberError; -import com.sudo.railo.member.infra.MemberRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Service -@RequiredArgsConstructor -public class MemberServiceImpl implements MemberService { - - private final MemberRepository memberRepository; - private final PasswordEncoder passwordEncoder; - private final MemberAuthService memberAuthService; - private final RedisUtil redisUtil; - private final TokenProvider tokenProvider; - - @Override - @Transactional - public GuestRegisterResponse guestRegister(GuestRegisterRequest request) { - - // 중복 체크 - List foundMembers = memberRepository.findByNameAndPhoneNumber(request.name(), request.phoneNumber()); - - foundMembers.stream() - .filter(member -> passwordEncoder.matches(request.password(), member.getPassword())) - .findFirst() - .ifPresent(member -> { - throw new BusinessException(MemberError.DUPLICATE_GUEST_INFO); - }); - - String encodedPassword = passwordEncoder.encode(request.password()); - - Member member = Member.guestCreate(request.name(), request.phoneNumber(), encodedPassword); - memberRepository.save(member); - - return new GuestRegisterResponse(request.name(), Role.GUEST); - } - - // 회원 삭제 로직 - @Override - @Transactional - public void memberDelete(String accessToken) { - - String currentMemberNo = SecurityUtil.getCurrentMemberNo(); - - Member currentMember = memberRepository.findByMemberNo(currentMemberNo) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - - try { - memberRepository.delete(currentMember); - } catch (Exception e) { - log.error("회원 삭제 실패 : {}", e.getMessage()); - throw new BusinessException(MemberError.MEMBER_DELETE_FAIL); - } - - // 로그아웃 수행 - memberAuthService.logout(accessToken); - } - - // 회원 조회 로직 - @Override - @Transactional(readOnly = true) - public MemberInfoResponse getMemberInfo() { - - String currentMemberNo = SecurityUtil.getCurrentMemberNo(); - - Member member = memberRepository.findByMemberNo(currentMemberNo) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - - MemberDetail memberDetail = member.getMemberDetail(); - - return MemberInfoResponse.of(member.getName(), member.getPhoneNumber(), memberDetail); - } - - @Override - @Transactional(readOnly = true) - public SendCodeResponse requestUpdateEmail(SendCodeRequest request) { - - Member member = getCurrentMember(); - MemberDetail memberDetail = member.getMemberDetail(); - - String newEmail = request.email(); - - // 이미 본인 이메일이랑 동일한 이메일로 변경을 요청했을 경우 예외 - if (memberDetail.getEmail().equals(newEmail)) { - throw new BusinessException(MemberError.SAME_EMAIL); - } - - // 다른 회원이 사용중인 이메일을 입력했을 경우 예외 - if (memberRepository.existsByMemberDetailEmail(newEmail)) { - throw new BusinessException(MemberError.DUPLICATE_EMAIL); - } - - // 동일 요청 건이 없으면 같은 이메일에 대한 요청이 들어오지 못하도록 redis 에 등록 - if (!redisUtil.handleUpdateEmailRequest(newEmail)) { - throw new BusinessException(MemberError.EMAIL_UPDATE_ALREADY_REQUESTED); - } - - return memberAuthService.sendAuthCode(newEmail); - } - - @Override - @Transactional - public void verifyUpdateEmail(UpdateEmailRequest request) { - - Member member = getCurrentMember(); - MemberDetail memberDetail = member.getMemberDetail(); - - String newEmail = request.newEmail(); - String authCode = request.authCode(); - - boolean isVerified = memberAuthService.verifyAuthCode(newEmail, authCode); // 이메일 검증 - - if (!isVerified) { - throw new BusinessException(AuthError.INVALID_AUTH_CODE); - } - - memberDetail.updateEmail(newEmail); - redisUtil.deleteUpdateEmailRequest(newEmail); // 해당 변경 요청 건 redis 에서 삭제 - } - - @Override - @Transactional - public void updatePhoneNumber(UpdatePhoneNumberRequest request) { - - Member member = getCurrentMember(); - - // 이미 본인이 사용하는 번호와 동일하게 입력했을 경우 예외 - if (member.getPhoneNumber().equals(request.newPhoneNumber())) { - throw new BusinessException(MemberError.SAME_PHONE_NUMBER); - } - - // 다른 회원이 사용 중인 번호를 입력했을 경우 예외 - if (memberRepository.existsByPhoneNumber(request.newPhoneNumber())) { - throw new BusinessException(MemberError.DUPLICATE_PHONE_NUMBER); - } - - member.updatePhoneNumber(request.newPhoneNumber()); - - } - - @Override - @Transactional - public void updatePassword(UpdatePasswordRequest request) { - - Member member = getCurrentMember(); - - if (passwordEncoder.matches(request.newPassword(), member.getPassword())) { - throw new BusinessException(MemberError.SAME_PASSWORD); - } - - member.updatePassword(passwordEncoder.encode(request.newPassword())); - - } - - @Override - @Transactional(readOnly = true) - public String getMemberEmail(String memberNo) { - Member member = memberRepository.findByMemberNo(memberNo) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - MemberDetail memberDetail = member.getMemberDetail(); - - return memberDetail.getEmail(); - } - - /* 회원 번호 찾기 */ - @Override - @Transactional(readOnly = true) - public SendCodeResponse requestFindMemberNo(FindMemberNoRequest request) { - - Member member = memberRepository.findMemberByNameAndPhoneNumber(request.name(), request.phoneNumber()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - - String memberEmail = member.getMemberDetail().getEmail(); - String memberNo = member.getMemberDetail().getMemberNo(); - - sendCodeAndSaveMemberNo(memberEmail, memberNo); - - return new SendCodeResponse(memberEmail); - } - - @Override - public VerifyMemberNoResponse verifyFindMemberNo(VerifyCodeRequest request) { - - String memberNo = verifyCodeAndGetMemberNo(request); - - return new VerifyMemberNoResponse(memberNo); - } - - @Override - @Transactional(readOnly = true) - public SendCodeResponse requestFindPassword(FindPasswordRequest request) { - - Member member = memberRepository.findByMemberNo(request.memberNo()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - - if (!member.getName().equals(request.name())) { - throw new BusinessException(MemberError.NAME_MISMATCH); - } - - String memberEmail = member.getMemberDetail().getEmail(); - String memberNo = member.getMemberDetail().getMemberNo(); - - sendCodeAndSaveMemberNo(memberEmail, memberNo); - - return new SendCodeResponse(memberEmail); - } - - @Override - public TemporaryTokenResponse verifyFindPassword(VerifyCodeRequest request) { - - String memberNo = verifyCodeAndGetMemberNo(request); - String temporaryToken = tokenProvider.generateTemporaryToken(memberNo); // 5분 동안 유효한 임시토큰 발급 - - return new TemporaryTokenResponse(temporaryToken); - } - - private Member getCurrentMember() { - String currentMemberNo = SecurityUtil.getCurrentMemberNo(); - return memberRepository.findByMemberNo(currentMemberNo) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - } - - private String verifyCodeAndGetMemberNo(VerifyCodeRequest request) { - - String email = request.email(); - String authCode = request.authCode(); - boolean isVerified = memberAuthService.verifyAuthCode(email, authCode); - - if (!isVerified) { // 인증 실패 시 - throw new BusinessException(AuthError.INVALID_AUTH_CODE); - } - - String memberNo = redisUtil.getMemberNo(request.email()); - redisUtil.deleteMemberNo(request.email()); - - return memberNo; - } - - private void sendCodeAndSaveMemberNo(String email, String memberNo) { - redisUtil.saveMemberNo(email, memberNo); // 레디스에 이메일 검증 후 보낼 회원번호 저장 - memberAuthService.sendAuthCode(email); // 찾아온 이메일로 인증 코드 전송 - } - - @Override - @Transactional(readOnly = true) - public java.math.BigDecimal getMileageBalance(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - - // 회원의 마일리지 잔액 반환 (기본값 0) - return member.getMileageBalance() != null ? member.getMileageBalance() : java.math.BigDecimal.ZERO; - } - -} diff --git a/src/main/java/com/sudo/railo/member/application/MemberUpdateService.java b/src/main/java/com/sudo/railo/member/application/MemberUpdateService.java new file mode 100644 index 00000000..a9d37195 --- /dev/null +++ b/src/main/java/com/sudo/railo/member/application/MemberUpdateService.java @@ -0,0 +1,122 @@ +package com.sudo.railo.member.application; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sudo.railo.auth.application.EmailAuthService; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.global.redis.MemberRedisRepository; +import com.sudo.railo.auth.application.dto.request.SendCodeRequest; +import com.sudo.railo.member.application.dto.request.UpdateEmailRequest; +import com.sudo.railo.member.application.dto.request.UpdatePasswordRequest; +import com.sudo.railo.member.application.dto.request.UpdatePhoneNumberRequest; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.domain.MemberDetail; +import com.sudo.railo.auth.exception.AuthError; +import com.sudo.railo.member.exception.MemberError; +import com.sudo.railo.member.infrastructure.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MemberUpdateService { + + private final MemberRepository memberRepository; + private final EmailAuthService emailAuthService; + private final PasswordEncoder passwordEncoder; + private final MemberRedisRepository memberRedisRepository; + + /** + * 이메일 인증을 통한 이메일 변경 + * */ + @Transactional(readOnly = true) + public SendCodeResponse requestUpdateEmail(SendCodeRequest request, String memberNo) { + + Member member = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + MemberDetail memberDetail = member.getMemberDetail(); + + String newEmail = request.email(); + + // 이미 본인 이메일이랑 동일한 이메일로 변경을 요청했을 경우 예외 + if (memberDetail.getEmail().equals(newEmail)) { + throw new BusinessException(MemberError.SAME_EMAIL); + } + + // 다른 회원이 사용중인 이메일을 입력했을 경우 예외 + if (memberRepository.existsByMemberDetailEmail(newEmail)) { + throw new BusinessException(MemberError.DUPLICATE_EMAIL); + } + + // 동일 요청 건이 없으면 같은 이메일에 대한 요청이 들어오지 못하도록 redis 에 등록 + if (!memberRedisRepository.handleUpdateEmailRequest(newEmail)) { + throw new BusinessException(MemberError.EMAIL_UPDATE_ALREADY_REQUESTED); + } + + return emailAuthService.sendAuthCode(newEmail); + } + + @Transactional + public void verifyUpdateEmail(UpdateEmailRequest request, String memberNo) { + + Member member = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + MemberDetail memberDetail = member.getMemberDetail(); + + String newEmail = request.newEmail(); + String authCode = request.authCode(); + + boolean isVerified = emailAuthService.verifyAuthCode(newEmail, authCode); // 이메일 검증 + + if (!isVerified) { + throw new BusinessException(AuthError.INVALID_AUTH_CODE); + } + + memberDetail.updateEmail(newEmail); + memberRedisRepository.deleteUpdateEmailRequest(newEmail); // 해당 변경 요청 건 redis 에서 삭제 + } + + /** + * 회원 휴대폰 번호 변경 + * */ + @Transactional + public void updatePhoneNumber(UpdatePhoneNumberRequest request, String memberNo) { + + Member member = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + + // 이미 본인이 사용하는 번호와 동일하게 입력했을 경우 예외 + if (member.getPhoneNumber().equals(request.newPhoneNumber())) { + throw new BusinessException(MemberError.SAME_PHONE_NUMBER); + } + + // 다른 회원이 사용 중인 번호를 입력했을 경우 예외 + if (memberRepository.existsByPhoneNumber(request.newPhoneNumber())) { + throw new BusinessException(MemberError.DUPLICATE_PHONE_NUMBER); + } + + member.updatePhoneNumber(request.newPhoneNumber()); + + } + + /** + * 회원 비밀번호 변경 + * */ + @Transactional + public void updatePassword(UpdatePasswordRequest request, String memberNo) { + + Member member = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + + if (passwordEncoder.matches(request.newPassword(), member.getPassword())) { + throw new BusinessException(MemberError.SAME_PASSWORD); + } + + member.updatePassword(passwordEncoder.encode(request.newPassword())); + + } + +} diff --git a/src/main/java/com/sudo/railo/member/batch/DeleteExpiredMembersJobConfig.java b/src/main/java/com/sudo/railo/member/batch/DeleteExpiredMembersJobConfig.java new file mode 100644 index 00000000..3fd8ca44 --- /dev/null +++ b/src/main/java/com/sudo/railo/member/batch/DeleteExpiredMembersJobConfig.java @@ -0,0 +1,124 @@ +package com.sudo.railo.member.batch; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.sql.DataSource; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +import com.sudo.railo.member.infrastructure.MemberRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class DeleteExpiredMembersJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final MemberRepository memberRepository; + private final DataSource dataSource; + + private static final int CHUNK_SIZE = 100; // 100개씩 처리 + + @Bean + public Job deleteExpiredMembersJob() { + return new JobBuilder("deleteExpiredMembersJob", jobRepository) + .start(deleteExpiredMembersStep()) + .build(); + } + + @Bean + public Step deleteExpiredMembersStep() { + return new StepBuilder("deleteExpiredMembersStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(expiredMembersReader()) + .writer(expiredMembersWriter()) + .build(); + } + + @Bean + public JdbcPagingItemReader expiredMembersReader() { + + MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider(); + queryProvider.setSelectClause("m.id"); + queryProvider.setFromClause("member m"); + queryProvider.setWhereClause("m.is_deleted = true AND m.updated_at < :date"); + + Map sortKeys = new HashMap<>(); + sortKeys.put("id", Order.ASCENDING); + queryProvider.setSortKeys(sortKeys); + + // 삭제된지 3년이 지난 회원 조회 파라미터 + Map parameters = new HashMap<>(); + parameters.put("date", LocalDateTime.now().minusYears(3)); + + return new JdbcPagingItemReaderBuilder() + .name("expiredMembersReader") + .dataSource(dataSource) + .queryProvider(queryProvider) + .parameterValues(parameters) + .rowMapper((rs, rowNum) -> rs.getLong("id")) + .pageSize(CHUNK_SIZE) + .build(); + } + + @Bean + public ItemWriter expiredMembersWriter() { + // 영구 삭제 처리 + return chunk -> { + List memberIds = new ArrayList<>(chunk.getItems()); + + if (!memberIds.isEmpty()) { + log.info("총 {}명의 회원 영구 삭제 처리", memberIds.size()); + + RetryTemplate retryTemplate = new RetryTemplate(); + SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(5); // 최대 5번 재시도 + retryTemplate.setRetryPolicy(retryPolicy); + + // 백오프 정책 설정 + ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy(); + backOffPolicy.setInitialInterval(250); + backOffPolicy.setMultiplier(2); + backOffPolicy.setMaxInterval(1000); //최대 1초 대기 + retryTemplate.setBackOffPolicy(backOffPolicy); + + try { + retryTemplate.execute(context -> { + memberRepository.deleteAllByIdInBatch(memberIds); + log.info("회원 영구 삭제 성공"); + return null; + }, context -> { + throw new RuntimeException("회원 삭제 실패: 최대 재시도 횟수 초과"); + }); + } catch (Exception e) { + log.error("회원 영구 삭제 처리 중 오류 발생: {}", e.getMessage()); + throw e; + } + + } + }; + } + +} diff --git a/src/main/java/com/sudo/railo/member/batch/DeleteExpiredMembersScheduler.java b/src/main/java/com/sudo/railo/member/batch/DeleteExpiredMembersScheduler.java new file mode 100644 index 00000000..4c413606 --- /dev/null +++ b/src/main/java/com/sudo/railo/member/batch/DeleteExpiredMembersScheduler.java @@ -0,0 +1,42 @@ +package com.sudo.railo.member.batch; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.JobParametersInvalidException; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException; +import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; +import org.springframework.batch.core.repository.JobRestartException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DeleteExpiredMembersScheduler { + + private final JobLauncher jobLauncher; + private final Job deleteExpiredMembersJob; + + @Scheduled(cron = "0 0 3 * * ?") + public void runDeleteExpiredMembers() { + try { + log.info("삭제 배치 작업 시작"); + JobParameters jobParameters = new JobParametersBuilder() + .addLong("time", System.currentTimeMillis()) // 작업 식별을 위한 시간 파라미터 추가 + .toJobParameters(); + + jobLauncher.run(deleteExpiredMembersJob, jobParameters); + log.info("삭제 배치 작업 완료"); + + } catch (JobExecutionAlreadyRunningException | JobRestartException + | JobInstanceAlreadyCompleteException | JobParametersInvalidException e) { + log.error("회원 영구 삭제 배치 작업 실행 중 오류 발생", e); + } + + } +} diff --git a/src/main/java/com/sudo/railo/member/docs/AuthControllerDocs.java b/src/main/java/com/sudo/railo/member/docs/AuthControllerDocs.java deleted file mode 100644 index 2c542a98..00000000 --- a/src/main/java/com/sudo/railo/member/docs/AuthControllerDocs.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.sudo.railo.member.docs; - -import com.sudo.railo.global.exception.error.ErrorResponse; -import com.sudo.railo.global.success.SuccessResponse; -import com.sudo.railo.member.application.dto.request.FindMemberNoRequest; -import com.sudo.railo.member.application.dto.request.FindPasswordRequest; -import com.sudo.railo.member.application.dto.request.MemberNoLoginRequest; -import com.sudo.railo.member.application.dto.request.SendCodeRequest; -import com.sudo.railo.member.application.dto.request.SignUpRequest; -import com.sudo.railo.member.application.dto.request.UpdateEmailRequest; -import com.sudo.railo.member.application.dto.request.VerifyCodeRequest; -import com.sudo.railo.member.application.dto.response.ReissueTokenResponse; -import com.sudo.railo.member.application.dto.response.SendCodeResponse; -import com.sudo.railo.member.application.dto.response.SignUpResponse; -import com.sudo.railo.member.application.dto.response.TemporaryTokenResponse; -import com.sudo.railo.member.application.dto.response.TokenResponse; -import com.sudo.railo.member.application.dto.response.VerifyCodeResponse; -import com.sudo.railo.member.application.dto.response.VerifyMemberNoResponse; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; - -@Tag(name = "Authentication", description = "🔐 인증 API - 회원 로그인, 회원가입, 토큰 관리 API") -public interface AuthControllerDocs { - - @Operation(method = "POST", summary = "회원가입", description = "사용자 정보를 받아 회원가입을 수행합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "회원가입에 성공하였습니다."), - @ApiResponse(responseCode = "409", description = "이미 사용중인 이메일입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "400", description = "요청 본문이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse signUp(SignUpRequest request); - - @Operation(method = "POST", summary = "회원번호 로그인", description = "회원번호와 비밀번호를 받아 로그인을 수행합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그인에 성공하였습니다."), - @ApiResponse(responseCode = "401", description = "비밀번호가 일치하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse memberNoLogin(MemberNoLoginRequest request); - - @Operation(method = "POST", summary = "로그아웃", description = "로그인 되어있는 회원을 로그아웃 처리합니다.", - security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그아웃에 성공하였습니다."), - @ApiResponse(responseCode = "401", description = "이미 로그아웃된 토큰입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse logout(HttpServletRequest request); - - @Operation(method = "POST", summary = "accessToken 재발급", description = "accessToken 이 만료되었을 때, 토큰을 재발급 받을 수 있도록 합니다.", - security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "accessToken 이 성공적으로 재발급되었습니다."), - @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse reissue(HttpServletRequest request); - - @Operation(method = "POST", summary = "인증되지 않은 사용자용 이메일 인증코드 전송 요청", description = "회원번호 찾기, 비밀번호 찾기 등 로그인 할 수 없는 상황에서 사용되는 이메일 인증 요청입니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송을 성공했습니다."), - @ApiResponse(responseCode = "400", description = "요청 본문이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse sendAuthCode(SendCodeRequest request); - - @Operation(method = "POST", summary = "인증된 사용자용 이메일 인증코드 전송 요청", description = "이메일 변경, 휴대폰 번호 변경 등 로그인 되어 있는 상태에서 사용되는 이메일 인증 요청입니다.", - security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송을 성공했습니다."), - @ApiResponse(responseCode = "400", description = "요청 본문이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse sendAuthCodeWithMember(String memberNo); - - @Operation(method = "POST", summary = "이메일 인증 코드 검증", description = "인증된 사용자와 인증되지 않은 사용자 모두 이메일 인증 코드 검증 시 사용합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "인증 코드 검증에 성공했을 경우 true, 실패했을 경우 false"), - @ApiResponse(responseCode = "400", description = "요청 본문이 유효하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse verifyAuthCode(VerifyCodeRequest request); - - @Operation(method = "POST", summary = "회원번호 찾기 요청", description = "회원번호를 찾기 위한 요청을 받고, 본인인증을 위한 이메일 인증 코드를 전송합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송을 성공했습니다."), - @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse requestFindMemberNo(FindMemberNoRequest request); - - @Operation(method = "POST", summary = "회원번호 찾기 인증코드 검증 요청", description = "회원번호를 찾기 위해 인증코드 검증 후, 성공하면 회원번호를 응답으로 보냅니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "이메일 인증 코드 검증에 성공했습니다."), - @ApiResponse(responseCode = "401", description = "인증 코드가 일치하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse verifyFindMemberNo(VerifyCodeRequest request); - - @Operation(method = "POST", summary = "비밀번호 찾기 요청", description = "비밀번호를 찾기 위한 요청을 받고, 본인인증을 위한 이메일 인증 코드를 전송합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송을 성공했습니다."), - @ApiResponse(responseCode = "400", description = "이름이 일치하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse requestFindPassword(FindPasswordRequest request); - - @Operation(method = "POST", summary = "비밀번호 찾기 인증코드 검증 요청", description = "비밀번호를 찾기 위해 인증코드 검증 후, 성공하면 유효시간이 5분인 임시토큰을 응답으로 보냅니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "이메일 인증 코드 검증에 성공했습니다."), - @ApiResponse(responseCode = "401", description = "인증 코드가 일치하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse verifyFindPassword(VerifyCodeRequest request); - - @Operation(method = "POST", summary = "이메일 변경 요청", description = "요청으로 변경할 이메일을 받아 db 내 정보로 변경 가능 여부 확인 후 이메일 인증 코드를 보냅니다.", - security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송을 성공했습니다."), - @ApiResponse(responseCode = "409", description = "현재 사용하는 이메일과 동일합니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "이미 사용중인 이메일입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse requestUpdateEmail(SendCodeRequest request); - - @Operation(method = "PUT", summary = "이메일 변경 인증코드 검증 요청", description = "이메일 변경 전 사용 가능한 이메일인지 검증 후, 회원 이메일을 변경합니다.", - security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "이메일 변경에 성공했습니다."), - @ApiResponse(responseCode = "401", description = "인증 코드가 일치하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse verifyUpdateEmail(UpdateEmailRequest request); -} diff --git a/src/main/java/com/sudo/railo/member/docs/MemberControllerDocs.java b/src/main/java/com/sudo/railo/member/docs/MemberControllerDocs.java index 73d56816..15c796f6 100644 --- a/src/main/java/com/sudo/railo/member/docs/MemberControllerDocs.java +++ b/src/main/java/com/sudo/railo/member/docs/MemberControllerDocs.java @@ -3,8 +3,6 @@ import com.sudo.railo.global.exception.error.ErrorResponse; import com.sudo.railo.global.success.SuccessResponse; import com.sudo.railo.member.application.dto.request.GuestRegisterRequest; -import com.sudo.railo.member.application.dto.request.UpdatePasswordRequest; -import com.sudo.railo.member.application.dto.request.UpdatePhoneNumberRequest; import com.sudo.railo.member.application.dto.response.GuestRegisterResponse; import com.sudo.railo.member.application.dto.response.MemberInfoResponse; @@ -33,7 +31,7 @@ public interface MemberControllerDocs { @ApiResponse(responseCode = "200", description = "회원 탈퇴가 성공적으로 완료되었습니다."), @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - SuccessResponse memberDelete(HttpServletRequest request); + SuccessResponse memberDelete(HttpServletRequest request, String memberNo); @Operation(method = "GET", summary = "단일 회원 정보 조회", description = "로그인 되어 있는 회원의 정보를 조회합니다.", security = {@SecurityRequirement(name = "bearerAuth")}) @@ -41,23 +39,6 @@ public interface MemberControllerDocs { @ApiResponse(responseCode = "200", description = "회원 정보 조회에 성공했습니다."), @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - SuccessResponse getMemberInfo(); - - @Operation(method = "PUT", summary = "휴대폰 번호 변경", description = "요청으로 변경할 휴대폰 번호를 받아 회원 정보의 휴대폰 번호를 새로운 번호로 변경합니다.", - security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "휴대폰 번호 변경에 성공했습니다."), - @ApiResponse(responseCode = "409", description = "현재 사용하는 휴대폰 번호와 동일합니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "이미 사용중인 이메일입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse updatePhoneNumber(UpdatePhoneNumberRequest request); - - @Operation(method = "PUT", summary = "비밀번호 변경", description = "요청으로 변경할 비밀번호를 받아 회원 정보의 비밀번호를 새로운 비밀번호로 변경합니다.", - security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "비밀번호 변경에 성공했습니다."), - @ApiResponse(responseCode = "409", description = "현재 사용하는 비밀번호와 동일합니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) - SuccessResponse updatePassword(UpdatePasswordRequest request); + SuccessResponse getMemberInfo(String memberNo); } diff --git a/src/main/java/com/sudo/railo/member/docs/MemberFindControllerDocs.java b/src/main/java/com/sudo/railo/member/docs/MemberFindControllerDocs.java new file mode 100644 index 00000000..5dd32429 --- /dev/null +++ b/src/main/java/com/sudo/railo/member/docs/MemberFindControllerDocs.java @@ -0,0 +1,50 @@ +package com.sudo.railo.member.docs; + +import com.sudo.railo.auth.application.dto.request.VerifyCodeRequest; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; +import com.sudo.railo.auth.application.dto.response.TemporaryTokenResponse; +import com.sudo.railo.global.exception.error.ErrorResponse; +import com.sudo.railo.global.success.SuccessResponse; +import com.sudo.railo.member.application.dto.request.FindMemberNoRequest; +import com.sudo.railo.member.application.dto.request.FindPasswordRequest; +import com.sudo.railo.member.application.dto.response.VerifyMemberNoResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "AuthMembers", description = "인증+회원 api - 이메일 인증을 통한 회원 정보 찾기 및 변경") +public interface MemberFindControllerDocs { + + @Operation(method = "POST", summary = "회원번호 찾기 요청", description = "회원번호를 찾기 위한 요청을 받고, 본인인증을 위한 이메일 인증 코드를 전송합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송을 성공했습니다."), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse requestFindMemberNo(FindMemberNoRequest request); + + @Operation(method = "POST", summary = "회원번호 찾기 인증코드 검증 요청", description = "회원번호를 찾기 위해 인증코드 검증 후, 성공하면 회원번호를 응답으로 보냅니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 인증 코드 검증에 성공했습니다."), + @ApiResponse(responseCode = "401", description = "인증 코드가 일치하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse verifyFindMemberNo(VerifyCodeRequest request); + + @Operation(method = "POST", summary = "비밀번호 찾기 요청", description = "비밀번호를 찾기 위한 요청을 받고, 본인인증을 위한 이메일 인증 코드를 전송합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송을 성공했습니다."), + @ApiResponse(responseCode = "400", description = "이름이 일치하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse requestFindPassword(FindPasswordRequest request); + + @Operation(method = "POST", summary = "비밀번호 찾기 인증코드 검증 요청", description = "비밀번호를 찾기 위해 인증코드 검증 후, 성공하면 유효시간이 5분인 임시토큰을 응답으로 보냅니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 인증 코드 검증에 성공했습니다."), + @ApiResponse(responseCode = "401", description = "인증 코드가 일치하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse verifyFindPassword(VerifyCodeRequest request); +} diff --git a/src/main/java/com/sudo/railo/member/docs/MemberUpdateControllerDocs.java b/src/main/java/com/sudo/railo/member/docs/MemberUpdateControllerDocs.java new file mode 100644 index 00000000..5bdbfdb5 --- /dev/null +++ b/src/main/java/com/sudo/railo/member/docs/MemberUpdateControllerDocs.java @@ -0,0 +1,58 @@ +package com.sudo.railo.member.docs; + +import com.sudo.railo.global.exception.error.ErrorResponse; +import com.sudo.railo.global.success.SuccessResponse; +import com.sudo.railo.auth.application.dto.request.SendCodeRequest; +import com.sudo.railo.member.application.dto.request.UpdateEmailRequest; +import com.sudo.railo.member.application.dto.request.UpdatePasswordRequest; +import com.sudo.railo.member.application.dto.request.UpdatePhoneNumberRequest; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; + +public interface MemberUpdateControllerDocs { + + @Operation(method = "POST", summary = "이메일 변경 요청", description = "요청으로 변경할 이메일을 받아 db 내 정보로 변경 가능 여부 확인 후 이메일 인증 코드를 보냅니다.", + tags = {"AuthMembers"}, + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 인증 코드 전송을 성공했습니다."), + @ApiResponse(responseCode = "409", description = "현재 사용하는 이메일과 동일합니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 사용중인 이메일입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse requestUpdateEmail(SendCodeRequest request, String memberNo); + + @Operation(method = "PUT", summary = "이메일 변경 인증코드 검증 요청", description = "이메일 변경 전 사용 가능한 이메일인지 검증 후, 회원 이메일을 변경합니다.", + tags = {"AuthMembers"}, + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 변경에 성공했습니다."), + @ApiResponse(responseCode = "401", description = "인증 코드가 일치하지 않습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse verifyUpdateEmail(UpdateEmailRequest request, String memberNo); + + @Operation(method = "PUT", summary = "휴대폰 번호 변경", description = "요청으로 변경할 휴대폰 번호를 받아 회원 정보의 휴대폰 번호를 새로운 번호로 변경합니다.", + tags = {"Members"}, + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "휴대폰 번호 변경에 성공했습니다."), + @ApiResponse(responseCode = "409", description = "현재 사용하는 휴대폰 번호와 동일합니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 사용중인 이메일입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse updatePhoneNumber(UpdatePhoneNumberRequest request, String memberNo); + + @Operation(method = "PUT", summary = "비밀번호 변경", description = "요청으로 변경할 비밀번호를 받아 회원 정보의 비밀번호를 새로운 비밀번호로 변경합니다.", + tags = {"Members"}, + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "비밀번호 변경에 성공했습니다."), + @ApiResponse(responseCode = "409", description = "현재 사용하는 비밀번호와 동일합니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + SuccessResponse updatePassword(UpdatePasswordRequest request, String memberNo); + +} diff --git a/src/main/java/com/sudo/railo/member/domain/Member.java b/src/main/java/com/sudo/railo/member/domain/Member.java index c8e7dffe..a5bd569c 100644 --- a/src/main/java/com/sudo/railo/member/domain/Member.java +++ b/src/main/java/com/sudo/railo/member/domain/Member.java @@ -1,5 +1,8 @@ package com.sudo.railo.member.domain; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + import com.sudo.railo.global.domain.BaseEntity; import jakarta.persistence.Column; @@ -10,6 +13,8 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -19,6 +24,14 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) +@SQLDelete(sql = "UPDATE member SET is_deleted = true WHERE id = ?") +@SQLRestriction("is_deleted = false") +@Table( + name = "member", + indexes = { + @Index(name = "idx_member_deleted_updated", columnList = "is_deleted,updated_at") + } +) public class Member extends BaseEntity { @Id @@ -40,8 +53,8 @@ public class Member extends BaseEntity { @Embedded private MemberDetail memberDetail; - @Column(name = "mileage_balance", precision = 10, scale = 0) - private java.math.BigDecimal mileageBalance = java.math.BigDecimal.ZERO; + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; private Member(String name, String phoneNumber, String password, Role role, MemberDetail memberDetail) { this.name = name; @@ -49,7 +62,7 @@ private Member(String name, String phoneNumber, String password, Role role, Memb this.password = password; this.role = role; this.memberDetail = memberDetail; - this.mileageBalance = java.math.BigDecimal.ZERO; + this.isDeleted = false; } public static Member create(String name, String phoneNumber, String password, Role role, @@ -70,33 +83,4 @@ public void updatePassword(String password) { this.password = password; } - /** - * 마일리지 추가 - */ - public void addMileage(Long amount) { - if (this.memberDetail == null) { - throw new IllegalStateException("회원 상세 정보가 없습니다"); - } - this.memberDetail.addMileage(amount); - } - - /** - * 마일리지 차감 - */ - public void useMileage(Long amount) { - if (this.memberDetail == null) { - throw new IllegalStateException("회원 상세 정보가 없습니다"); - } - this.memberDetail.useMileage(amount); - } - - /** - * 현재 마일리지 조회 - */ - public Long getTotalMileage() { - if (this.memberDetail == null) { - return 0L; - } - return this.memberDetail.getTotalMileage(); - } } diff --git a/src/main/java/com/sudo/railo/member/domain/MemberDetail.java b/src/main/java/com/sudo/railo/member/domain/MemberDetail.java index bc04956b..143f86d3 100644 --- a/src/main/java/com/sudo/railo/member/domain/MemberDetail.java +++ b/src/main/java/com/sudo/railo/member/domain/MemberDetail.java @@ -46,34 +46,4 @@ public void updateEmail(String newEmail) { this.email = newEmail; } - /** - * 마일리지 조회 (MemberInfoAdapter 호환성을 위한 메서드) - */ - public Long getMileage() { - return this.totalMileage; - } - - /** - * 마일리지 추가 - */ - public void addMileage(Long amount) { - if (amount < 0) { - throw new IllegalArgumentException("마일리지 추가 금액은 0보다 커야 합니다"); - } - this.totalMileage += amount; - } - - /** - * 마일리지 차감 - */ - public void useMileage(Long amount) { - if (amount < 0) { - throw new IllegalArgumentException("마일리지 사용 금액은 0보다 커야 합니다"); - } - if (this.totalMileage < amount) { - throw new IllegalArgumentException("마일리지가 부족합니다"); - } - this.totalMileage -= amount; - } - } diff --git a/src/main/java/com/sudo/railo/member/infra/MemberRepository.java b/src/main/java/com/sudo/railo/member/infra/MemberRepository.java deleted file mode 100644 index e6cbe947..00000000 --- a/src/main/java/com/sudo/railo/member/infra/MemberRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.sudo.railo.member.infra; - -import java.util.List; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.sudo.railo.member.domain.Member; - -public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { - boolean existsByMemberDetailEmail(String email); - - List findByNameAndPhoneNumber(String name, String phoneNumber); - - boolean existsByPhoneNumber(String phoneNumber); -} diff --git a/src/main/java/com/sudo/railo/member/infra/MemberRepositoryCustom.java b/src/main/java/com/sudo/railo/member/infra/MemberRepositoryCustom.java deleted file mode 100644 index f8dcd74c..00000000 --- a/src/main/java/com/sudo/railo/member/infra/MemberRepositoryCustom.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sudo.railo.member.infra; - -import java.util.Optional; - -import com.sudo.railo.member.domain.Member; - -public interface MemberRepositoryCustom { - Optional findByMemberNo(String memberNo); - - Optional findMemberByNameAndPhoneNumber(String name, String phoneNumber); -} diff --git a/src/main/java/com/sudo/railo/member/infrastructure/MemberRepository.java b/src/main/java/com/sudo/railo/member/infrastructure/MemberRepository.java new file mode 100644 index 00000000..6344bbed --- /dev/null +++ b/src/main/java/com/sudo/railo/member/infrastructure/MemberRepository.java @@ -0,0 +1,26 @@ +package com.sudo.railo.member.infrastructure; + +import java.util.List; +import java.util.Optional; + +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 com.sudo.railo.member.domain.Member; + +public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { + boolean existsByMemberDetailEmail(String email); + + List findByNameAndPhoneNumber(String name, String phoneNumber); + + boolean existsByPhoneNumber(String phoneNumber); + + @Modifying + @Query(value = "delete from member where id in (:memberIds)", nativeQuery = true) + void deleteAllByIdInBatch(@Param("memberIds") List memberIds); + + @Query(value = "SELECT * FROM member m WHERE m.member_no = :memberNo", nativeQuery = true) + Optional findByMemberNoIgnoreIsDeleted(@Param("memberNo") String memberNo); +} diff --git a/src/main/java/com/sudo/railo/member/infrastructure/MemberRepositoryCustom.java b/src/main/java/com/sudo/railo/member/infrastructure/MemberRepositoryCustom.java new file mode 100644 index 00000000..5f1e6c2b --- /dev/null +++ b/src/main/java/com/sudo/railo/member/infrastructure/MemberRepositoryCustom.java @@ -0,0 +1,18 @@ +package com.sudo.railo.member.infrastructure; + +import java.util.Optional; + +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.exception.MemberError; + +public interface MemberRepositoryCustom { + Optional findByMemberNo(String memberNo); + + Optional findMemberByNameAndPhoneNumber(String name, String phoneNumber); + + default Member getMember(String memberNo) { + return findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/sudo/railo/member/infra/MemberRepositoryCustomImpl.java b/src/main/java/com/sudo/railo/member/infrastructure/MemberRepositoryCustomImpl.java similarity index 96% rename from src/main/java/com/sudo/railo/member/infra/MemberRepositoryCustomImpl.java rename to src/main/java/com/sudo/railo/member/infrastructure/MemberRepositoryCustomImpl.java index ec5c770d..f7edf34a 100644 --- a/src/main/java/com/sudo/railo/member/infra/MemberRepositoryCustomImpl.java +++ b/src/main/java/com/sudo/railo/member/infrastructure/MemberRepositoryCustomImpl.java @@ -1,4 +1,4 @@ -package com.sudo.railo.member.infra; +package com.sudo.railo.member.infrastructure; import java.util.Optional; diff --git a/src/main/java/com/sudo/railo/member/presentation/AuthController.java b/src/main/java/com/sudo/railo/member/presentation/AuthController.java deleted file mode 100644 index a06cfe6f..00000000 --- a/src/main/java/com/sudo/railo/member/presentation/AuthController.java +++ /dev/null @@ -1,170 +0,0 @@ -package com.sudo.railo.member.presentation; - -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.sudo.railo.global.security.jwt.TokenExtractor; -import com.sudo.railo.global.success.SuccessResponse; -import com.sudo.railo.member.application.MemberAuthService; -import com.sudo.railo.member.application.MemberService; -import com.sudo.railo.member.application.dto.request.FindMemberNoRequest; -import com.sudo.railo.member.application.dto.request.FindPasswordRequest; -import com.sudo.railo.member.application.dto.request.MemberNoLoginRequest; -import com.sudo.railo.member.application.dto.request.SendCodeRequest; -import com.sudo.railo.member.application.dto.request.SignUpRequest; -import com.sudo.railo.member.application.dto.request.UpdateEmailRequest; -import com.sudo.railo.member.application.dto.request.VerifyCodeRequest; -import com.sudo.railo.member.application.dto.response.ReissueTokenResponse; -import com.sudo.railo.member.application.dto.response.SendCodeResponse; -import com.sudo.railo.member.application.dto.response.SignUpResponse; -import com.sudo.railo.member.application.dto.response.TemporaryTokenResponse; -import com.sudo.railo.member.application.dto.response.TokenResponse; -import com.sudo.railo.member.application.dto.response.VerifyCodeResponse; -import com.sudo.railo.member.application.dto.response.VerifyMemberNoResponse; -import com.sudo.railo.member.docs.AuthControllerDocs; -import com.sudo.railo.member.success.AuthSuccess; -import com.sudo.railo.member.success.MemberSuccess; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@RestController -@RequestMapping("/auth") -@RequiredArgsConstructor -public class AuthController implements AuthControllerDocs { - - private final MemberAuthService memberAuthService; - private final TokenExtractor tokenExtractor; - private final MemberService memberService; - - @PostMapping("/signup") - public SuccessResponse signUp(@RequestBody @Valid SignUpRequest request) { - - SignUpResponse response = memberAuthService.signUp(request); - - return SuccessResponse.of(AuthSuccess.SIGN_UP_SUCCESS, response); - } - - @PostMapping("/login") - public SuccessResponse memberNoLogin(@RequestBody @Valid MemberNoLoginRequest request) { - - TokenResponse tokenResponse = memberAuthService.memberNoLogin(request); - - return SuccessResponse.of(AuthSuccess.MEMBER_NO_LOGIN_SUCCESS, tokenResponse); - } - - @PostMapping("/logout") - public SuccessResponse logout(HttpServletRequest request) { - - String accessToken = tokenExtractor.resolveToken(request); - - memberAuthService.logout(accessToken); - - return SuccessResponse.of(AuthSuccess.LOGOUT_SUCCESS); - } - - @PostMapping("/reissue") - public SuccessResponse reissue(HttpServletRequest request) { - - String refreshToken = tokenExtractor.resolveToken(request); - - ReissueTokenResponse tokenResponse = memberAuthService.reissueAccessToken(refreshToken); - - return SuccessResponse.of(AuthSuccess.REISSUE_TOKEN_SUCCESS, tokenResponse); - } - - /* 이메일 인증 (인증되지 않은 사용자) */ - @PostMapping("/emails") - public SuccessResponse sendAuthCode(@RequestBody @Valid SendCodeRequest request) { - - String email = request.email(); - SendCodeResponse response = memberAuthService.sendAuthCode(email); - - return SuccessResponse.of(AuthSuccess.SEND_CODE_SUCCESS, response); - } - - /* 이메일 인증 (인증된 사용자) */ - @PostMapping("/members/emails") - public SuccessResponse sendAuthCodeWithMember( - @AuthenticationPrincipal(expression = "username") String memberNo) { - - log.info("memberNo: {}", memberNo); - - String email = memberService.getMemberEmail(memberNo); - SendCodeResponse response = memberAuthService.sendAuthCode(email); - - return SuccessResponse.of(AuthSuccess.SEND_CODE_SUCCESS, response); - } - - /* 이메일 인증 (인증된 사용자, 인증되지 않은 사용자 모두 사용) */ - @PostMapping("/emails/verify") - public SuccessResponse verifyAuthCode(@RequestBody @Valid VerifyCodeRequest request) { - - String email = request.email(); - String authCode = request.authCode(); - - boolean isVerified = memberAuthService.verifyAuthCode(email, authCode); - VerifyCodeResponse response = new VerifyCodeResponse(isVerified); - - return SuccessResponse.of(AuthSuccess.VERIFY_CODE_SUCCESS_FINISH, response); - } - - /* 회원 번호 찾기 with 이메일 인증 */ - @PostMapping("/member-no") - public SuccessResponse requestFindMemberNo(@RequestBody @Valid FindMemberNoRequest request) { - - SendCodeResponse response = memberService.requestFindMemberNo(request); - - return SuccessResponse.of(AuthSuccess.SEND_CODE_SUCCESS, response); - } - - @PostMapping("/member-no/verify") - public SuccessResponse verifyFindMemberNo(@RequestBody @Valid VerifyCodeRequest request) { - - VerifyMemberNoResponse response = memberService.verifyFindMemberNo(request); - - return SuccessResponse.of(AuthSuccess.VERIFY_CODE_SUCCESS, response); - } - - /* 비밀번호 찾기 with 이메일 인증 */ - @PostMapping("/password") - public SuccessResponse requestFindPassword(@RequestBody @Valid FindPasswordRequest request) { - - SendCodeResponse response = memberService.requestFindPassword(request); - - return SuccessResponse.of(AuthSuccess.SEND_CODE_SUCCESS, response); - } - - @PostMapping("/password/verify") - public SuccessResponse verifyFindPassword(@RequestBody @Valid VerifyCodeRequest request) { - - TemporaryTokenResponse response = memberService.verifyFindPassword(request); - - return SuccessResponse.of(AuthSuccess.VERIFY_CODE_SUCCESS, response); - } - - /* 이메일 변경 with 이메일 인증 */ - @PostMapping("/members/me/email-code") - public SuccessResponse requestUpdateEmail(@RequestBody @Valid SendCodeRequest request) { - - SendCodeResponse response = memberService.requestUpdateEmail(request); - - return SuccessResponse.of(AuthSuccess.SEND_CODE_SUCCESS, response); - } - - @PutMapping("/members/me/email-code") - public SuccessResponse verifyUpdateEmail(@RequestBody @Valid UpdateEmailRequest request) { - - memberService.verifyUpdateEmail(request); - - return SuccessResponse.of(MemberSuccess.MEMBER_EMAIL_UPDATE_SUCCESS); - } - -} diff --git a/src/main/java/com/sudo/railo/member/presentation/MemberController.java b/src/main/java/com/sudo/railo/member/presentation/MemberController.java index bb74f61c..a2905afd 100644 --- a/src/main/java/com/sudo/railo/member/presentation/MemberController.java +++ b/src/main/java/com/sudo/railo/member/presentation/MemberController.java @@ -1,19 +1,17 @@ package com.sudo.railo.member.presentation; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.sudo.railo.global.security.jwt.TokenExtractor; +import com.sudo.railo.auth.security.jwt.TokenExtractor; import com.sudo.railo.global.success.SuccessResponse; import com.sudo.railo.member.application.MemberService; import com.sudo.railo.member.application.dto.request.GuestRegisterRequest; -import com.sudo.railo.member.application.dto.request.UpdatePasswordRequest; -import com.sudo.railo.member.application.dto.request.UpdatePhoneNumberRequest; import com.sudo.railo.member.application.dto.response.GuestRegisterResponse; import com.sudo.railo.member.application.dto.response.MemberInfoResponse; import com.sudo.railo.member.docs.MemberControllerDocs; @@ -40,36 +38,23 @@ public SuccessResponse guestRegister(@RequestBody @Valid } @DeleteMapping("/members") - public SuccessResponse memberDelete(HttpServletRequest request) { + public SuccessResponse memberDelete(HttpServletRequest request, + @AuthenticationPrincipal(expression = "username") String memberNo) { String accessToken = tokenExtractor.resolveToken(request); - memberService.memberDelete(accessToken); + memberService.memberDelete(accessToken, memberNo); return SuccessResponse.of(MemberSuccess.MEMBER_DELETE_SUCCESS); } @GetMapping("/members/me") - public SuccessResponse getMemberInfo() { + public SuccessResponse getMemberInfo( + @AuthenticationPrincipal(expression = "username") String memberNo) { - MemberInfoResponse response = memberService.getMemberInfo(); + MemberInfoResponse response = memberService.getMemberInfo(memberNo); return SuccessResponse.of(MemberSuccess.MEMBER_INFO_SUCCESS, response); } - @PutMapping("/members/phone-number") - public SuccessResponse updatePhoneNumber(@RequestBody @Valid UpdatePhoneNumberRequest request) { - - memberService.updatePhoneNumber(request); - - return SuccessResponse.of(MemberSuccess.MEMBER_PHONENUMBER_UPDATE_SUCCESS); - } - - @PutMapping("/members/password") - public SuccessResponse updatePassword(@RequestBody @Valid UpdatePasswordRequest request) { - - memberService.updatePassword(request); - - return SuccessResponse.of(MemberSuccess.MEMBER_PASSWORD_UPDATE_SUCCESS); - } } diff --git a/src/main/java/com/sudo/railo/member/presentation/MemberFindController.java b/src/main/java/com/sudo/railo/member/presentation/MemberFindController.java new file mode 100644 index 00000000..b3ba733a --- /dev/null +++ b/src/main/java/com/sudo/railo/member/presentation/MemberFindController.java @@ -0,0 +1,68 @@ +package com.sudo.railo.member.presentation; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sudo.railo.auth.application.dto.request.VerifyCodeRequest; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; +import com.sudo.railo.auth.application.dto.response.TemporaryTokenResponse; +import com.sudo.railo.auth.success.AuthSuccess; +import com.sudo.railo.global.success.SuccessResponse; +import com.sudo.railo.member.application.MemberFindService; +import com.sudo.railo.member.application.dto.request.FindMemberNoRequest; +import com.sudo.railo.member.application.dto.request.FindPasswordRequest; +import com.sudo.railo.member.application.dto.response.VerifyMemberNoResponse; +import com.sudo.railo.member.docs.MemberFindControllerDocs; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class MemberFindController implements MemberFindControllerDocs { + + private final MemberFindService memberFindService; + + /** + * 이메일 인증을 통한 회원 번호 찾기 + * */ + @PostMapping("/member-no") + public SuccessResponse requestFindMemberNo(@RequestBody @Valid FindMemberNoRequest request) { + + SendCodeResponse response = memberFindService.requestFindMemberNo(request); + + return SuccessResponse.of(AuthSuccess.SEND_CODE_SUCCESS, response); + } + + @PostMapping("/member-no/verify") + public SuccessResponse verifyFindMemberNo(@RequestBody @Valid VerifyCodeRequest request) { + + VerifyMemberNoResponse response = memberFindService.verifyFindMemberNo(request); + + return SuccessResponse.of(AuthSuccess.VERIFY_CODE_SUCCESS, response); + } + + /** + * 이메일 인증을 통한 비밀번호 찾기 + * */ + @PostMapping("/password") + public SuccessResponse requestFindPassword(@RequestBody @Valid FindPasswordRequest request) { + + SendCodeResponse response = memberFindService.requestFindPassword(request); + + return SuccessResponse.of(AuthSuccess.SEND_CODE_SUCCESS, response); + } + + @PostMapping("/password/verify") + public SuccessResponse verifyFindPassword(@RequestBody @Valid VerifyCodeRequest request) { + + TemporaryTokenResponse response = memberFindService.verifyFindPassword(request); + + return SuccessResponse.of(AuthSuccess.VERIFY_CODE_SUCCESS, response); + } +} diff --git a/src/main/java/com/sudo/railo/member/presentation/MemberUpdateController.java b/src/main/java/com/sudo/railo/member/presentation/MemberUpdateController.java new file mode 100644 index 00000000..8d07e7ff --- /dev/null +++ b/src/main/java/com/sudo/railo/member/presentation/MemberUpdateController.java @@ -0,0 +1,67 @@ +package com.sudo.railo.member.presentation; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import com.sudo.railo.global.success.SuccessResponse; +import com.sudo.railo.member.application.MemberUpdateService; +import com.sudo.railo.auth.application.dto.request.SendCodeRequest; +import com.sudo.railo.member.application.dto.request.UpdateEmailRequest; +import com.sudo.railo.member.application.dto.request.UpdatePasswordRequest; +import com.sudo.railo.member.application.dto.request.UpdatePhoneNumberRequest; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; +import com.sudo.railo.member.docs.MemberUpdateControllerDocs; +import com.sudo.railo.auth.success.AuthSuccess; +import com.sudo.railo.member.success.MemberSuccess; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class MemberUpdateController implements MemberUpdateControllerDocs { + + private final MemberUpdateService memberUpdateService; + + @PostMapping("/auth/members/me/email-code") + public SuccessResponse requestUpdateEmail(@RequestBody @Valid SendCodeRequest request, + @AuthenticationPrincipal(expression = "username") String memberNo) { + + SendCodeResponse response = memberUpdateService.requestUpdateEmail(request, memberNo); + + return SuccessResponse.of(AuthSuccess.SEND_CODE_SUCCESS, response); + } + + @PutMapping("/auth/members/me/email-code") + public SuccessResponse verifyUpdateEmail(@RequestBody @Valid UpdateEmailRequest request, + @AuthenticationPrincipal(expression = "username") String memberNo) { + + memberUpdateService.verifyUpdateEmail(request, memberNo); + + return SuccessResponse.of(MemberSuccess.MEMBER_EMAIL_UPDATE_SUCCESS); + } + + @PutMapping("/api/v1/members/phone-number") + public SuccessResponse updatePhoneNumber(@RequestBody @Valid UpdatePhoneNumberRequest request, + @AuthenticationPrincipal(expression = "username") String memberNo) { + + memberUpdateService.updatePhoneNumber(request, memberNo); + + return SuccessResponse.of(MemberSuccess.MEMBER_PHONENUMBER_UPDATE_SUCCESS); + } + + @PutMapping("/api/v1/members/password") + public SuccessResponse updatePassword(@RequestBody @Valid UpdatePasswordRequest request, + @AuthenticationPrincipal(expression = "username") String memberNo) { + + memberUpdateService.updatePassword(request, memberNo); + + return SuccessResponse.of(MemberSuccess.MEMBER_PASSWORD_UPDATE_SUCCESS); + } + +} diff --git a/src/main/java/com/sudo/railo/payment/application/PaymentService.java b/src/main/java/com/sudo/railo/payment/application/PaymentService.java new file mode 100644 index 00000000..9629f413 --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/application/PaymentService.java @@ -0,0 +1,271 @@ +package com.sudo.railo.payment.application; + +import java.math.BigDecimal; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sudo.railo.booking.application.ReservationApplicationService; +import com.sudo.railo.booking.application.TicketService; +import com.sudo.railo.booking.domain.Reservation; +import com.sudo.railo.booking.exception.BookingError; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepository; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.exception.MemberError; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.payment.application.dto.PaymentInfo; +import com.sudo.railo.payment.application.dto.projection.PaymentProjection; +import com.sudo.railo.payment.application.dto.request.PaymentProcessAccountRequest; +import com.sudo.railo.payment.application.dto.request.PaymentProcessCardRequest; +import com.sudo.railo.payment.application.dto.request.PaymentProcessRequest; +import com.sudo.railo.payment.application.dto.response.PaymentCancelResponse; +import com.sudo.railo.payment.application.dto.response.PaymentHistoryResponse; +import com.sudo.railo.payment.application.dto.response.PaymentProcessResponse; +import com.sudo.railo.payment.domain.Payment; +import com.sudo.railo.payment.domain.status.PaymentStatus; +import com.sudo.railo.payment.exception.PaymentError; +import com.sudo.railo.payment.infrastructure.PaymentRepository; +import com.sudo.railo.payment.util.PaymentKeyGenerator; +import com.sudo.railo.train.infrastructure.SeatReservationRepositoryCustom; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final ReservationRepository reservationRepository; + private final SeatReservationRepositoryCustom seatReservationRepositoryCustom; + private final MemberRepository memberRepository; + private final PaymentRepository paymentRepository; + private final PaymentKeyGenerator paymentKeyGenerator; + private final ReservationApplicationService reservationApplicationService; + private final TicketService ticketService; + + /** + * 결제 처리 (카드) + * + * @param memberNo 회원번호 + * @param request {@link PaymentProcessCardRequest} 객체 + * @return {@link PaymentProcessResponse} 객체 + */ + @Transactional + public PaymentProcessResponse processPaymentViaCard(String memberNo, PaymentProcessCardRequest request) { + return processPayment(memberNo, request); + } + + /** + * 결제 처리 (계좌 이체) + * + * @param memberNo 회원번호 + * @param request {@link PaymentProcessAccountRequest} 객체 + * @return {@link PaymentProcessResponse} 객체 + */ + @Transactional + public PaymentProcessResponse processPaymentViaBankAccount(String memberNo, PaymentProcessAccountRequest request) { + return processPayment(memberNo, request); + } + + /** + * 결제 처리 (공통) + * + * @param memberNo 회원번호 + * @param request {@link PaymentProcessRequest} 객체 + * @return {@link PaymentProcessResponse} 객체 + */ + private PaymentProcessResponse processPayment(String memberNo, PaymentProcessRequest request) { + Reservation reservation = getReservation(request.getReservationId()); + Payment payment = createAndSavePayment(request, memberNo, reservation); + + validatePaymentApprovalConditions(payment, reservation); + executePaymentApproval(payment, reservation); + completePaymentProcess(request, payment, reservation); + + return PaymentProcessResponse.from(payment); + } + + private Reservation getReservation(Long reservationId) { + return reservationRepository.findById(reservationId) + .orElseThrow(() -> new BusinessException(PaymentError.RESERVATION_NOT_FOUND)); + } + + private Payment createAndSavePayment(PaymentProcessRequest request, String memberNo, Reservation reservation) { + Member member = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + + validateReservation(member, reservation); + validatePaymentProcessRequest(request, reservation); + + String paymentKey = paymentKeyGenerator.generatePaymentKey(memberNo); + PaymentInfo paymentInfo = new PaymentInfo(request.getAmount(), request.getPaymentMethod(), PaymentStatus.PENDING); + Payment payment = Payment.create(member, reservation, paymentKey, paymentInfo); + + return paymentRepository.save(payment); + } + + private void validateReservation(Member member, Reservation reservation) { + // 예약 소유자 검증 + if (!reservation.getMember().getId().equals(member.getId())) { + throw new BusinessException(PaymentError.RESERVATION_ACCESS_DENIED); + } + + // 예약 상태 검증 (결제 가능한 상태인지) + if (!reservation.canBePaid()) { + throw new BusinessException(PaymentError.RESERVATION_NOT_PAYABLE); + } + } + + private void validatePaymentProcessRequest(PaymentProcessRequest request, Reservation reservation) { + // 금액 위변조 검증 + if (!request.getAmount().equals(BigDecimal.valueOf(reservation.getFare()))) { + throw new BusinessException(PaymentError.PAYMENT_AMOUNT_MISMATCH); + } + + // 중복 결제 검증 + if (paymentRepository.existsByReservationIdAndPaymentStatus(request.getReservationId(), PaymentStatus.PAID)) { + throw new BusinessException(PaymentError.PAYMENT_ALREADY_COMPLETED); + } + } + + private void validatePaymentApprovalConditions(Payment payment, Reservation reservation) { + // 결제 상태 검증 + if (!payment.canBePaid()) { + throw new BusinessException(PaymentError.PAYMENT_NOT_APPROVABLE); + } + + // 예약 상태 재검증 (동시성 문제 방지) + if (!reservation.canBePaid()) { + throw new BusinessException(PaymentError.RESERVATION_NOT_PAYABLE); + } + } + + private void executePaymentApproval(Payment payment, Reservation reservation) { + // 결제 승인 처리 + payment.approve(); + + // 예약 상태 변경 + markReservationAsPaid(reservation); + } + + private void markReservationAsPaid(Reservation reservation) { + reservation.approve(); + + log.info("예약 결제 완료 처리: reservationId={}", reservation.getId()); + } + + private void completePaymentProcess(PaymentProcessRequest request, Payment payment, Reservation reservation) { + // 티켓 발급 + generateTicket(reservation); + + log.info("결제 완료: paymentKey={}, reservationId={}, amount={}", + payment.getPaymentKey(), request.getReservationId(), request.getAmount()); + } + + private void generateTicket(Reservation reservation) { + seatReservationRepositoryCustom.findSeatInfoByReservationId(reservation.getId()) + .forEach(seatInfoProjection -> ticketService.createTicket( + reservation, seatInfoProjection.getSeat(), seatInfoProjection.getPassengerType())); + } + + @Transactional + public PaymentCancelResponse cancelPayment(String memberNo, String paymentKey) { + Payment payment = findPayment(memberNo, paymentKey); + + // 결제 취소 처리 + payment.cancel("사용자 요청에 의한 취소"); + + markReservationAsCancelled(payment.getReservation()); + + log.info("결제 취소 완료: paymentKey={}, reservationId={}", paymentKey, payment.getReservation().getId()); + + // 즉각 환불 처리 (임시) + refundPayment(payment, payment.getReservation()); + + return PaymentCancelResponse.from(payment); + } + + private Payment findPayment(String memberNo, String paymentKey) { + Member member = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + + Payment payment = paymentRepository.findByPaymentKey(paymentKey) + .orElseThrow(() -> new BusinessException(PaymentError.PAYMENT_NOT_FOUND)); + + validatePaymentCancellableCondition(member, payment); + + return payment; + } + + private void validatePaymentCancellableCondition(Member member, Payment payment) { + // 결제 소유자 확인 + if (!payment.getMember().getId().equals(member.getId())) { + throw new BusinessException(PaymentError.PAYMENT_ACCESS_DENIED); + } + + // 결제 취소 가능 여부 확인 + if (!payment.canBeCancelled()) { + throw new BusinessException(PaymentError.PAYMENT_NOT_CANCELLABLE); + } + + // 예약 취소 가능 여부 확인 + if (!payment.getReservation().canBeCancelled()) { + throw new BusinessException(BookingError.RESERVATION_DELETE_FAILED); + } + } + + private void markReservationAsCancelled(Reservation reservation) { + reservation.cancel(); + reservationApplicationService.cancelReservation(reservation); + + log.info("예약 취소 처리: reservationId={}", reservation.getId()); + } + + /** + * 환불 처리 + * @param payment {@link Payment} 객체 + */ + public void refundPayment(Payment payment, Reservation reservation) { + // 해당 로직은 외부 엔드포인트가 존재하지 않고 추후 엔드포인트가 생긴다고 하더라도 + // 신뢰할 수 있는 PG사에서 환불 완료된 결제에 대해서만 호출할 예정이기 때문에 검증 과정이 필요 없습니다. + + // 즉각 환불 처리 로직 (임시) + if (payment.canBeRefunded()) { + payment.refund(); + } + + markReservationAsRefunded(reservation); + + log.info("환불 처리 완료: paymentKey={}", payment.getPaymentKey()); + } + + private void markReservationAsRefunded(Reservation reservation) { + reservation.refund(); + + log.info("예약 환불 처리: reservationId={}", reservation.getId()); + } + + /** + * 결제 내역 조회 + * @param memberNo 회원번호 + * @return {@code List} + */ + @Transactional(readOnly = true) + public List getPaymentHistory(String memberNo) { + Member member = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); + + List paymentProjections = paymentRepository.findPaymentHistoryByMemberId(member.getId()); + + return paymentProjections.stream() + .map(paymentProjection -> new PaymentHistoryResponse( + paymentProjection.getPaymentId(), paymentProjection.getPaymentKey(), + paymentProjection.getReservationCode(), paymentProjection.getAmount(), + paymentProjection.getPaymentMethod(), paymentProjection.getPaymentStatus(), + paymentProjection.getPaidAt(), paymentProjection.getCancelledAt(), paymentProjection.getRefundedAt())) + .toList(); + } +} diff --git a/src/main/java/com/sudo/railo/payment/application/config/PaymentConfig.java b/src/main/java/com/sudo/railo/payment/application/config/PaymentConfig.java deleted file mode 100644 index 4c9e4f8b..00000000 --- a/src/main/java/com/sudo/railo/payment/application/config/PaymentConfig.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.sudo.railo.payment.application.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.time.Duration; - -@Data -@Component -@ConfigurationProperties(prefix = "payment") -public class PaymentConfig { - - /** - * 결제 계산 세션 만료 시간 (분) - */ - private int calculationExpiryMinutes = 30; - - /** - * 최근 거래 조회 제한 개수 - */ - private int recentTransactionsLimit = 10; - - /** - * 최대 재시도 횟수 - */ - private int maxRetryAttempts = 3; - - /** - * 락 타임아웃 시간 - */ - private Duration lockTimeout = Duration.ofSeconds(10); - - /** - * 마일리지 관련 설정 - */ - private MileageConfig mileage = new MileageConfig(); - - /** - * 결제 검증 관련 설정 - */ - private ValidationConfig validation = new ValidationConfig(); - - /** - * 배치 처리 관련 설정 - */ - private BatchConfig batch = new BatchConfig(); - - @Data - public static class MileageConfig { - /** - * 마일리지 전환율 (1원당 적립 마일리지) - */ - private BigDecimal conversionRate = new BigDecimal("1.0"); - - /** - * 최대 마일리지 사용 비율 (전체 결제금액의 100%) - */ - private BigDecimal maxUsageRate = new BigDecimal("1.0"); - - /** - * 마일리지 최소 사용 금액 - */ - private BigDecimal minUsageAmount = new BigDecimal("1000"); - - /** - * 마일리지 유효기간 (개월) - */ - private int validityMonths = 24; - - /** - * 마일리지 적립 최소 결제금액 - */ - private BigDecimal minEarnAmount = new BigDecimal("1000"); - } - - @Data - public static class ValidationConfig { - /** - * 최대 결제 금액 제한 - */ - private BigDecimal maxPaymentAmount = new BigDecimal("10000000"); - - /** - * 최소 결제 금액 - */ - private BigDecimal minPaymentAmount = new BigDecimal("100"); - - /** - * 비회원 최대 결제 금액 - */ - private BigDecimal maxNonMemberPaymentAmount = new BigDecimal("500000"); - - /** - * 일일 최대 결제 횟수 (회원) - */ - private int maxDailyPaymentCount = 50; - - /** - * 일일 최대 결제 횟수 (비회원) - */ - private int maxDailyNonMemberPaymentCount = 10; - } - - @Data - public static class BatchConfig { - /** - * 배치 처리 크기 - */ - private int batchSize = 1000; - - /** - * 만료 세션 정리 주기 (분) - */ - private int expiredSessionCleanupIntervalMinutes = 60; - - /** - * 마일리지 만료 처리 주기 (시간) - */ - private int mileageExpiryProcessIntervalHours = 24; - - /** - * 통계 집계 주기 (시간) - */ - private int statisticsAggregationIntervalHours = 6; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/config/RefundPolicyConfig.java b/src/main/java/com/sudo/railo/payment/application/config/RefundPolicyConfig.java deleted file mode 100644 index 77316c8e..00000000 --- a/src/main/java/com/sudo/railo/payment/application/config/RefundPolicyConfig.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.sudo.railo.payment.application.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -import java.math.BigDecimal; -import java.util.HashMap; -import java.util.Map; - -/** - * 환불 정책 설정 - * application.yml에서 환불 수수료율을 설정할 수 있도록 지원 - */ -@Data -@Configuration -@ConfigurationProperties(prefix = "payment.refund") -public class RefundPolicyConfig { - - /** - * 운영사별 환불 정책 설정 - */ - private Map operators = new HashMap<>(); - - /** - * 기본 환불 정책 활성화 여부 - */ - private boolean defaultPolicyEnabled = true; - - @Data - public static class OperatorRefundPolicy { - /** - * 정책 활성화 여부 - */ - private boolean enabled = true; - - /** - * 출발 전 환불 수수료율 - */ - private Map beforeDeparture = new HashMap<>(); - - /** - * 출발 후 환불 수수료율 - */ - private Map afterDeparture = new HashMap<>(); - - /** - * 도착 후 환불 가능 여부 - */ - private boolean refundableAfterArrival = false; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/context/PaymentContext.java b/src/main/java/com/sudo/railo/payment/application/context/PaymentContext.java deleted file mode 100644 index 5eaa3947..00000000 --- a/src/main/java/com/sudo/railo/payment/application/context/PaymentContext.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.sudo.railo.payment.application.context; - -import com.sudo.railo.payment.domain.entity.MemberType; -import com.sudo.railo.payment.application.dto.request.PaymentExecuteRequest; -import com.sudo.railo.payment.application.dto.response.PaymentCalculationResponse; -import com.sudo.railo.payment.exception.PaymentContextException; -import lombok.Builder; -import lombok.Getter; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 결제 실행 컨텍스트 - * - * 결제 프로세스 전반에서 필요한 모든 정보를 담는 불변 객체입니다. - * 요청 검증, 계산 정보, 마일리지 검증 결과 등을 통합 관리합니다. - */ -@Builder -@Getter -public class PaymentContext { - - private final PaymentExecuteRequest request; - private final PaymentCalculationResponse calculation; - private final MileageValidationResult mileageResult; - private final MemberType memberType; - private final LocalDateTime createdAt; - - /** - * 회원 결제 여부 확인 - */ - public boolean isForMember() { - return memberType == MemberType.MEMBER; - } - - /** - * 마일리지 사용 여부 확인 - */ - public boolean hasMileageUsage() { - return mileageResult != null && - mileageResult.getUsageAmount().compareTo(BigDecimal.ZERO) > 0; - } - - /** - * 최종 결제 금액 조회 - */ - public BigDecimal getFinalPayableAmount() { - return calculation.getFinalPayableAmount(); - } - - /** - * Idempotency Key 조회 - */ - public String getIdempotencyKey() { - return request.getIdempotencyKey(); - } - - /** - * 예약 ID 조회 - */ - public Long getReservationId() { - String reservationIdStr = calculation.getReservationId(); - if (reservationIdStr == null) { - return null; - } - - // "R2025060100001" 형태인 경우 'R' 제거 후 숫자 추출 - if (reservationIdStr.startsWith("R")) { - return Long.parseLong(reservationIdStr.substring(1)); - } - - // 이미 숫자 형태인 경우 - return Long.parseLong(reservationIdStr); - } - - /** - * 회원 ID 조회 (회원인 경우만) - */ - public Long getMemberId() { - return isForMember() ? request.getMemberId() : null; - } - - /** - * 컨텍스트 유효성 검증 - */ - public void validate() { - if (request == null) { - throw new PaymentContextException("결제 요청이 없습니다"); - } - - if (calculation == null) { - throw new PaymentContextException("결제 계산 정보가 없습니다"); - } - - if (calculation.getExpiresAt().isBefore(LocalDateTime.now())) { - throw new PaymentContextException("결제 계산이 만료되었습니다"); - } - - if (memberType == null) { - throw new PaymentContextException("회원 타입이 지정되지 않았습니다"); - } - - // 마일리지 사용 시 검증 - if (hasMileageUsage() && !isForMember()) { - throw new PaymentContextException("비회원은 마일리지를 사용할 수 없습니다"); - } - } - - /** - * 마일리지 검증 결과 - */ - @Builder - @Getter - public static class MileageValidationResult { - private final BigDecimal availableAmount; - private final BigDecimal usageAmount; - private final BigDecimal remainingAmount; - private final boolean isValid; - private final String validationMessage; - - /** - * 성공적인 검증 결과 생성 - */ - public static MileageValidationResult success( - BigDecimal availableAmount, - BigDecimal usageAmount) { - return MileageValidationResult.builder() - .availableAmount(availableAmount) - .usageAmount(usageAmount) - .remainingAmount(availableAmount.subtract(usageAmount)) - .isValid(true) - .validationMessage("마일리지 검증 성공") - .build(); - } - - /** - * 실패한 검증 결과 생성 - */ - public static MileageValidationResult failure(String message) { - return MileageValidationResult.builder() - .availableAmount(BigDecimal.ZERO) - .usageAmount(BigDecimal.ZERO) - .remainingAmount(BigDecimal.ZERO) - .isValid(false) - .validationMessage(message) - .build(); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/MileageValidationResult.java b/src/main/java/com/sudo/railo/payment/application/dto/MileageValidationResult.java deleted file mode 100644 index 6d77eaef..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/MileageValidationResult.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.sudo.railo.payment.application.dto; - -import lombok.Builder; -import lombok.Getter; - -import java.math.BigDecimal; - -/** - * 마일리지 검증 결과 - * - * 마일리지 사용 가능 여부와 차감 금액 정보를 담은 DTO - */ -@Getter -@Builder -public class MileageValidationResult { - - /** - * 검증 성공 여부 - */ - private final boolean valid; - - /** - * 사용할 마일리지 포인트 - */ - private final BigDecimal usageAmount; - - /** - * 사용 가능한 마일리지 잔액 - */ - private final BigDecimal availableBalance; - - /** - * 마일리지로 차감될 원화 금액 - */ - private final BigDecimal deductionAmount; - - /** - * 검증 실패 사유 (실패 시에만) - */ - private final String failureReason; - - /** - * 마일리지 사용 여부 확인 - */ - public boolean hasMileageUsage() { - return usageAmount != null && usageAmount.compareTo(BigDecimal.ZERO) > 0; - } - - /** - * 성공 결과 생성 팩토리 메서드 - */ - public static MileageValidationResult success(BigDecimal usageAmount, - BigDecimal availableBalance, - BigDecimal deductionAmount) { - return MileageValidationResult.builder() - .valid(true) - .usageAmount(usageAmount) - .availableBalance(availableBalance) - .deductionAmount(deductionAmount) - .build(); - } - - /** - * 실패 결과 생성 팩토리 메서드 - */ - public static MileageValidationResult failure(String reason) { - return MileageValidationResult.builder() - .valid(false) - .usageAmount(BigDecimal.ZERO) - .availableBalance(BigDecimal.ZERO) - .deductionAmount(BigDecimal.ZERO) - .failureReason(reason) - .build(); - } - - /** - * 마일리지 미사용 결과 생성 팩토리 메서드 - */ - public static MileageValidationResult notUsed() { - return MileageValidationResult.builder() - .valid(true) - .usageAmount(BigDecimal.ZERO) - .availableBalance(BigDecimal.ZERO) - .deductionAmount(BigDecimal.ZERO) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/PaymentInfo.java b/src/main/java/com/sudo/railo/payment/application/dto/PaymentInfo.java new file mode 100644 index 00000000..6fa01caf --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/application/dto/PaymentInfo.java @@ -0,0 +1,9 @@ +package com.sudo.railo.payment.application.dto; + +import java.math.BigDecimal; + +import com.sudo.railo.payment.domain.status.PaymentStatus; +import com.sudo.railo.payment.domain.type.PaymentMethod; + +public record PaymentInfo(BigDecimal amount, PaymentMethod paymentMethod, PaymentStatus paymentStatus) { +} diff --git a/src/main/java/com/sudo/railo/payment/application/dto/PaymentResult.java b/src/main/java/com/sudo/railo/payment/application/dto/PaymentResult.java deleted file mode 100644 index e2a5569f..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/PaymentResult.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.sudo.railo.payment.application.dto; - -import com.sudo.railo.payment.domain.entity.Payment; -import lombok.Builder; -import lombok.Getter; - -import java.math.BigDecimal; - -/** - * 결제 실행 결과 - * - * 결제 실행 후 반환되는 결과를 담은 DTO - * Payment 엔티티와 관련 실행 결과 정보를 포함 - */ -@Getter -@Builder -public class PaymentResult { - - /** - * 저장된 Payment 엔티티 - */ - private final Payment payment; - - /** - * 마일리지 실행 결과 - */ - private final MileageExecutionResult mileageResult; - - /** - * PG 결제 결과 - */ - private final PgPaymentResult pgResult; - - /** - * 성공 여부 - */ - private final boolean success; - - /** - * 결과 메시지 - */ - private final String message; - - /** - * 마일리지 실행 결과 - */ - @Getter - @Builder - public static class MileageExecutionResult { - private final boolean success; - private final BigDecimal usedPoints; - private final BigDecimal remainingBalance; - private final String transactionId; - - /** - * ID 조회 (하위 호환성) - */ - public String getId() { - return transactionId; - } - } - - /** - * PG 결제 결과 - */ - @Getter - @Builder - public static class PgPaymentResult { - private final boolean success; - private final String pgTransactionId; - private final String pgApprovalNo; - private final String pgMessage; - - public static PgPaymentResult success(String pgTransactionId, String pgApprovalNo) { - return PgPaymentResult.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .pgApprovalNo(pgApprovalNo) - .build(); - } - - public static PgPaymentResult failure(String message) { - return PgPaymentResult.builder() - .success(false) - .pgMessage(message) - .build(); - } - } - - /** - * 성공 결과 생성 - */ - public static PaymentResult success(Payment payment, - MileageExecutionResult mileageResult, - PgPaymentResult pgResult) { - return PaymentResult.builder() - .payment(payment) - .mileageResult(mileageResult) - .pgResult(pgResult) - .success(true) - .message("결제가 성공적으로 처리되었습니다") - .build(); - } - - /** - * 실패 결과 생성 - */ - public static PaymentResult failure(Payment payment, String message) { - return PaymentResult.builder() - .payment(payment) - .success(false) - .message(message) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/SavedPaymentMethodRequestDto.java b/src/main/java/com/sudo/railo/payment/application/dto/SavedPaymentMethodRequestDto.java deleted file mode 100644 index 8bd96498..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/SavedPaymentMethodRequestDto.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.sudo.railo.payment.application.dto; - -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.AllArgsConstructor; -import lombok.Builder; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class SavedPaymentMethodRequestDto { - - @NotNull(message = "회원 ID는 필수입니다.") - private Long memberId; - - @NotBlank(message = "결제수단 타입은 필수입니다.") - private String paymentMethodType; // CREDIT_CARD, BANK_ACCOUNT - - @NotBlank(message = "별명은 필수입니다.") - private String alias; // "표시 명" → "별명"으로 변경 - - // 신용카드 관련 필드 - private String cardNumber; - private String cardHolderName; - private String cardExpiryMonth; - private String cardExpiryYear; - private String cardCvc; // CVC 필드 추가 - - // 계좌 관련 필드 - private String bankCode; - private String accountNumber; - private String accountHolderName; - private String accountPassword; // 계좌 비밀번호 필드 추가 - - @Builder.Default - private Boolean isDefault = false; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/SavedPaymentMethodResponseDto.java b/src/main/java/com/sudo/railo/payment/application/dto/SavedPaymentMethodResponseDto.java deleted file mode 100644 index f0cd7914..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/SavedPaymentMethodResponseDto.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.sudo.railo.payment.application.dto; - -import lombok.*; - -import java.time.LocalDateTime; - -/** - * 저장된 결제수단 응답 DTO - */ -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class SavedPaymentMethodResponseDto { - - private Long id; - private Long memberId; - private String paymentMethodType; - private String alias; - private Boolean isDefault; - private Boolean isActive; - private LocalDateTime lastUsedAt; - private LocalDateTime createdAt; - - // 마스킹된 정보 (항상 반환) - private String maskedCardNumber; - private String maskedAccountNumber; - private String bankCode; - - // 실제 정보 (특별한 권한 필요) - private String cardNumber; - private String cardHolderName; - private String cardExpiryMonth; - private String cardExpiryYear; - private String accountNumber; - private String accountHolderName; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/projection/PaymentProjection.java b/src/main/java/com/sudo/railo/payment/application/dto/projection/PaymentProjection.java new file mode 100644 index 00000000..971a6407 --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/application/dto/projection/PaymentProjection.java @@ -0,0 +1,39 @@ +package com.sudo.railo.payment.application.dto.projection; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import com.querydsl.core.annotations.QueryProjection; +import com.sudo.railo.payment.domain.status.PaymentStatus; +import com.sudo.railo.payment.domain.type.PaymentMethod; + +import lombok.Getter; + +@Getter +public class PaymentProjection { + + private final Long paymentId; + private final String paymentKey; + private final String reservationCode; + private final BigDecimal amount; + private final PaymentMethod paymentMethod; + private final PaymentStatus paymentStatus; + private final LocalDateTime paidAt; + private final LocalDateTime cancelledAt; + private final LocalDateTime refundedAt; + + @QueryProjection + public PaymentProjection(Long paymentId, String paymentKey, String reservationCode, + BigDecimal amount, PaymentMethod paymentMethod, PaymentStatus paymentStatus, + LocalDateTime paidAt, LocalDateTime cancelledAt, LocalDateTime refundedAt) { + this.paymentId = paymentId; + this.paymentKey = paymentKey; + this.reservationCode = reservationCode; + this.amount = amount; + this.paymentMethod = paymentMethod; + this.paymentStatus = paymentStatus; + this.paidAt = paidAt; + this.cancelledAt = cancelledAt; + this.refundedAt = refundedAt; + } +} diff --git a/src/main/java/com/sudo/railo/payment/application/dto/request/BankAccountVerificationRequest.java b/src/main/java/com/sudo/railo/payment/application/dto/request/BankAccountVerificationRequest.java deleted file mode 100644 index 59d6c389..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/request/BankAccountVerificationRequest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.sudo.railo.payment.application.dto.request; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 은행 계좌 검증 요청 DTO - * 계좌 유효성 검증을 위한 최소 정보만 포함 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class BankAccountVerificationRequest { - - @NotBlank(message = "은행 코드는 필수입니다.") - private String bankCode; - - @NotBlank(message = "계좌번호는 필수입니다.") - @Pattern(regexp = "^[0-9]{10,20}$", message = "계좌번호는 10-20자리 숫자여야 합니다.") - private String accountNumber; - - @NotBlank(message = "계좌 비밀번호는 필수입니다.") - @Pattern(regexp = "^[0-9]{4}$", message = "계좌 비밀번호는 4자리 숫자여야 합니다.") - private String accountPassword; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/request/CreateSavedPaymentMethodRequest.java b/src/main/java/com/sudo/railo/payment/application/dto/request/CreateSavedPaymentMethodRequest.java deleted file mode 100644 index 51c3e3a5..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/request/CreateSavedPaymentMethodRequest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.sudo.railo.payment.application.dto.request; - -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.AllArgsConstructor; -import lombok.Builder; - -import jakarta.validation.constraints.NotBlank; - -/** - * 결제수단 저장 요청 DTO (프론트엔드용) - * memberId는 JWT 토큰에서 자동으로 추출되므로 포함하지 않음 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class CreateSavedPaymentMethodRequest { - - @NotBlank(message = "결제수단 타입은 필수입니다.") - private String paymentMethodType; // CREDIT_CARD, BANK_ACCOUNT - - @NotBlank(message = "별명은 필수입니다.") - private String alias; - - // 신용카드 관련 필드 - private String cardNumber; - private String cardHolderName; - private String cardExpiryMonth; - private String cardExpiryYear; - private String cardCvc; - - // 계좌 관련 필드 - private String bankCode; - private String accountNumber; - private String accountHolderName; - private String accountPassword; - - @Builder.Default - private Boolean isDefault = false; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentCalculationRequest.java b/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentCalculationRequest.java deleted file mode 100644 index f3ce2fbc..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentCalculationRequest.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.sudo.railo.payment.application.dto.request; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import lombok.*; -import java.math.BigDecimal; -import java.util.List; -import java.time.LocalDateTime; -// import com.sudo.railo.train.domain.type.TrainOperator; // 제거됨 - -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class PaymentCalculationRequest { - - // 예약 ID (Optional - 예약 삭제 시에도 결제 가능하도록) - private Long reservationId; - - @NotBlank(message = "주문 ID는 필수입니다") - private String externalOrderId; - - @NotBlank(message = "사용자 ID는 필수입니다") - private String userId; - - @NotNull(message = "원본 금액은 필수입니다") - @DecimalMin(value = "0", message = "금액은 0 이상이어야 합니다") - private BigDecimal originalAmount; - - @Valid - private List items; - - @Valid - private List requestedPromotions; - - @DecimalMin(value = "0", message = "마일리지 사용 금액은 0 이상이어야 합니다") - @Builder.Default - private BigDecimal mileageToUse = BigDecimal.ZERO; - - @DecimalMin(value = "0", message = "보유 마일리지는 0 이상이어야 합니다") - @Builder.Default - private BigDecimal availableMileage = BigDecimal.ZERO; - - private String clientIp; - private String userAgent; - - // 열차 정보 (예약 삭제 시에도 결제 가능하도록 직접 전달) - private Long trainScheduleId; - private LocalDateTime trainDepartureTime; - private LocalDateTime trainArrivalTime; - // private TrainOperator trainOperator; // 제거됨 - private String routeInfo; // 예: "서울-부산" - private String seatNumber; // 좌석 번호 - - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class PaymentItem { - @NotBlank(message = "상품 ID는 필수입니다") - private String productId; - - @Min(value = 1, message = "수량은 1 이상이어야 합니다") - private Integer quantity; - - @DecimalMin(value = "0", message = "단가는 0 이상이어야 합니다") - private BigDecimal unitPrice; - } - - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class PromotionRequest { - @NotBlank(message = "프로모션 타입은 필수입니다") - private String type; // COUPON, MILEAGE, DISCOUNT_CODE - - private String identifier; // 쿠폰 코드 등 - private BigDecimal pointsToUse; // 마일리지 사용 포인트 - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentExecuteRequest.java b/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentExecuteRequest.java deleted file mode 100644 index f4dc0a09..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentExecuteRequest.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.sudo.railo.payment.application.dto.request; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.*; -import java.math.BigDecimal; -import java.util.Map; - -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class PaymentExecuteRequest { - - @NotBlank(message = "계산 세션 ID는 필수입니다") - private String calculationId; - - @Valid - @NotNull(message = "결제 수단 정보는 필수입니다") - private PaymentMethodInfo paymentMethod; - - @NotBlank(message = "중복 방지 키는 필수입니다") - private String idempotencyKey; - - // 회원 정보 - private Long memberId; - - // 비회원 정보 (회원 ID가 없을 경우 필수) - private String nonMemberName; - private String nonMemberPhone; - private String nonMemberPassword; - - // 마일리지 정보 (회원인 경우만 사용) - @Builder.Default - private BigDecimal mileageToUse = BigDecimal.ZERO; - - @Builder.Default - private BigDecimal availableMileage = BigDecimal.ZERO; - - // 현금영수증 정보 - @Builder.Default - private Boolean requestReceipt = false; // 현금영수증 신청 여부 - - private String receiptType; // 현금영수증 타입: "personal" 또는 "business" - private String receiptPhoneNumber; // 개인 소득공제용 휴대폰 번호 - private String businessNumber; // 사업자 증빙용 사업자등록번호 - - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class PaymentMethodInfo { - @NotBlank(message = "결제 타입은 필수입니다") - private String type; // CREDIT_CARD, BANK_TRANSFER, MOBILE - - @NotBlank(message = "PG 제공자는 필수입니다") - private String pgProvider; // TOSS_PAYMENTS, IAMPORT, etc. - - @NotBlank(message = "PG 토큰은 필수입니다") - private String pgToken; - - private Map additionalInfo; - } - - /** - * 현금영수증 정보 조회 (하위 호환성) - */ - public CashReceiptInfo getCashReceiptInfo() { - return CashReceiptInfo.builder() - .requested(this.requestReceipt != null ? this.requestReceipt : false) - .type(this.receiptType) - .phoneNumber(this.receiptPhoneNumber) - .businessNumber(this.businessNumber) - .build(); - } - - @Data - @Builder - public static class CashReceiptInfo { - private boolean requested; - private String type; - private String phoneNumber; - private String businessNumber; - - public boolean isRequested() { - return requested; - } - } - - /** - * ID 조회 (하위 호환성) - */ - public String getId() { - return calculationId; - } - - /** - * 비회원 정보 조회 - */ - public NonMemberInfo getNonMemberInfo() { - if (nonMemberName == null && nonMemberPhone == null && nonMemberPassword == null) { - return null; - } - - return NonMemberInfo.builder() - .name(nonMemberName) - .phone(nonMemberPhone) - .password(nonMemberPassword) - .build(); - } - - @Data - @Builder - public static class NonMemberInfo { - private String name; - private String phone; - private String password; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentProcessAccountRequest.java b/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentProcessAccountRequest.java new file mode 100644 index 00000000..4480659d --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentProcessAccountRequest.java @@ -0,0 +1,41 @@ +package com.sudo.railo.payment.application.dto.request; + +import com.sudo.railo.payment.domain.type.PaymentMethod; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@Getter +@Setter +@Schema(description = "결제 처리 요청 (계좌이체)") +public class PaymentProcessAccountRequest extends PaymentProcessRequest { + + @Schema(description = "은행 코드", example = "088", allowableValues = {"088", "020", "003", "004", "011"}) + @NotNull(message = "은행 코드는 필수입니다") + private String bankCode; + + @Schema(description = "계좌 번호", example = "123456789012") + @NotNull(message = "계좌 번호는 필수입니다") + private String accountNumber; + + @Schema(description = "예금주명", example = "홍길동") + @NotNull(message = "예금주명은 필수입니다") + private String accountHolderName; + + @Schema(description = "주민등록번호 (앞 6자리)", example = "000505") + @NotNull(message = "인증 번호는 필수입니다") + private String identificationNumber; + + @Schema(description = "계좌 비밀번호 (앞 2자리)", example = "75") + @NotNull(message = "계좌 비밀번호는 필수입니다") + private String accountPassword; + + @Override + public PaymentMethod getPaymentMethod() { + return PaymentMethod.TRANSFER; + } +} diff --git a/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentProcessCardRequest.java b/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentProcessCardRequest.java new file mode 100644 index 00000000..63be2cc2 --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentProcessCardRequest.java @@ -0,0 +1,41 @@ +package com.sudo.railo.payment.application.dto.request; + +import com.sudo.railo.payment.domain.type.PaymentMethod; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@Getter +@Setter +@Schema(description = "결제 처리 요청 (카드)") +public class PaymentProcessCardRequest extends PaymentProcessRequest { + + @Schema(description = "카드 번호", example = "1234-5678-9012-3456") + @NotNull(message = "카드 번호는 필수입니다") + private String cardNumber; + + @Schema(description = "유효기간 (MMYY)", example = "1225") + @NotNull(message = "유효 기간은 필수입니다") + private String validThru; + + @Schema(description = "주민등록번호 (앞 6자리)", example = "000505") + @NotNull(message = "인증 번호는 필수입니다") + String rrn; + + @Schema(description = "할부 개월수", example = "0", allowableValues = {"0", "2", "3", "6", "12"}) + @NotNull(message = "할부 개월수는 필수입니다") + private Integer installmentMonths; + + @Schema(description = "카드 비밀번호 (앞 2자리)", example = "12") + @NotNull(message = "카드 비밀번호는 필수입니다") + private int cardPassword; + + @Override + public PaymentMethod getPaymentMethod() { + return PaymentMethod.CARD; + } +} diff --git a/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentProcessRequest.java b/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentProcessRequest.java new file mode 100644 index 00000000..f5c97a36 --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/application/dto/request/PaymentProcessRequest.java @@ -0,0 +1,32 @@ +package com.sudo.railo.payment.application.dto.request; + +import java.math.BigDecimal; + +import com.sudo.railo.payment.domain.type.PaymentMethod; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@Getter +@Setter +public abstract class PaymentProcessRequest { + + @Schema(description = "예약 ID", example = "1") + @NotNull(message = "예약 ID는 필수입니다") + private Long reservationId; + + @Schema(description = "결제 금액", example = "50000") + @NotNull(message = "결제 금액은 필수입니다") + @Positive(message = "결제 금액은 0보다 커야 합니다") + private BigDecimal amount; + + /** + * 하위 클래스에서 PaymentMethod를 반환하도록 구현 + */ + public abstract PaymentMethod getPaymentMethod(); +} diff --git a/src/main/java/com/sudo/railo/payment/application/dto/request/RefundRequestDto.java b/src/main/java/com/sudo/railo/payment/application/dto/request/RefundRequestDto.java deleted file mode 100644 index 6cb69057..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/request/RefundRequestDto.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.sudo.railo.payment.application.dto.request; - -import com.sudo.railo.payment.domain.entity.RefundType; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.NotBlank; -import java.time.LocalDateTime; - -/** - * 환불 요청 DTO - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class RefundRequestDto { - - private String idempotencyKey; // 멱등성 키 (선택적, 클라이언트에서 생성) - - @NotNull(message = "결제 ID는 필수입니다") - private Long paymentId; - - @NotNull(message = "환불 유형은 필수입니다") - private RefundType refundType; - - private LocalDateTime trainDepartureTime; - - private LocalDateTime trainArrivalTime; - - private String refundReason; - - /** - * ID 조회 (하위 호환성) - */ - public Long getId() { - return paymentId; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/BankAccountVerificationResponse.java b/src/main/java/com/sudo/railo/payment/application/dto/response/BankAccountVerificationResponse.java deleted file mode 100644 index bdb7ed8e..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/response/BankAccountVerificationResponse.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.sudo.railo.payment.application.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 은행 계좌 검증 응답 DTO - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class BankAccountVerificationResponse { - - private boolean verified; - private String accountHolderName; - private String maskedAccountNumber; - private String bankName; - private String message; - - /** - * 검증 성공 응답 생성 - */ - public static BankAccountVerificationResponse success(String accountHolderName, - String maskedAccountNumber, - String bankName) { - return BankAccountVerificationResponse.builder() - .verified(true) - .accountHolderName(accountHolderName) - .maskedAccountNumber(maskedAccountNumber) - .bankName(bankName) - .message("계좌 인증이 완료되었습니다.") - .build(); - } - - /** - * 검증 실패 응답 생성 - */ - public static BankAccountVerificationResponse failure(String message) { - return BankAccountVerificationResponse.builder() - .verified(false) - .message(message) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/MileageBalanceInfo.java b/src/main/java/com/sudo/railo/payment/application/dto/response/MileageBalanceInfo.java deleted file mode 100644 index 6970bbbd..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/response/MileageBalanceInfo.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.sudo.railo.payment.application.dto.response; - -import lombok.*; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 마일리지 잔액 정보 응답 DTO - */ -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class MileageBalanceInfo { - - private Long memberId; - private BigDecimal currentBalance; // 현재 총 잔액 - private BigDecimal activeBalance; // 활성 잔액 (만료되지 않은 것만) - private BigDecimal expiringMileage; // 30일 이내 만료 예정 마일리지 - private LocalDateTime lastTransactionAt; // 마지막 거래 시간 - private MileageStatistics statistics; // 통계 정보 - private List recentTransactions; // 최근 거래 내역 요약 - private BigDecimal pendingEarning; // 적립 예정 마일리지 - private BigDecimal expiringInMonth; // 한 달 이내 만료 예정 마일리지 - - /** - * 거래 요약 정보 - */ - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class TransactionSummary { - - private Long transactionId; - private String type; // 거래 유형 - private BigDecimal amount; // 거래 금액 - private String description; // 설명 - private LocalDateTime processedAt; // 처리 시간 - private String status; // 상태 - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/MileageStatistics.java b/src/main/java/com/sudo/railo/payment/application/dto/response/MileageStatistics.java deleted file mode 100644 index 30f5c0bc..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/response/MileageStatistics.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.sudo.railo.payment.application.dto.response; - -import lombok.*; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 마일리지 통계 정보 DTO - */ -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class MileageStatistics { - - private Integer totalTransactions; // 총 거래 건수 - private Integer earnTransactionCount; // 적립 거래 건수 - private Integer useTransactionCount; // 사용 거래 건수 - - private BigDecimal totalEarned; // 총 적립 포인트 - private BigDecimal totalUsed; // 총 사용 포인트 - private BigDecimal netAmount; // 순 증감 (적립 - 사용) - - private BigDecimal averageEarningPerTransaction; // 거래당 평균 적립 - private BigDecimal averageUsagePerTransaction; // 거래당 평균 사용 - - private LocalDateTime firstTransactionAt; // 첫 거래 시간 - private LocalDateTime lastEarningAt; // 마지막 적립 시간 - private LocalDateTime lastUsageAt; // 마지막 사용 시간 - - /** - * 순 증감 계산 (적립 - 사용) - */ - public BigDecimal getNetAmount() { - if (totalEarned == null) totalEarned = BigDecimal.ZERO; - if (totalUsed == null) totalUsed = BigDecimal.ZERO; - return totalEarned.subtract(totalUsed); - } - - /** - * 적립 비율 계산 (총 적립 / 총 거래) - */ - public String getEarningRate() { - if (totalTransactions == null || totalTransactions == 0) { - return "0%"; - } - if (earnTransactionCount == null) { - return "0%"; - } - double rate = (double) earnTransactionCount / totalTransactions * 100; - return String.format("%.1f%%", rate); - } - - /** - * 사용 비율 계산 (총 사용 / 총 거래) - */ - public String getUsageRate() { - if (totalTransactions == null || totalTransactions == 0) { - return "0%"; - } - if (useTransactionCount == null) { - return "0%"; - } - double rate = (double) useTransactionCount / totalTransactions * 100; - return String.format("%.1f%%", rate); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/MileageStatisticsResponse.java b/src/main/java/com/sudo/railo/payment/application/dto/response/MileageStatisticsResponse.java deleted file mode 100644 index 1d805e24..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/response/MileageStatisticsResponse.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.sudo.railo.payment.application.dto.response; - -import lombok.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 마일리지 통계 응답 DTO - * 회원의 마일리지 사용 통계 정보를 담은 응답 클래스 - */ -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class MileageStatisticsResponse { - - private Long memberId; - private MileageStatistics statistics; - private LocalDateTime calculatedAt; - - /** - * 마일리지 통계 정보로부터 응답 생성 - */ - public static MileageStatisticsResponse from(Long memberId, MileageStatistics statistics) { - return MileageStatisticsResponse.builder() - .memberId(memberId) - .statistics(statistics) - .calculatedAt(LocalDateTime.now()) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/MileageTransactionResponse.java b/src/main/java/com/sudo/railo/payment/application/dto/response/MileageTransactionResponse.java deleted file mode 100644 index 147cbfb7..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/response/MileageTransactionResponse.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.sudo.railo.payment.application.dto.response; - -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import lombok.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Collectors; - -/** - * 마일리지 거래 내역 응답 DTO - * 회원의 마일리지 거래 내역을 담은 응답 클래스 - */ -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class MileageTransactionResponse { - - private List transactions; - private Long totalElements; - private Integer totalPages; - private Integer currentPage; - private Integer pageSize; - private Boolean hasNext; - private Boolean hasPrevious; - - /** - * 마일리지 거래 내역 아이템 - */ - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class MileageTransactionItem { - - private Long transactionId; - private String transactionType; // 거래 유형 (적립, 사용, 만료 등) - private BigDecimal pointsAmount; // 포인트 금액 - private BigDecimal balanceAfter; // 거래 후 잔액 - private String description; // 거래 설명 - private LocalDateTime processedAt; // 처리 시간 - private LocalDateTime createdAt; // 생성 시간 - private String status; // 상태 (COMPLETED, PENDING, CANCELLED) - - // 연관 정보 - private String relatedPaymentId; // 연관 결제 ID - private Long relatedTrainScheduleId; // 연관 기차 스케줄 ID - - /** - * MileageTransaction 엔티티로부터 응답 생성 - */ - public static MileageTransactionItem from(MileageTransaction transaction) { - return MileageTransactionItem.builder() - .transactionId(transaction.getId()) - .transactionType(transaction.getType().getDescription()) - .pointsAmount(transaction.getPointsAmount()) - .balanceAfter(transaction.getBalanceAfter()) - .description(transaction.getDescription()) - .processedAt(transaction.getProcessedAt()) - .createdAt(transaction.getCreatedAt()) - .status(transaction.getStatus().getDescription()) - .relatedPaymentId(transaction.getPaymentId()) - .relatedTrainScheduleId(transaction.getTrainScheduleId()) - .build(); - } - } - - /** - * MileageTransaction 리스트로부터 응답 생성 - */ - public static MileageTransactionResponse from(List transactions, - Long totalElements, - Integer totalPages, - Integer currentPage, - Integer pageSize, - Boolean hasNext, - Boolean hasPrevious) { - List items = transactions.stream() - .map(MileageTransactionItem::from) - .collect(Collectors.toList()); - - return MileageTransactionResponse.builder() - .transactions(items) - .totalElements(totalElements) - .totalPages(totalPages) - .currentPage(currentPage) - .pageSize(pageSize) - .hasNext(hasNext) - .hasPrevious(hasPrevious) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentCalculationResponse.java b/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentCalculationResponse.java deleted file mode 100644 index 1d1aaf71..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentCalculationResponse.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.sudo.railo.payment.application.dto.response; - -import lombok.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; - -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class PaymentCalculationResponse { - - private String calculationId; - private String reservationId; - private String externalOrderId; - private BigDecimal originalAmount; - private BigDecimal finalPayableAmount; - private LocalDateTime expiresAt; - private String pgOrderId; // PG사에 전달할 주문번호 - - // 마일리지 관련 정보 - private MileageInfo mileageInfo; - - private List appliedPromotions; - private List validationErrors; - - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class MileageInfo { - private BigDecimal usedMileage; // 사용된 마일리지 - private BigDecimal mileageDiscount; // 마일리지 할인 금액 (원화) - private BigDecimal availableMileage; // 보유 마일리지 - private BigDecimal maxUsableMileage; // 최대 사용 가능 마일리지 - private BigDecimal recommendedMileage; // 권장 사용 마일리지 - private BigDecimal expectedEarning; // 예상 적립 마일리지 - private BigDecimal usageRate; // 마일리지 사용률 (%) - private String usageRateDisplay; // 사용률 표시용 (예: "15.5%") - } - - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class AppliedPromotion { - private String type; - private String identifier; - private String description; - private BigDecimal discountAmount; - private BigDecimal pointsUsed; - private BigDecimal amountDeducted; - private String status; // APPLIED, FAILED - } - - /** - * 총 할인 금액 계산 - */ - public BigDecimal getTotalDiscountAmount() { - if (appliedPromotions == null || appliedPromotions.isEmpty()) { - return BigDecimal.ZERO; - } - - return appliedPromotions.stream() - .filter(p -> "APPLIED".equals(p.getStatus())) - .map(p -> p.getDiscountAmount() != null ? p.getDiscountAmount() : BigDecimal.ZERO) - .reduce(BigDecimal.ZERO, BigDecimal::add); - } - - /** - * 만료 여부 확인 - */ - public boolean isExpired() { - return expiresAt != null && expiresAt.isBefore(LocalDateTime.now()); - } - - /** - * ID 조회 (하위 호환성) - */ - public String getId() { - return calculationId; - } - - /** - * 원래 금액 조회 (별칭) - */ - public BigDecimal getAmountOriginalTotal() { - return originalAmount; - } - - /** - * 총 할인 금액 조회 (별칭) - */ - public BigDecimal getTotalDiscountAmountApplied() { - return getTotalDiscountAmount(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentCancelResponse.java b/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentCancelResponse.java new file mode 100644 index 00000000..790560e2 --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentCancelResponse.java @@ -0,0 +1,30 @@ +package com.sudo.railo.payment.application.dto.response; + +import java.time.LocalDateTime; + +import com.sudo.railo.payment.domain.Payment; +import com.sudo.railo.payment.domain.status.PaymentStatus; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "결제 취소 응답") +public record PaymentCancelResponse( + + @Schema(description = "결제 ID", example = "1") + Long paymentId, + + @Schema(description = "결제 고유 번호 (결제 발생 일자-회원 번호-결제 순번)", example = "20250723-202309210103-001") + String paymentKey, + + @Schema(description = "결제 상태", example = "CANCELLED") + PaymentStatus paymentStatus, + + @Schema(description = "결제 완료 시간") + LocalDateTime cancelledAt +) { + + public static PaymentCancelResponse from(Payment payment) { + return new PaymentCancelResponse(payment.getId(), payment.getPaymentKey(), payment.getPaymentStatus(), + payment.getCancelledAt()); + } +} diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentExecuteResponse.java b/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentExecuteResponse.java deleted file mode 100644 index f09c97ee..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentExecuteResponse.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.sudo.railo.payment.application.dto.response; - -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import lombok.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Map; - -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class PaymentExecuteResponse { - - private Long paymentId; - private Long reservationId; - private String externalOrderId; - private PaymentExecutionStatus paymentStatus; - private BigDecimal amountPaid; - - // 마일리지 관련 정보 - @Builder.Default - private BigDecimal mileagePointsUsed = BigDecimal.ZERO; - - @Builder.Default - private BigDecimal mileageAmountDeducted = BigDecimal.ZERO; - - @Builder.Default - private BigDecimal mileageToEarn = BigDecimal.ZERO; - - // PG 관련 정보 - private String pgTransactionId; - private String pgApprovalNo; - private String receiptUrl; - private LocalDateTime paidAt; - - private PaymentResult result; - - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class PaymentResult { - private boolean success; - private String message; - private String errorCode; - private Map additionalData; - } - - /** - * ID 조회 (하위 호환성) - */ - public Long getId() { - return paymentId; - } - - /** - * 사용된 마일리지 조회 (하위 호환성) - * getMileagePointsUsed()의 별칭 - */ - public BigDecimal getMileageUsed() { - return mileagePointsUsed; - } - - /** - * 적립될 마일리지 조회 (하위 호환성) - * getMileageToEarn()의 별칭 - */ - public BigDecimal getMileageEarned() { - return mileageToEarn; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentHistoryResponse.java b/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentHistoryResponse.java index 5227b7b9..7d403961 100644 --- a/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentHistoryResponse.java +++ b/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentHistoryResponse.java @@ -1,158 +1,41 @@ package com.sudo.railo.payment.application.dto.response; -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.payment.domain.entity.RefundCalculation; -import lombok.*; - import java.math.BigDecimal; import java.time.LocalDateTime; -import java.util.List; -/** - * 결제 내역 조회 응답 DTO - */ -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class PaymentHistoryResponse { - - private List payments; - private Long totalElements; - private Integer totalPages; - private Integer currentPage; - private Integer pageSize; - private Boolean hasNext; - private Boolean hasPrevious; - - /** - * 결제 내역 아이템 - */ - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class PaymentHistoryItem { - - private Long paymentId; - private Long reservationId; - private String externalOrderId; - private Long trainScheduleId; - - // 금액 정보 - private BigDecimal amountOriginalTotal; - private BigDecimal totalDiscountAmountApplied; - private BigDecimal mileagePointsUsed; - private BigDecimal mileageAmountDeducted; - private BigDecimal mileageToEarn; - private BigDecimal amountPaid; - - // 결제 정보 - private PaymentExecutionStatus paymentStatus; - private String paymentMethod; - private String pgProvider; - private String pgApprovalNo; - - // 환불 정보 추가 - private boolean hasRefund; - private String refundStatus; - - // 시간 정보 - private LocalDateTime paidAt; - private LocalDateTime createdAt; - - // 마일리지 요약 정보 - private MileageSummary mileageSummary; - - /** - * Payment 엔티티에서 PaymentHistoryItem 생성 - */ - public static PaymentHistoryItem from(Payment payment, List mileageTransactions) { - return from(payment, mileageTransactions, null); - } - - /** - * Payment 엔티티에서 PaymentHistoryItem 생성 (환불 정보 포함) - */ - public static PaymentHistoryItem from(Payment payment, List mileageTransactions, RefundCalculation refundCalculation) { - - // 마일리지 요약 정보 생성 - MileageSummary mileageSummary = MileageSummary.from(mileageTransactions); - - return PaymentHistoryItem.builder() - .paymentId(payment.getId()) - .reservationId(payment.getReservationId()) - .externalOrderId(payment.getExternalOrderId()) - .trainScheduleId(payment.getTrainScheduleId()) - .amountOriginalTotal(payment.getAmountOriginalTotal()) - .totalDiscountAmountApplied(payment.getTotalDiscountAmountApplied()) - .mileagePointsUsed(payment.getMileagePointsUsed()) - .mileageAmountDeducted(payment.getMileageAmountDeducted()) - .mileageToEarn(payment.getMileageToEarn()) - .amountPaid(payment.getAmountPaid()) - .paymentStatus(payment.getPaymentStatus()) - .paymentMethod(payment.getPaymentMethod().getDisplayName()) - .pgProvider(payment.getPgProvider()) - .pgApprovalNo(payment.getPgApprovalNo()) - .hasRefund(refundCalculation != null) - .refundStatus(refundCalculation != null ? refundCalculation.getRefundStatus().name() : null) - .paidAt(payment.getPaidAt()) - .createdAt(payment.getCreatedAt()) - .mileageSummary(mileageSummary) - .build(); - } - } - - /** - * 마일리지 요약 정보 - */ - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class MileageSummary { - - private Integer totalTransactions; // 총 거래 건수 - private BigDecimal totalEarned; // 총 적립 포인트 - private BigDecimal totalUsed; // 총 사용 포인트 - private BigDecimal netAmount; // 순 증감 (적립 - 사용) - private LocalDateTime lastTransactionAt; // 마지막 거래 시간 - - /** - * MileageTransaction 리스트에서 요약 정보 생성 - */ - public static MileageSummary from(List transactions) { - if (transactions == null || transactions.isEmpty()) { - return MileageSummary.builder() - .totalTransactions(0) - .totalEarned(BigDecimal.ZERO) - .totalUsed(BigDecimal.ZERO) - .netAmount(BigDecimal.ZERO) - .build(); - } - - BigDecimal totalEarned = transactions.stream() - .filter(t -> t.getType() == MileageTransaction.TransactionType.EARN) - .map(MileageTransaction::getPointsAmount) - .reduce(BigDecimal.ZERO, BigDecimal::add); - - BigDecimal totalUsed = transactions.stream() - .filter(t -> t.getType() == MileageTransaction.TransactionType.USE) - .map(MileageTransaction::getPointsAmount) - .map(BigDecimal::abs) // 사용은 음수로 저장되므로 절댓값 - .reduce(BigDecimal.ZERO, BigDecimal::add); - - LocalDateTime lastTransactionAt = transactions.stream() - .map(MileageTransaction::getCreatedAt) - .max(LocalDateTime::compareTo) - .orElse(null); - - return MileageSummary.builder() - .totalTransactions(transactions.size()) - .totalEarned(totalEarned) - .totalUsed(totalUsed) - .netAmount(totalEarned.subtract(totalUsed)) - .lastTransactionAt(lastTransactionAt) - .build(); - } - } -} \ No newline at end of file +import com.sudo.railo.payment.domain.status.PaymentStatus; +import com.sudo.railo.payment.domain.type.PaymentMethod; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "결제 내역 응답") +public record PaymentHistoryResponse( + + @Schema(description = "결제 ID", example = "1") + Long paymentId, + + @Schema(description = "결제 키", example = "PAY_1234567890ABCDEF") + String paymentKey, + + @Schema(description = "예약 코드", example = "202312251230A1B2") + String reservationCode, + + @Schema(description = "결제 금액", example = "50000") + BigDecimal amount, + + @Schema(description = "결제 수단", example = "CARD") + PaymentMethod paymentMethod, + + @Schema(description = "결제 상태", example = "PAID") + PaymentStatus paymentStatus, + + @Schema(description = "결제 완료 시간") + LocalDateTime paidAt, + + @Schema(description = "결제 취소 시간") + LocalDateTime cancelledAt, + + @Schema(description = "환불 완료 시간") + LocalDateTime refundedAt +) { +} diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentInfoResponse.java b/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentInfoResponse.java deleted file mode 100644 index d1a2518f..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentInfoResponse.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.sudo.railo.payment.application.dto.response; - -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import lombok.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 결제 상세 정보 응답 DTO - */ -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class PaymentInfoResponse { - - // 기본 결제 정보 - private Long paymentId; - private Long reservationId; - private String externalOrderId; - - // 금액 정보 - private BigDecimal amountOriginalTotal; - private BigDecimal totalDiscountAmountApplied; - private BigDecimal mileagePointsUsed; - private BigDecimal mileageAmountDeducted; - private BigDecimal mileageToEarn; - private BigDecimal amountPaid; - - // 결제 상태 및 방법 - private PaymentExecutionStatus paymentStatus; - private String paymentMethod; - private String pgProvider; - private String pgTransactionId; - private String pgApprovalNo; - private String receiptUrl; - - // 시간 정보 - private LocalDateTime paidAt; - private LocalDateTime createdAt; - - // 비회원 정보 (마스킹 처리) - private String nonMemberName; - private String nonMemberPhoneMasked; - - // 마일리지 거래 내역 - private List mileageTransactions; - - // 할인 적용 내역 - private List discountDetails; - - /** - * 마일리지 거래 내역 정보 - */ - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class MileageTransactionInfo { - - private Long transactionId; - private String transactionType; // 거래 유형 (적립, 사용, 만료 등) - private BigDecimal amount; // 거래 금액 - private String description; // 거래 설명 - private LocalDateTime processedAt; // 처리 시간 - private BigDecimal balanceAfter; // 거래 후 잔액 - - public static MileageTransactionInfo from(com.sudo.railo.payment.domain.entity.MileageTransaction transaction) { - return MileageTransactionInfo.builder() - .transactionId(transaction.getId()) - .transactionType(transaction.getType().getDescription()) - .amount(transaction.getPointsAmount()) - .description(transaction.getDescription()) - .processedAt(transaction.getProcessedAt()) - .balanceAfter(transaction.getBalanceAfter()) - .build(); - } - } - - /** - * 할인 적용 내역 정보 - */ - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class DiscountInfo { - - private String discountType; // 할인 유형 (쿠폰, 마일리지, 프로모션 등) - private String discountName; // 할인명 - private BigDecimal discountAmount; // 할인 금액 - private String description; // 할인 설명 - - /** - * 마일리지 할인 정보 생성 - */ - public static DiscountInfo createMileageDiscount(BigDecimal mileageUsed, BigDecimal discountAmount) { - return DiscountInfo.builder() - .discountType("MILEAGE") - .discountName("마일리지 사용") - .discountAmount(discountAmount) - .description(String.format("%s포인트 사용 (1포인트 = 1원)", mileageUsed)) - .build(); - } - - /** - * 일반 프로모션 할인 정보 생성 - */ - public static DiscountInfo createPromotionDiscount(String promotionName, BigDecimal discountAmount, String description) { - return DiscountInfo.builder() - .discountType("PROMOTION") - .discountName(promotionName) - .discountAmount(discountAmount) - .description(description) - .build(); - } - } - - /** - * 결제 요약 정보 - */ - @Data - @Builder - @NoArgsConstructor @AllArgsConstructor - public static class PaymentSummary { - - private BigDecimal originalAmount; // 원본 금액 - private BigDecimal totalDiscount; // 총 할인 금액 - private BigDecimal finalAmount; // 최종 결제 금액 - private Integer discountCount; // 적용된 할인 개수 - private BigDecimal savingsAmount; // 절약한 금액 - private String savingsRate; // 절약률 (%) - - /** - * 결제 정보로부터 요약 생성 - */ - public static PaymentSummary from(PaymentInfoResponse payment) { - BigDecimal originalAmount = payment.getAmountOriginalTotal(); - BigDecimal totalDiscount = payment.getTotalDiscountAmountApplied() - .add(payment.getMileageAmountDeducted() != null ? payment.getMileageAmountDeducted() : BigDecimal.ZERO); - BigDecimal finalAmount = payment.getAmountPaid(); - - // 절약률 계산 - String savingsRate = "0%"; - if (originalAmount.compareTo(BigDecimal.ZERO) > 0) { - BigDecimal rate = totalDiscount.divide(originalAmount, 4, BigDecimal.ROUND_HALF_UP) - .multiply(new BigDecimal("100")); - savingsRate = String.format("%.1f%%", rate); - } - - return PaymentSummary.builder() - .originalAmount(originalAmount) - .totalDiscount(totalDiscount) - .finalAmount(finalAmount) - .discountCount(payment.getDiscountDetails() != null ? payment.getDiscountDetails().size() : 0) - .savingsAmount(totalDiscount) - .savingsRate(savingsRate) - .build(); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentProcessResponse.java b/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentProcessResponse.java new file mode 100644 index 00000000..7a538351 --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/application/dto/response/PaymentProcessResponse.java @@ -0,0 +1,38 @@ +package com.sudo.railo.payment.application.dto.response; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import com.sudo.railo.payment.domain.Payment; +import com.sudo.railo.payment.domain.status.PaymentStatus; +import com.sudo.railo.payment.domain.type.PaymentMethod; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "결제 처리 응답") +public record PaymentProcessResponse( + + @Schema(description = "결제 ID", example = "1") + Long paymentId, + + @Schema(description = "결제 고유 번호 (결제 발생 일자-회원 번호-결제 순번)", example = "20250723-202309210103-001") + String paymentKey, + + @Schema(description = "결제 금액", example = "50000") + BigDecimal amount, + + @Schema(description = "결제 수단", example = "CARD") + PaymentMethod paymentMethod, + + @Schema(description = "결제 상태", example = "PAID") + PaymentStatus paymentStatus, + + @Schema(description = "결제 완료 시간") + LocalDateTime paidAt +) { + + public static PaymentProcessResponse from(Payment payment) { + return new PaymentProcessResponse(payment.getId(), payment.getPaymentKey(), payment.getAmount(), + payment.getPaymentMethod(), payment.getPaymentStatus(), payment.getPaidAt()); + } +} diff --git a/src/main/java/com/sudo/railo/payment/application/dto/response/RefundResponseDto.java b/src/main/java/com/sudo/railo/payment/application/dto/response/RefundResponseDto.java deleted file mode 100644 index 0126029b..00000000 --- a/src/main/java/com/sudo/railo/payment/application/dto/response/RefundResponseDto.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.sudo.railo.payment.application.dto.response; - -import com.sudo.railo.payment.domain.entity.RefundCalculation; -import com.sudo.railo.payment.domain.entity.RefundStatus; -import com.sudo.railo.payment.domain.entity.RefundType; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 환불 응답 DTO - * - * @deprecated isRefundableByTime 필드명이 refundableByTime으로 변경될 예정입니다. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class RefundResponseDto { - - private Long refundCalculationId; - private Long paymentId; - private Long reservationId; - private Long memberId; - - private BigDecimal originalAmount; - private BigDecimal mileageUsed; - private BigDecimal refundFeeRate; - private BigDecimal refundFee; - private BigDecimal refundAmount; - private BigDecimal mileageRefundAmount; - - private LocalDateTime trainDepartureTime; - private LocalDateTime trainArrivalTime; - private LocalDateTime refundRequestTime; - private LocalDateTime processedAt; - - private RefundType refundType; - private RefundStatus refundStatus; - private String refundReason; - - @JsonProperty("isRefundableByTime") - private Boolean isRefundableByTime; - - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - - /** - * RefundCalculation 엔티티를 DTO로 변환 - */ - public static RefundResponseDto from(RefundCalculation refundCalculation) { - return RefundResponseDto.builder() - .refundCalculationId(refundCalculation.getId()) - .paymentId(refundCalculation.getPaymentId()) - .reservationId(refundCalculation.getReservationId()) - .memberId(refundCalculation.getMemberId()) - .originalAmount(refundCalculation.getOriginalAmount()) - .mileageUsed(refundCalculation.getMileageUsed()) - .refundFeeRate(refundCalculation.getRefundFeeRate()) - .refundFee(refundCalculation.getRefundFee()) - .refundAmount(refundCalculation.getRefundAmount()) - .mileageRefundAmount(refundCalculation.getMileageRefundAmount()) - .trainDepartureTime(refundCalculation.getTrainDepartureTime()) - .trainArrivalTime(refundCalculation.getTrainArrivalTime()) - .refundRequestTime(refundCalculation.getRefundRequestTime()) - .processedAt(refundCalculation.getProcessedAt()) - .refundType(refundCalculation.getRefundType()) - .refundStatus(refundCalculation.getRefundStatus()) - .refundReason(refundCalculation.getRefundReason()) - .isRefundableByTime(refundCalculation.isRefundableByTime()) - .createdAt(refundCalculation.getCreatedAt()) - .updatedAt(refundCalculation.getUpdatedAt()) - .build(); - } - - /** - * ID 조회 (하위 호환성) - */ - public Long getId() { - return refundCalculationId; - } - - /** - * RefundCalculation 엔티티와 RefundCalculationService를 사용하여 DTO 생성 - * - * @param refundCalculation 환불 계산 엔티티 - * @param calculationService 환불 계산 서비스 - * @return RefundResponseDto - */ - public static RefundResponseDto from(RefundCalculation refundCalculation, - com.sudo.railo.payment.domain.service.RefundCalculationService calculationService) { - return RefundResponseDto.builder() - .refundCalculationId(refundCalculation.getId()) - .paymentId(refundCalculation.getPaymentId()) - .reservationId(refundCalculation.getReservationId()) - .memberId(refundCalculation.getMemberId()) - .originalAmount(refundCalculation.getOriginalAmount()) - .mileageUsed(refundCalculation.getMileageUsed()) - .refundFeeRate(refundCalculation.getRefundFeeRate()) - .refundFee(refundCalculation.getRefundFee()) - .refundAmount(refundCalculation.getRefundAmount()) - .mileageRefundAmount(refundCalculation.getMileageRefundAmount()) - .trainDepartureTime(refundCalculation.getTrainDepartureTime()) - .trainArrivalTime(refundCalculation.getTrainArrivalTime()) - .refundRequestTime(refundCalculation.getRefundRequestTime()) - .processedAt(refundCalculation.getProcessedAt()) - .refundType(refundCalculation.getRefundType()) - .refundStatus(refundCalculation.getRefundStatus()) - .refundReason(refundCalculation.getRefundReason()) - .isRefundableByTime(calculationService.isRefundableByTime(refundCalculation)) - .createdAt(refundCalculation.getCreatedAt()) - .updatedAt(refundCalculation.getUpdatedAt()) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/event/AmountMismatchAlertEvent.java b/src/main/java/com/sudo/railo/payment/application/event/AmountMismatchAlertEvent.java deleted file mode 100644 index 3a253c45..00000000 --- a/src/main/java/com/sudo/railo/payment/application/event/AmountMismatchAlertEvent.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.sudo.railo.payment.application.event; - -import lombok.Builder; -import lombok.Getter; -import lombok.ToString; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 결제 금액 불일치 알림 이벤트 - * PG사에서 확인한 금액과 계산된 금액이 다를 때 발행 - */ -@Getter -@Builder -@ToString -public class AmountMismatchAlertEvent { - - private final String calculationId; - private final BigDecimal expectedAmount; - private final BigDecimal actualAmount; - private final String pgOrderId; - private final String pgAuthNumber; - private final LocalDateTime occurredAt; - private final String severity; // HIGH, MEDIUM, LOW - - public static AmountMismatchAlertEvent create(String calculationId, - BigDecimal expectedAmount, - BigDecimal actualAmount, - String pgOrderId, - String pgAuthNumber) { - // 금액 차이에 따른 심각도 결정 - BigDecimal difference = expectedAmount.subtract(actualAmount).abs(); - BigDecimal percentageDiff = difference.divide(expectedAmount, 2, BigDecimal.ROUND_HALF_UP) - .multiply(new BigDecimal("100")); - - String severity; - if (percentageDiff.compareTo(new BigDecimal("10")) > 0) { - severity = "HIGH"; // 10% 이상 차이 - } else if (percentageDiff.compareTo(new BigDecimal("5")) > 0) { - severity = "MEDIUM"; // 5% 이상 차이 - } else { - severity = "LOW"; // 5% 미만 차이 - } - - return AmountMismatchAlertEvent.builder() - .calculationId(calculationId) - .expectedAmount(expectedAmount) - .actualAmount(actualAmount) - .pgOrderId(pgOrderId) - .pgAuthNumber(pgAuthNumber) - .occurredAt(LocalDateTime.now()) - .severity(severity) - .build(); - } - - public BigDecimal getAmountDifference() { - return expectedAmount.subtract(actualAmount).abs(); - } - - public String getAlertMessage() { - return String.format( - "[%s] 결제 금액 불일치 감지 - 계산ID: %s, 예상금액: %s원, 실제금액: %s원, 차이: %s원, PG주문번호: %s", - severity, - calculationId, - expectedAmount, - actualAmount, - getAmountDifference(), - pgOrderId - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/event/MileageEventListener.java b/src/main/java/com/sudo/railo/payment/application/event/MileageEventListener.java deleted file mode 100644 index 04112a41..00000000 --- a/src/main/java/com/sudo/railo/payment/application/event/MileageEventListener.java +++ /dev/null @@ -1,194 +0,0 @@ -package com.sudo.railo.payment.application.event; - -import com.sudo.railo.payment.application.dto.PaymentResult.MileageExecutionResult; -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.payment.domain.repository.PaymentRepository; -import com.sudo.railo.payment.domain.service.MileageExecutionService; -import com.sudo.railo.payment.exception.InsufficientMileageException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -import java.math.BigDecimal; - -/** - * 마일리지 관련 이벤트 리스너 - * 결제 완료 이벤트를 수신하여 마일리지 적립/사용을 처리 - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class MileageEventListener { - - private final MileageExecutionService mileageExecutionService; - private final PaymentRepository paymentRepository; - - /** - * 결제 상태 변경 이벤트 처리 - SUCCESS 상태일 때 마일리지 사용 처리 - * - * @deprecated 마일리지 사용은 PaymentExecutionService에서 이미 처리하므로 중복 처리 방지를 위해 비활성화 - * 마일리지 적립은 MileageScheduleEventListener에서 처리 - * - * @param event 결제 상태 변경 이벤트 - */ - @Deprecated - // @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - // @Async("taskExecutor") - public void handlePaymentStateChangedForMileageUsage(PaymentStateChangedEvent event) { - // PaymentExecutionService.execute()에서 이미 마일리지 사용을 처리하므로 - // 중복 차감을 방지하기 위해 이 메서드는 비활성화 - log.debug("마일리지 사용 처리 스킵 (PaymentExecutionService에서 이미 처리됨) - 결제ID: {}", event.getPaymentId()); - } - - /** - * 마일리지 사용 처리 - * - * @param payment 결제 정보 - */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void processMileageUsage(Payment payment) { - log.debug("마일리지 사용 처리 - 회원ID: {}, 사용포인트: {}", - payment.getMemberId(), payment.getMileagePointsUsed()); - - try { - MileageExecutionResult result = mileageExecutionService.executeUsage(payment); - - if (result != null && result.isSuccess()) { - log.debug("마일리지 사용 완료 - 거래ID: {}, 회원ID: {}, 사용포인트: {}", - result.getId(), - payment.getMemberId(), - result.getUsedPoints()); - } - - } catch (InsufficientMileageException e) { - log.error("마일리지 잔액 부족 - 회원ID: {}, 요청포인트: {}", - payment.getMemberId(), payment.getMileagePointsUsed(), e); - throw e; - } catch (Exception e) { - log.error("마일리지 사용 처리 중 오류 - 회원ID: {}", payment.getMemberId(), e); - throw e; - } - } - - /** - * 마일리지 적립 처리 - * - * @deprecated 마일리지 적립은 열차 도착 후 MileageScheduleEventListener에서 처리 - * @param payment 결제 정보 - */ - @Deprecated - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void processMileageEarning(Payment payment) { - log.debug("마일리지 적립 처리 - 회원ID: {}, 적립포인트: {}", - payment.getMemberId(), payment.getMileageToEarn()); - - try { - MileageTransaction earningTransaction = mileageExecutionService.executeEarning(payment); - - if (earningTransaction != null) { - log.debug("마일리지 적립 완료 - 거래ID: {}, 회원ID: {}, 적립포인트: {}", - earningTransaction.getId(), - payment.getMemberId(), - payment.getMileageToEarn()); - } - - } catch (Exception e) { - log.error("마일리지 적립 처리 중 오류 - 회원ID: {}", payment.getMemberId(), e); - throw e; - } - } - - /** - * 결제 상태 변경 이벤트 처리 - CANCELLED 상태일 때 마일리지 복구 - * - * CANCELLED: 결제 시도 중 실패 (결제 완료 전) - * REFUNDED: 결제 완료 후 환불 (RefundService에서 처리) - * - * @param event 결제 상태 변경 이벤트 - */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async("taskExecutor") - public void handlePaymentStateChangedForCancellation(PaymentStateChangedEvent event) { - // CANCELLED 상태가 아니면 처리하지 않음 - if (event.getNewStatus() != PaymentExecutionStatus.CANCELLED) { - return; - } - - log.debug("결제 취소 마일리지 처리 시작 - 결제ID: {}", event.getPaymentId()); - - try { - // Payment 엔티티 조회 - Payment payment = paymentRepository.findById(Long.parseLong(event.getPaymentId())) - .orElseThrow(() -> new IllegalArgumentException("결제 정보를 찾을 수 없습니다: " + event.getPaymentId())); - - // CANCELLED는 결제 완료 전 취소이므로 마일리지 사용 복구만 수행 - // (적립은 아직 되지 않았으므로 취소할 필요 없음) - if (payment.getMemberId() != null && - payment.getMileagePointsUsed() != null && - payment.getMileagePointsUsed().compareTo(BigDecimal.ZERO) > 0) { - - restoreMileageUsage(payment); - } - - log.debug("결제 취소 마일리지 처리 완료 - 결제ID: {}", event.getPaymentId()); - - } catch (Exception e) { - log.error("결제 취소 마일리지 처리 중 오류 발생 - 결제ID: {}", event.getPaymentId(), e); - } - } - - /** - * 마일리지 사용 복구 - */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void restoreMileageUsage(Payment payment) { - log.debug("마일리지 사용 복구 - 회원ID: {}, 복구포인트: {}", - payment.getMemberId(), payment.getMileagePointsUsed()); - - try { - MileageTransaction restoreTransaction = mileageExecutionService.restoreUsage( - payment.getId().toString(), - payment.getMemberId(), - payment.getMileagePointsUsed() - ); - - log.debug("마일리지 사용 복구 완료 - 거래ID: {}", restoreTransaction.getId()); - - } catch (Exception e) { - log.error("마일리지 사용 복구 중 오류 - 회원ID: {}", payment.getMemberId(), e); - throw e; - } - } - - /** - * 마일리지 적립 취소 - */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void cancelMileageEarning(Payment payment) { - log.debug("마일리지 적립 취소 - 회원ID: {}, 취소포인트: {}", - payment.getMemberId(), payment.getMileageToEarn()); - - try { - MileageTransaction cancelTransaction = mileageExecutionService.cancelEarning( - payment.getId().toString(), - payment.getMemberId(), - payment.getMileageToEarn() - ); - - log.debug("마일리지 적립 취소 완료 - 거래ID: {}", cancelTransaction.getId()); - - } catch (Exception e) { - log.error("마일리지 적립 취소 중 오류 - 회원ID: {}", payment.getMemberId(), e); - throw e; - } - } - -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/event/MileageScheduleEventListener.java b/src/main/java/com/sudo/railo/payment/application/event/MileageScheduleEventListener.java deleted file mode 100644 index 7827a631..00000000 --- a/src/main/java/com/sudo/railo/payment/application/event/MileageScheduleEventListener.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.sudo.railo.payment.application.event; - -import com.sudo.railo.payment.application.port.in.CreateMileageEarningScheduleUseCase; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.payment.domain.repository.PaymentRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -import java.time.LocalDateTime; - -/** - * 마일리지 적립 스케줄 생성 이벤트 리스너 - * 결제 완료 시 열차 도착 시점에 마일리지가 적립되도록 스케줄을 생성 - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class MileageScheduleEventListener { - - private final CreateMileageEarningScheduleUseCase createMileageEarningScheduleUseCase; - private final PaymentRepository paymentRepository; - - /** - * 결제 상태 변경 이벤트 처리 - SUCCESS 상태일 때 마일리지 적립 스케줄 생성 - * - * @param event 결제 상태 변경 이벤트 - */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handlePaymentStateChanged(PaymentStateChangedEvent event) { - // SUCCESS 상태가 아니면 처리하지 않음 - if (event.getNewStatus() != PaymentExecutionStatus.SUCCESS) { - return; - } - - log.info("마일리지 적립 스케줄 생성 시작 - 결제ID: {}, 예약ID: {}", - event.getPaymentId(), event.getReservationId()); - - try { - // Payment 엔티티 조회 - Payment payment = paymentRepository.findById(Long.parseLong(event.getPaymentId())) - .orElseThrow(() -> new IllegalArgumentException("결제 정보를 찾을 수 없습니다: " + event.getPaymentId())); - - // 회원이 아니면 마일리지 적립 스케줄 생성하지 않음 - Long memberId = payment.getMember() != null ? payment.getMember().getId() : null; - if (memberId == null) { - log.debug("비회원 결제는 마일리지 적립 스케줄을 생성하지 않습니다 - 결제ID: {}", payment.getId()); - return; - } - - // Payment에 저장된 열차 정보 사용 (예약이 삭제되어도 처리 가능) - Long trainScheduleId = payment.getTrainScheduleId(); - LocalDateTime expectedArrivalTime = payment.getTrainArrivalTime(); - - // 열차 정보가 없는 경우 처리 - if (trainScheduleId == null || expectedArrivalTime == null) { - log.error("결제에 열차 정보가 없습니다. 마일리지 적립 스케줄을 생성할 수 없습니다 - 결제ID: {}", payment.getId()); - - // 알림 또는 수동 처리를 위한 로직 추가 가능 - // 예: 관리자에게 알림 발송, 별도 처리 큐에 저장 등 - - return; - } - - // 마일리지 적립 스케줄 생성 - CreateMileageEarningScheduleUseCase.CreateScheduleCommand command = - new CreateMileageEarningScheduleUseCase.CreateScheduleCommand( - trainScheduleId, - payment.getId().toString(), - memberId, - payment.getAmountPaid(), - expectedArrivalTime - ); - - CreateMileageEarningScheduleUseCase.ScheduleCreatedResult result = - createMileageEarningScheduleUseCase.createEarningSchedule(command); - - log.info("마일리지 적립 스케줄 생성 완료 - 결제ID: {}, 열차스케줄ID: {}, 예상도착시간: {}, 스케줄ID: {}, 기본적립액: {}P", - payment.getId(), trainScheduleId, expectedArrivalTime, - result.scheduleId(), result.baseMileageAmount()); - - } catch (Exception e) { - log.error("마일리지 적립 스케줄 생성 중 오류 발생 - 결제ID: {}", event.getPaymentId(), e); - // 스케줄 생성 실패는 메인 결제 트랜잭션에 영향주지 않음 - // 필요시 재시도 로직이나 수동 처리를 위한 알림 추가 가능 - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/event/PaymentCancelledEvent.java b/src/main/java/com/sudo/railo/payment/application/event/PaymentCancelledEvent.java deleted file mode 100644 index 85ab5fc8..00000000 --- a/src/main/java/com/sudo/railo/payment/application/event/PaymentCancelledEvent.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.sudo.railo.payment.application.event; - -import com.sudo.railo.payment.domain.entity.Payment; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 결제 취소 완료 이벤트 - * Booking 도메인에서 예약 상태를 업데이트하기 위해 발행 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PaymentCancelledEvent { - - private Long paymentId; - private Long reservationId; - private String externalOrderId; - private Long memberId; - private String cancelReason; - private LocalDateTime cancelledAt; - - // 마일리지 복구 정보 - private BigDecimal mileageToRestore; // 복구할 마일리지 (사용한 것) - private BigDecimal mileageEarnedToCancel; // 취소할 적립 예정 마일리지 - - // Payment 엔티티 참조 (이벤트 처리를 위해) - private Payment payment; - - /** - * Payment 엔티티로부터 취소 이벤트 생성 - */ - public static PaymentCancelledEvent from(Payment payment, String cancelReason) { - return PaymentCancelledEvent.builder() - .paymentId(payment.getId()) - .reservationId(payment.getReservationId()) - .externalOrderId(payment.getExternalOrderId()) - .memberId(payment.getMemberId()) - .cancelReason(cancelReason) - .cancelledAt(payment.getCancelledAt()) - .mileageToRestore(payment.getMileagePointsUsed()) - .mileageEarnedToCancel(payment.getMileageToEarn()) - .payment(payment) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/event/PaymentCompletedEvent.java b/src/main/java/com/sudo/railo/payment/application/event/PaymentCompletedEvent.java deleted file mode 100644 index 51d40c56..00000000 --- a/src/main/java/com/sudo/railo/payment/application/event/PaymentCompletedEvent.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.sudo.railo.payment.application.event; - -import com.sudo.railo.payment.domain.entity.Payment; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 결제 완료 이벤트 - * 마일리지 적립/사용을 트리거하는 이벤트 - */ -@Getter -@AllArgsConstructor -public class PaymentCompletedEvent { - - private final String paymentId; - private final Long memberId; - private final BigDecimal amountPaid; - private final BigDecimal mileageToEarn; - private final LocalDateTime completedAt; - private final Payment payment; // 완전한 결제 정보 - - /** - * Payment 엔티티로부터 이벤트 생성 - */ - public static PaymentCompletedEvent from(Payment payment) { - return new PaymentCompletedEvent( - payment.getId().toString(), - payment.getMemberId(), - payment.getAmountPaid(), - payment.getMileageToEarn(), - payment.getPaidAt(), - payment - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/event/PaymentEvent.java b/src/main/java/com/sudo/railo/payment/application/event/PaymentEvent.java deleted file mode 100644 index e5309972..00000000 --- a/src/main/java/com/sudo/railo/payment/application/event/PaymentEvent.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.sudo.railo.payment.application.event; - -import lombok.*; -import java.time.LocalDateTime; -import java.util.Map; - -@Data -@Builder -@NoArgsConstructor @AllArgsConstructor -public class PaymentEvent { - - private String eventId; - private String eventType; - private String paymentId; - private String externalOrderId; - private String userId; - private LocalDateTime timestamp; - private Map eventData; - - public static PaymentEvent createCalculationEvent(String calculationId, String orderId, String userId) { - return PaymentEvent.builder() - .eventId(java.util.UUID.randomUUID().toString()) - .eventType("PAYMENT_CALCULATION_CREATED") - .paymentId(calculationId) - .externalOrderId(orderId) - .userId(userId) - .timestamp(LocalDateTime.now()) - .build(); - } - - public static PaymentEvent createExecutionEvent(String paymentId, String orderId, String userId) { - return PaymentEvent.builder() - .eventId(java.util.UUID.randomUUID().toString()) - .eventType("PAYMENT_EXECUTION_STARTED") - .paymentId(paymentId) - .externalOrderId(orderId) - .userId(userId) - .timestamp(LocalDateTime.now()) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/event/PaymentEventPublisher.java b/src/main/java/com/sudo/railo/payment/application/event/PaymentEventPublisher.java deleted file mode 100644 index d437bc0d..00000000 --- a/src/main/java/com/sudo/railo/payment/application/event/PaymentEventPublisher.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.sudo.railo.payment.application.event; - -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Map; - -@Component -@RequiredArgsConstructor -@Slf4j -public class PaymentEventPublisher { - - private final ApplicationEventPublisher eventPublisher; - - public void publishCalculationEvent(String calculationId, String orderId, String userId) { - PaymentEvent event = PaymentEvent.createCalculationEvent(calculationId, orderId, userId); - log.info("결제 계산 이벤트 발행: calculationId={}, orderId={}", calculationId, orderId); - eventPublisher.publishEvent(event); - } - - public void publishExecutionEvent(String paymentId, String orderId, String userId) { - PaymentEvent event = PaymentEvent.createExecutionEvent(paymentId, orderId, userId); - log.info("결제 실행 이벤트 발행: paymentId={}, orderId={}", paymentId, orderId); - eventPublisher.publishEvent(event); - } - - - /** - * 결제 상태 변경 이벤트 발행 (Event Sourcing) - */ - public void publishPaymentStateChanged(Payment payment, - PaymentExecutionStatus previousStatus, - PaymentExecutionStatus newStatus, - String reason, - String triggeredBy) { - PaymentStateChangedEvent event = PaymentStateChangedEvent.create( - payment.getId().toString(), - payment.getReservationId(), - previousStatus, - newStatus, - reason, - triggeredBy - ); - - log.info("🚀 [PaymentEventPublisher] 결제 상태 변경 이벤트 발행 시작: paymentId={}, reservationId={}, {} → {}, reason={}, triggeredBy={}", - payment.getId(), payment.getReservationId(), previousStatus, newStatus, reason, triggeredBy); - - eventPublisher.publishEvent(event); - - log.info("✅ [PaymentEventPublisher] 이벤트 발행 완료: paymentId={}, reservationId={}", - payment.getId(), payment.getReservationId()); - } - - /** - * 결제 상태 변경 이벤트 발행 (메타데이터 포함) - */ - public void publishPaymentStateChangedWithMetadata(Payment payment, - PaymentExecutionStatus previousStatus, - PaymentExecutionStatus newStatus, - String reason, - String triggeredBy, - Map metadata) { - PaymentStateChangedEvent event = PaymentStateChangedEvent.create( - payment.getId().toString(), - payment.getReservationId(), - previousStatus, - newStatus, - reason, - triggeredBy - ).withMetadata(metadata); - - log.info("결제 상태 변경 이벤트 발행 (메타데이터 포함): paymentId={}, {} → {}", - payment.getId(), previousStatus, newStatus); - eventPublisher.publishEvent(event); - } - - /** - * 금액 불일치 알림 이벤트 발행 - */ - public void publishAmountMismatchAlert(String calculationId, - BigDecimal expectedAmount, - BigDecimal actualAmount, - String pgOrderId) { - AmountMismatchAlertEvent event = AmountMismatchAlertEvent.create( - calculationId, - expectedAmount, - actualAmount, - pgOrderId, - null // pgAuthNumber는 옵션 - ); - - log.warn("⚠️ [PaymentEventPublisher] {}", event.getAlertMessage()); - eventPublisher.publishEvent(event); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/event/PaymentRefundedEvent.java b/src/main/java/com/sudo/railo/payment/application/event/PaymentRefundedEvent.java deleted file mode 100644 index 7c099a13..00000000 --- a/src/main/java/com/sudo/railo/payment/application/event/PaymentRefundedEvent.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.sudo.railo.payment.application.event; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 결제 환불 완료 이벤트 - * Booking 도메인에서 예약 상태를 업데이트하기 위해 발행 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PaymentRefundedEvent { - - private Long paymentId; - private Long reservationId; - private String externalOrderId; - private Long memberId; - private BigDecimal refundAmount; - private BigDecimal refundFee; - private String refundReason; - private String pgRefundTransactionId; - private LocalDateTime refundedAt; - - // 마일리지 복구 정보 - private BigDecimal mileageToRestore; // 복구할 마일리지 - private BigDecimal mileageEarnedToCancel; // 취소할 적립 예정 마일리지 -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/event/PaymentStateChangedEvent.java b/src/main/java/com/sudo/railo/payment/application/event/PaymentStateChangedEvent.java deleted file mode 100644 index 307bbb4b..00000000 --- a/src/main/java/com/sudo/railo/payment/application/event/PaymentStateChangedEvent.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.sudo.railo.payment.application.event; - -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; - -import java.time.LocalDateTime; -import java.util.Map; - -/** - * 결제 상태 변경 이벤트 - * - * Event Sourcing을 위해 결제의 모든 상태 변경을 기록합니다. - * 이를 통해 결제의 전체 라이프사이클을 추적하고 감사(audit) 추적이 가능합니다. - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@ToString -public class PaymentStateChangedEvent { - - private String eventId; - private String paymentId; - private Long reservationId; - private PaymentExecutionStatus previousStatus; - private PaymentExecutionStatus newStatus; - private LocalDateTime changedAt; - private String reason; - private String triggeredBy; // 시스템/사용자/스케줄러 등 - private Map metadata; // 추가 정보 (PG 응답, 에러 메시지 등) - - /** - * 상태 변경 이벤트 생성 팩토리 메서드 - */ - public static PaymentStateChangedEvent create( - String paymentId, - Long reservationId, - PaymentExecutionStatus previousStatus, - PaymentExecutionStatus newStatus, - String reason, - String triggeredBy) { - - return PaymentStateChangedEvent.builder() - .eventId(java.util.UUID.randomUUID().toString()) - .paymentId(paymentId) - .reservationId(reservationId) - .previousStatus(previousStatus) - .newStatus(newStatus) - .changedAt(LocalDateTime.now()) - .reason(reason) - .triggeredBy(triggeredBy) - .build(); - } - - /** - * 메타데이터 추가 - */ - public PaymentStateChangedEvent withMetadata(Map metadata) { - this.metadata = metadata != null ? new java.util.HashMap<>(metadata) : null; - return this; - } - - /** - * 이벤트가 실패 상태로의 변경인지 확인 - */ - public boolean isFailureTransition() { - return newStatus == PaymentExecutionStatus.FAILED || - newStatus == PaymentExecutionStatus.CANCELLED; - } - - /** - * 이벤트가 성공 상태로의 변경인지 확인 - */ - public boolean isSuccessTransition() { - return newStatus == PaymentExecutionStatus.SUCCESS; - } - - /** - * 환불 관련 상태 변경인지 확인 - */ - public boolean isRefundTransition() { - return newStatus == PaymentExecutionStatus.REFUNDED; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/event/PaymentStateEventListener.java b/src/main/java/com/sudo/railo/payment/application/event/PaymentStateEventListener.java deleted file mode 100644 index 0feb6513..00000000 --- a/src/main/java/com/sudo/railo/payment/application/event/PaymentStateEventListener.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.sudo.railo.payment.application.event; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sudo.railo.payment.application.service.DomainEventOutboxService; -import com.sudo.railo.payment.domain.entity.DomainEventOutbox; -import com.sudo.railo.payment.domain.repository.DomainEventOutboxRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -import java.util.HashMap; -import java.util.Map; - -/** - * 결제 상태 변경 이벤트 리스너 - * - * Event Sourcing을 위해 모든 결제 상태 변경을 DomainEventOutbox에 저장합니다. - * 트랜잭션 커밋 후 비동기로 실행되어 성능에 영향을 주지 않습니다. - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class PaymentStateEventListener { - - private final DomainEventOutboxRepository outboxRepository; - private final ObjectMapper objectMapper; - - /** - * 결제 상태 변경 이벤트 처리 - * 트랜잭션 커밋 후 실행되어 데이터 일관성 보장 - */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Async - public void handlePaymentStateChanged(PaymentStateChangedEvent event) { - try { - log.info("결제 상태 변경 이벤트 처리 시작 - paymentId: {}, {} → {}", - event.getPaymentId(), event.getPreviousStatus(), event.getNewStatus()); - - // 이벤트 데이터를 JSON으로 변환 - Map eventData = new HashMap<>(); - eventData.put("paymentId", event.getPaymentId()); - eventData.put("reservationId", event.getReservationId()); - eventData.put("previousStatus", event.getPreviousStatus() != null ? event.getPreviousStatus().name() : "null"); - eventData.put("newStatus", event.getNewStatus().name()); - eventData.put("changedAt", event.getChangedAt().toString()); - eventData.put("reason", event.getReason()); - eventData.put("triggeredBy", event.getTriggeredBy()); - - if (event.getMetadata() != null) { - eventData.put("metadata", event.getMetadata()); - } - - String eventDataJson = objectMapper.writeValueAsString(eventData); - - // DomainEventOutbox에 저장 - DomainEventOutbox outboxEvent = DomainEventOutbox.createPaymentStateChangedEvent( - event.getEventId(), - event.getPaymentId(), - eventDataJson - ); - - outboxRepository.save(outboxEvent); - - // 특별한 상태 변경에 대한 추가 처리 - if (event.isFailureTransition()) { - handlePaymentFailure(event); - } else if (event.isSuccessTransition()) { - handlePaymentSuccess(event); - } else if (event.isRefundTransition()) { - handlePaymentRefund(event); - } - - log.info("결제 상태 변경 이벤트 처리 완료 - eventId: {}", event.getEventId()); - - } catch (Exception e) { - log.error("결제 상태 변경 이벤트 처리 실패 - paymentId: {}", event.getPaymentId(), e); - // 실패한 이벤트는 재처리를 위해 별도 처리 필요 - } - } - - /** - * 결제 실패 시 추가 처리 - */ - private void handlePaymentFailure(PaymentStateChangedEvent event) { - log.warn("결제 실패 처리 - paymentId: {}, reason: {}", - event.getPaymentId(), event.getReason()); - // 알림 발송, 모니터링 등 추가 처리 - } - - /** - * 결제 성공 시 추가 처리 - */ - private void handlePaymentSuccess(PaymentStateChangedEvent event) { - log.info("결제 성공 처리 - paymentId: {}", event.getPaymentId()); - // 통계 업데이트, 리포트 생성 등 추가 처리 - } - - /** - * 결제 환불 시 추가 처리 - */ - private void handlePaymentRefund(PaymentStateChangedEvent event) { - log.info("결제 환불 처리 - paymentId: {}, reason: {}", - event.getPaymentId(), event.getReason()); - // 환불 통계, 원인 분석 등 추가 처리 - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/factory/PaymentFactory.java b/src/main/java/com/sudo/railo/payment/application/factory/PaymentFactory.java deleted file mode 100644 index 2b548f77..00000000 --- a/src/main/java/com/sudo/railo/payment/application/factory/PaymentFactory.java +++ /dev/null @@ -1,226 +0,0 @@ -package com.sudo.railo.payment.application.factory; - -import com.sudo.railo.payment.application.context.PaymentContext; -import com.sudo.railo.payment.domain.entity.CashReceipt; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.exception.PaymentValidationException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.math.RoundingMode; - -/** - * Payment 엔티티 팩토리 - * - * PaymentContext를 기반으로 Payment 엔티티를 생성합니다. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class PaymentFactory { - - private final PasswordEncoder passwordEncoder; - private final com.sudo.railo.member.infra.MemberRepository memberRepository; - - /** - * PaymentContext로부터 Payment 엔티티 생성 - * - * @param context 검증된 결제 컨텍스트 - * @return Payment 엔티티 - */ - public Payment create(PaymentContext context) { - log.debug("Payment 엔티티 생성 시작 - reservationId: {}", context.getReservationId()); - - // 마일리지 정보 추출 - BigDecimal mileagePointsUsed = extractMileagePointsUsed(context); - BigDecimal mileageAmountDeducted = extractMileageAmountDeducted(context); - - // 최종 결제 금액 계산 - BigDecimal finalPayableAmount = calculateFinalPayableAmount(context, mileageAmountDeducted); - - // 마일리지 적립 예정 금액 계산 - BigDecimal mileageToEarn = calculateMileageToEarn(context, finalPayableAmount); - - // Payment Builder 생성 - Payment.PaymentBuilder builder = Payment.builder() - .reservationId(context.getReservationId()) - .externalOrderId(generateExternalOrderId(context)) - .amountOriginalTotal(context.getCalculation().getAmountOriginalTotal()) - .totalDiscountAmountApplied(context.getCalculation().getTotalDiscountAmountApplied()) - .mileagePointsUsed(mileagePointsUsed) - .mileageAmountDeducted(mileageAmountDeducted) - .amountPaid(finalPayableAmount) - .mileageToEarn(mileageToEarn) - .paymentMethod(extractPaymentMethod(context)) - .pgProvider(context.getRequest().getPaymentMethod().getPgProvider()) - .paymentStatus(PaymentExecutionStatus.PENDING) - .idempotencyKey(context.getIdempotencyKey()); - - // 회원/비회원별 정보 설정 - if (context.isForMember()) { - setMemberInfo(builder, context); - } else { - setNonMemberInfo(builder, context); - } - - // 현금영수증 정보 설정 - setCashReceiptInfo(builder, context); - - Payment payment = builder.build(); - - log.debug("Payment 엔티티 생성 완료 - amountPaid: {}, mileageUsed: {}", - payment.getAmountPaid(), payment.getMileagePointsUsed()); - - return payment; - } - - /** - * 사용 마일리지 포인트 추출 - */ - private BigDecimal extractMileagePointsUsed(PaymentContext context) { - if (context.getMileageResult() == null || !context.getMileageResult().isValid()) { - return BigDecimal.ZERO; - } - return context.getMileageResult().getUsageAmount(); - } - - /** - * 마일리지 차감 금액 추출 - */ - private BigDecimal extractMileageAmountDeducted(PaymentContext context) { - if (context.getMileageResult() == null || !context.getMileageResult().isValid()) { - return BigDecimal.ZERO; - } - // 1포인트 = 1원 고정 - return context.getMileageResult().getUsageAmount(); - } - - /** - * 최종 결제 금액 계산 - */ - private BigDecimal calculateFinalPayableAmount(PaymentContext context, BigDecimal mileageAmountDeducted) { - BigDecimal finalAmount = context.getFinalPayableAmount().subtract(mileageAmountDeducted); - - if (finalAmount.compareTo(BigDecimal.ZERO) < 0) { - throw new PaymentValidationException("최종 결제 금액이 음수입니다"); - } - - return finalAmount; - } - - /** - * 마일리지 적립 예정 금액 계산 - */ - private BigDecimal calculateMileageToEarn(PaymentContext context, BigDecimal finalPayableAmount) { - if (!context.isForMember()) { - return BigDecimal.ZERO; - } - - // 기본 적립률 1% (정책에 따라 변경 가능) - BigDecimal earningRate = BigDecimal.valueOf(0.01); - - return finalPayableAmount - .multiply(earningRate) - .setScale(0, RoundingMode.DOWN); - } - - /** - * 외부 주문 ID 생성 - */ - private String generateExternalOrderId(PaymentContext context) { - // 계산 응답에 externalOrderId가 있으면 사용, 없으면 생성 - String externalOrderId = context.getCalculation().getExternalOrderId(); - if (externalOrderId != null && !externalOrderId.trim().isEmpty()) { - return externalOrderId; - } - - // 없으면 타임스탬프 기반 생성 - return "ORD" + System.currentTimeMillis(); - } - - /** - * 결제 수단 추출 - */ - private PaymentMethod extractPaymentMethod(PaymentContext context) { - String methodType = context.getRequest().getPaymentMethod().getType(); - try { - return PaymentMethod.valueOf(methodType); - } catch (IllegalArgumentException e) { - throw new PaymentValidationException("지원하지 않는 결제 수단입니다: " + methodType); - } - } - - /** - * 회원 정보 설정 - */ - private void setMemberInfo(Payment.PaymentBuilder builder, PaymentContext context) { - // Member 엔티티 조회 및 설정 - Long memberId = context.getMemberId(); - com.sudo.railo.member.domain.Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new PaymentValidationException("회원을 찾을 수 없습니다. ID: " + memberId)); - - builder.member(member); - log.debug("회원 정보 설정 완료 - memberId: {}, memberName: {}", - member.getId(), member.getName()); - } - - /** - * 비회원 정보 설정 - */ - private void setNonMemberInfo(Payment.PaymentBuilder builder, PaymentContext context) { - var nonMemberInfo = context.getRequest().getNonMemberInfo(); - - if (nonMemberInfo == null) { - throw new PaymentValidationException("비회원 정보가 필요합니다"); - } - - // 비밀번호 암호화 - String encodedPassword = passwordEncoder.encode(nonMemberInfo.getPassword()); - - builder.nonMemberName(nonMemberInfo.getName()) - .nonMemberPhone(normalizePhoneNumber(nonMemberInfo.getPhone())) - .nonMemberPassword(encodedPassword); - } - - /** - * 현금영수증 정보 설정 - */ - private void setCashReceiptInfo(Payment.PaymentBuilder builder, PaymentContext context) { - var cashReceiptRequest = context.getRequest().getCashReceiptInfo(); - - if (cashReceiptRequest == null || !cashReceiptRequest.isRequested()) { - builder.cashReceipt(CashReceipt.notRequested()); - return; - } - - CashReceipt cashReceipt; - if ("personal".equals(cashReceiptRequest.getType())) { - cashReceipt = CashReceipt.createPersonalReceipt( - normalizePhoneNumber(cashReceiptRequest.getPhoneNumber()) - ); - } else if ("business".equals(cashReceiptRequest.getType())) { - cashReceipt = CashReceipt.createBusinessReceipt( - cashReceiptRequest.getBusinessNumber() - ); - } else { - throw new PaymentValidationException("지원하지 않는 현금영수증 타입입니다: " + cashReceiptRequest.getType()); - } - - builder.cashReceipt(cashReceipt); - } - - /** - * 전화번호 정규화 - */ - private String normalizePhoneNumber(String phoneNumber) { - if (phoneNumber == null) { - return null; - } - return phoneNumber.replaceAll("[^0-9]", ""); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/mapper/PaymentResponseMapper.java b/src/main/java/com/sudo/railo/payment/application/mapper/PaymentResponseMapper.java deleted file mode 100644 index 4885486e..00000000 --- a/src/main/java/com/sudo/railo/payment/application/mapper/PaymentResponseMapper.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.sudo.railo.payment.application.mapper; - -import com.sudo.railo.payment.application.context.PaymentContext; -import com.sudo.railo.payment.application.dto.PaymentResult; -import com.sudo.railo.payment.application.dto.response.PaymentExecuteResponse; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; - -import java.math.BigDecimal; - -/** - * Payment 응답 매핑 유틸리티 - * - * PaymentResult와 PaymentContext를 PaymentExecuteResponse로 변환 - */ -public class PaymentResponseMapper { - - private PaymentResponseMapper() { - // 유틸리티 클래스이므로 인스턴스화 방지 - } - - /** - * PaymentResult를 PaymentExecuteResponse로 변환 - * - * @param result 결제 실행 결과 - * @param context 결제 컨텍스트 - * @return 결제 실행 응답 DTO - */ - public static PaymentExecuteResponse from(PaymentResult result, PaymentContext context) { - Payment payment = result.getPayment(); - - String message = buildSuccessMessage(payment, context); - - return PaymentExecuteResponse.builder() - .paymentId(payment.getId()) - .externalOrderId(payment.getExternalOrderId()) - .paymentStatus(payment.getPaymentStatus()) - .amountPaid(payment.getAmountPaid()) - .mileagePointsUsed(payment.getMileagePointsUsed()) - .mileageAmountDeducted(payment.getMileageAmountDeducted()) - .mileageToEarn(payment.getMileageToEarn()) - .result(PaymentExecuteResponse.PaymentResult.builder() - .success(result.isSuccess()) - .message(message) - .build()) - .build(); - } - - /** - * Payment 엔티티를 PaymentExecuteResponse로 변환 (조회용) - * - * @param payment 결제 엔티티 - * @return 결제 실행 응답 DTO - */ - public static PaymentExecuteResponse from(Payment payment) { - boolean isSuccess = payment.getPaymentStatus() == PaymentExecutionStatus.SUCCESS; - String message = isSuccess ? "결제가 완료되었습니다." : "결제 처리 중입니다."; - - return PaymentExecuteResponse.builder() - .paymentId(payment.getId()) - .externalOrderId(payment.getExternalOrderId()) - .paymentStatus(payment.getPaymentStatus()) - .amountPaid(payment.getAmountPaid()) - .mileagePointsUsed(payment.getMileagePointsUsed()) - .mileageAmountDeducted(payment.getMileageAmountDeducted()) - .mileageToEarn(payment.getMileageToEarn()) - .result(PaymentExecuteResponse.PaymentResult.builder() - .success(isSuccess) - .message(message) - .build()) - .build(); - } - - /** - * 성공 메시지 생성 - */ - private static String buildSuccessMessage(Payment payment, PaymentContext context) { - if (!context.isForMember()) { - return "비회원 결제가 완료되었습니다."; - } - - BigDecimal mileageToEarn = payment.getMileageToEarn(); - if (mileageToEarn != null && mileageToEarn.compareTo(BigDecimal.ZERO) > 0) { - return String.format("회원 결제가 완료되었습니다. 마일리지 %s포인트가 적립됩니다.", - mileageToEarn); - } - - return "회원 결제가 완료되었습니다."; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/in/CreateMileageEarningScheduleUseCase.java b/src/main/java/com/sudo/railo/payment/application/port/in/CreateMileageEarningScheduleUseCase.java deleted file mode 100644 index cab48185..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/in/CreateMileageEarningScheduleUseCase.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.sudo.railo.payment.application.port.in; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 마일리지 적립 스케줄 생성 유스케이스 - * - * 헥사고날 아키텍처의 입력 포트로, 결제 완료 시 마일리지 적립 스케줄을 생성하는 - * 비즈니스 기능을 정의합니다. - */ -public interface CreateMileageEarningScheduleUseCase { - - /** - * 마일리지 적립 스케줄 생성 명령 - */ - record CreateScheduleCommand( - Long trainScheduleId, - String paymentId, - Long memberId, - BigDecimal paymentAmount, - LocalDateTime expectedArrivalTime - ) {} - - /** - * 마일리지 적립 스케줄 생성 결과 - */ - record ScheduleCreatedResult( - Long scheduleId, - BigDecimal baseMileageAmount, - LocalDateTime scheduledEarningTime, - String status - ) {} - - /** - * 결제 완료 시 마일리지 적립 스케줄을 생성합니다. - * - * @param command 스케줄 생성 명령 - * @return 생성된 스케줄 정보 - */ - ScheduleCreatedResult createEarningSchedule(CreateScheduleCommand command); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/in/ManageMileageEarningUseCase.java b/src/main/java/com/sudo/railo/payment/application/port/in/ManageMileageEarningUseCase.java deleted file mode 100644 index c9ef5806..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/in/ManageMileageEarningUseCase.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sudo.railo.payment.application.port.in; - -/** - * 마일리지 적립 관리 유스케이스 - * - * 헥사고날 아키텍처의 입력 포트로, 마일리지 적립 시스템의 - * 관리 및 정리 작업을 정의합니다. - */ -public interface ManageMileageEarningUseCase { - - /** - * 오래된 완료 스케줄 정리 명령 - */ - record CleanupOldSchedulesCommand( - int retentionDays - ) { - public CleanupOldSchedulesCommand() { - this(90); // 기본 보관 기간 90일 - } - } - - /** - * 정리 작업 결과 - */ - record CleanupResult( - int deletedCount, - String message - ) {} - - /** - * 오래된 완료된 마일리지 적립 스케줄을 정리합니다. - * - * @param command 정리 작업 명령 - * @return 정리 결과 - */ - CleanupResult cleanupOldCompletedSchedules(CleanupOldSchedulesCommand command); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/in/ProcessMileageEarningUseCase.java b/src/main/java/com/sudo/railo/payment/application/port/in/ProcessMileageEarningUseCase.java deleted file mode 100644 index fc355351..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/in/ProcessMileageEarningUseCase.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.sudo.railo.payment.application.port.in; - -/** - * 마일리지 적립 처리 유스케이스 - * - * 헥사고날 아키텍처의 입력 포트로, 준비된 마일리지 적립 스케줄을 - * 실제로 처리하는 비즈니스 기능을 정의합니다. - */ -public interface ProcessMileageEarningUseCase { - - /** - * 배치 처리 명령 - */ - record ProcessBatchCommand( - int batchSize - ) { - public ProcessBatchCommand() { - this(100); // 기본 배치 크기 - } - } - - /** - * 개별 스케줄 처리 명령 - */ - record ProcessScheduleCommand( - Long scheduleId - ) {} - - /** - * 배치 처리 결과 - */ - record BatchProcessedResult( - int totalSchedules, - int successCount, - int failureCount - ) {} - - /** - * 준비된 마일리지 적립 스케줄들을 배치로 처리합니다. - * - * @param command 배치 처리 명령 - * @return 처리 결과 - */ - BatchProcessedResult processReadySchedules(ProcessBatchCommand command); - - /** - * 특정 마일리지 적립 스케줄을 처리합니다. - * - * @param command 스케줄 처리 명령 - */ - void processEarningSchedule(ProcessScheduleCommand command); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/in/QueryMileageEarningUseCase.java b/src/main/java/com/sudo/railo/payment/application/port/in/QueryMileageEarningUseCase.java deleted file mode 100644 index 8075e958..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/in/QueryMileageEarningUseCase.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.sudo.railo.payment.application.port.in; - -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * 마일리지 적립 조회 유스케이스 - * - * 헥사고날 아키텍처의 입력 포트로, 마일리지 적립 스케줄 및 - * 통계 정보를 조회하는 비즈니스 기능을 정의합니다. - */ -public interface QueryMileageEarningUseCase { - - /** - * 회원의 적립 예정 마일리지를 조회합니다. - * - * @param memberId 회원 ID - * @return 적립 예정 마일리지 총액 - */ - BigDecimal getPendingMileageByMemberId(Long memberId); - - /** - * 회원의 마일리지 적립 스케줄을 조회합니다. - * - * @param memberId 회원 ID - * @param status 조회할 스케줄 상태 (null인 경우 전체 조회) - * @return 마일리지 적립 스케줄 목록 - */ - List getEarningSchedulesByMemberId( - Long memberId, - MileageEarningSchedule.EarningStatus status - ); - - /** - * 특정 결제의 마일리지 적립 스케줄을 조회합니다. - * - * @param paymentId 결제 ID - * @return 마일리지 적립 스케줄 (없으면 Optional.empty()) - */ - Optional getEarningScheduleByPaymentId(String paymentId); - - /** - * 마일리지 적립 통계를 조회합니다. - * - * @param startTime 조회 시작 시간 - * @param endTime 조회 종료 시간 - * @return 통계 정보 맵 - */ - Map getEarningStatistics(LocalDateTime startTime, LocalDateTime endTime); - - /** - * 지연 보상 통계를 조회합니다. - * - * @param startTime 조회 시작 시간 - * @param endTime 조회 종료 시간 - * @return 지연 보상 통계 정보 맵 - */ - Map getDelayCompensationStatistics(LocalDateTime startTime, LocalDateTime endTime); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/in/UpdateMileageEarningScheduleUseCase.java b/src/main/java/com/sudo/railo/payment/application/port/in/UpdateMileageEarningScheduleUseCase.java deleted file mode 100644 index e013def8..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/in/UpdateMileageEarningScheduleUseCase.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.sudo.railo.payment.application.port.in; - -import java.time.LocalDateTime; - -/** - * 마일리지 적립 스케줄 업데이트 유스케이스 - * - * 헥사고날 아키텍처의 입력 포트로, 열차 도착 및 지연 시 - * 마일리지 적립 스케줄을 업데이트하는 비즈니스 기능을 정의합니다. - */ -public interface UpdateMileageEarningScheduleUseCase { - - /** - * 열차 도착 시 스케줄 준비 명령 - */ - record MarkScheduleReadyCommand( - Long trainScheduleId, - LocalDateTime actualArrivalTime - ) {} - - /** - * 열차 지연 시 보상 업데이트 명령 - */ - record UpdateDelayCompensationCommand( - Long trainScheduleId, - int delayMinutes, - LocalDateTime actualArrivalTime - ) {} - - /** - * 스케줄 업데이트 결과 - */ - record ScheduleUpdateResult( - int affectedSchedules, - String status - ) {} - - /** - * 열차 도착 시 마일리지 적립 스케줄을 READY 상태로 변경합니다. - * - * @param command 스케줄 준비 명령 - * @return 업데이트 결과 - */ - ScheduleUpdateResult markScheduleReady(MarkScheduleReadyCommand command); - - /** - * 열차 지연 시 지연 보상 마일리지를 계산하고 업데이트합니다. - * - * @param command 지연 보상 업데이트 명령 - * @return 업데이트 결과 - */ - ScheduleUpdateResult updateDelayCompensation(UpdateDelayCompensationCommand command); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/LoadMemberInfoPort.java b/src/main/java/com/sudo/railo/payment/application/port/out/LoadMemberInfoPort.java deleted file mode 100644 index 0a1a2257..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/LoadMemberInfoPort.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import java.math.BigDecimal; - -/** - * 회원 정보 조회 포트 - * - * 헥사고날 아키텍처의 출력 포트로, Member 도메인에서 - * 마일리지 적립에 필요한 회원 정보를 조회하는 기능을 정의합니다. - */ -public interface LoadMemberInfoPort { - - /** - * 회원의 현재 마일리지 잔액을 조회합니다. - * - * @param memberId 회원 ID - * @return 현재 마일리지 잔액 - */ - BigDecimal getMileageBalance(Long memberId); - - /** - * 회원 타입을 조회합니다. - * - * @param memberId 회원 ID - * @return 회원 타입 (VIP, BUSINESS, GENERAL 등) - */ - String getMemberType(Long memberId); - - /** - * 회원 존재 여부를 확인합니다. - * - * @param memberId 회원 ID - * @return 존재 여부 - */ - boolean existsById(Long memberId); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/LoadMemberPort.java b/src/main/java/com/sudo/railo/payment/application/port/out/LoadMemberPort.java deleted file mode 100644 index fbbdae8e..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/LoadMemberPort.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import com.sudo.railo.member.domain.Member; -import java.util.Optional; - -/** - * 회원 정보 조회 포트 - * - * 애플리케이션 계층에서 회원 정보를 조회하기 위한 출력 포트 - * 인프라 계층에서 구현 - */ -public interface LoadMemberPort { - - /** - * 회원 타입 조회 - * - * @param memberId 회원 ID - * @return 회원 타입 (GENERAL, VIP 등) - */ - String getMemberType(Long memberId); - - /** - * 회원 존재 여부 확인 - * - * @param memberId 회원 ID - * @return 존재 여부 - */ - boolean existsById(Long memberId); - - /** - * 회원 엔티티 조회 - * - * @param memberId 회원 ID - * @return 회원 엔티티 (Optional) - */ - Optional findById(Long memberId); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/LoadMileageEarningSchedulePort.java b/src/main/java/com/sudo/railo/payment/application/port/out/LoadMileageEarningSchedulePort.java deleted file mode 100644 index 32452a73..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/LoadMileageEarningSchedulePort.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * 마일리지 적립 스케줄 조회 포트 - * - * 헥사고날 아키텍처의 출력 포트로, 영속성 계층에서 - * 마일리지 적립 스케줄을 조회하는 기능을 정의합니다. - */ -public interface LoadMileageEarningSchedulePort { - - /** - * ID로 스케줄을 조회합니다. - */ - Optional findById(Long scheduleId); - - /** - * 처리 준비된 스케줄을 비관적 락과 함께 조회합니다. - * FOR UPDATE SKIP LOCKED를 사용하여 동시성 문제를 방지합니다. - */ - List findReadySchedulesWithLock(LocalDateTime currentTime, int limit); - - /** - * 열차 스케줄 ID로 적립 스케줄들을 조회합니다. - */ - List findByTrainScheduleId(Long trainScheduleId); - - /** - * 결제 ID로 적립 스케줄을 조회합니다. - */ - Optional findByPaymentId(String paymentId); - - /** - * 회원 ID로 적립 스케줄들을 조회합니다. - */ - List findByMemberId(Long memberId); - - /** - * 회원 ID와 상태로 적립 스케줄들을 조회합니다. - */ - List findByMemberIdAndStatus( - Long memberId, - MileageEarningSchedule.EarningStatus status - ); - - /** - * 회원의 적립 예정 마일리지 총액을 계산합니다. - */ - BigDecimal calculatePendingMileageByMemberId(Long memberId); - - /** - * 마일리지 적립 통계를 조회합니다. - */ - Object getMileageEarningStatistics(LocalDateTime startTime, LocalDateTime endTime); - - /** - * 지연 보상 통계를 조회합니다. - */ - Object getDelayCompensationStatistics(LocalDateTime startTime, LocalDateTime endTime); - - /** - * 특정 시간 이전에 완료된 스케줄들을 삭제합니다. - * - * @param cutoffTime 삭제 기준 시간 - * @return 삭제된 스케줄 수 - */ - int deleteCompletedSchedulesBeforeTime(LocalDateTime cutoffTime); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/LoadMileageTransactionPort.java b/src/main/java/com/sudo/railo/payment/application/port/out/LoadMileageTransactionPort.java deleted file mode 100644 index 2714b8ba..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/LoadMileageTransactionPort.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * 마일리지 거래 조회 포트 - * - * 애플리케이션 계층에서 마일리지 거래 정보를 조회하기 위한 출력 포트 - * 인프라 계층에서 구현 - */ -public interface LoadMileageTransactionPort { - - Optional findById(Long id); - - Optional findTopByMemberIdOrderByCreatedAtDesc(Long memberId); - - List findByMemberIdAndType(Long memberId, MileageTransaction.TransactionType type); - - Page findByMemberId(Long memberId, Pageable pageable); - - BigDecimal calculateBalanceByMemberId(Long memberId); - - List findExpiredTransactionsByMemberIdAndDate(Long memberId, LocalDateTime date); - - BigDecimal sumEarnedPointsByMemberIdAndDateRange(Long memberId, LocalDateTime startDate, LocalDateTime endDate); - - BigDecimal sumUsedPointsByMemberIdAndDateRange(Long memberId, LocalDateTime startDate, LocalDateTime endDate); - - Long countTransactionsByMemberIdAndType(Long memberId, MileageTransaction.TransactionType type); - - // 추가 메서드들 - 점진적 마이그레이션을 위해 필요 - List findByPaymentId(String paymentId); - - List findByPaymentIds(List paymentIds); - - List findMileageUsageByPaymentId(String paymentId); - - List findByTrainScheduleId(Long trainScheduleId); - - List findByEarningScheduleId(Long earningScheduleId); - - Optional findBaseEarningByScheduleId(Long earningScheduleId); - - Optional findDelayCompensationByScheduleId(Long earningScheduleId); - - List findByMemberIdAndEarningType(Long memberId, MileageTransaction.EarningType earningType); - - BigDecimal calculateTotalDelayCompensationByMemberId(Long memberId); - - List findDelayCompensationTransactions(LocalDateTime startTime, LocalDateTime endTime); - - List getEarningTypeStatistics(LocalDateTime startTime, LocalDateTime endTime); - - List getDelayCompensationStatisticsByDelayTime(LocalDateTime startTime, LocalDateTime endTime); - - Page findTrainRelatedEarningsByMemberId(Long memberId, Pageable pageable); - - List findAllEarningHistory(Long memberId); - - // 추가 메서드들 - Phase 2.1 - BigDecimal calculateTotalMileageByTrainSchedule(Long trainScheduleId); - - List findAllMileageTransactionsByPaymentId(String paymentId); - - List findPendingTransactionsBeforeTime(LocalDateTime beforeTime); - - BigDecimal calculateTotalEarnedInPeriod(Long memberId, LocalDateTime startDate, LocalDateTime endDate); - - BigDecimal calculateTotalUsedInPeriod(Long memberId, LocalDateTime startDate, LocalDateTime endDate); - - List findByMemberIdOrderByCreatedAtDesc(Long memberId); - - Page findByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable); - - List findEarningHistoryByTrainId(Long memberId, String trainId, LocalDateTime startDate, LocalDateTime endDate); - - List findEarningHistoryByPeriod(Long memberId, LocalDateTime startDate, LocalDateTime endDate); - - List findByMemberId(Long memberId); - - List findByPaymentIdOrderByCreatedAtDesc(String paymentId); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/LoadPaymentPort.java b/src/main/java/com/sudo/railo/payment/application/port/out/LoadPaymentPort.java deleted file mode 100644 index a6190060..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/LoadPaymentPort.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import com.sudo.railo.payment.domain.entity.Payment; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.time.LocalDateTime; -import java.util.Optional; - -/** - * 결제 정보 조회 포트 - * - * 애플리케이션 계층에서 결제 정보를 조회하기 위한 출력 포트 - * 인프라 계층에서 구현 - */ -public interface LoadPaymentPort { - - /** - * ID로 결제 정보 조회 - * - * @param paymentId 결제 ID - * @return 결제 정보 - */ - Optional findById(Long paymentId); - - /** - * 멱등성 키로 결제 존재 여부 확인 - * - * @param idempotencyKey 멱등성 키 - * @return 존재 여부 - */ - boolean existsByIdempotencyKey(String idempotencyKey); - - /** - * 외부 주문 ID로 결제 조회 - * - * @param externalOrderId 외부 주문 ID - * @return 결제 정보 - */ - Optional findByExternalOrderId(String externalOrderId); - - /** - * 예약 ID로 결제 조회 - * - * @param reservationId 예약 ID - * @return 결제 정보 - */ - Optional findByReservationId(Long reservationId); - - /** - * 회원 ID로 결제 내역 조회 (페이징) - * - * @param memberId 회원 ID - * @param pageable 페이징 정보 - * @return 페이징된 결제 내역 - */ - Page findByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable); - - /** - * 회원 ID와 기간으로 결제 내역 조회 (페이징) - * - * @param memberId 회원 ID - * @param startDate 시작일시 - * @param endDate 종료일시 - * @param pageable 페이징 정보 - * @return 페이징된 결제 내역 - */ - Page findByMemberIdAndCreatedAtBetweenOrderByCreatedAtDesc( - Long memberId, LocalDateTime startDate, LocalDateTime endDate, Pageable pageable); - - /** - * 비회원 정보로 결제 내역 조회 (페이징) - * - * @param name 비회원 이름 - * @param phoneNumber 비회원 전화번호 - * @param pageable 페이징 정보 - * @return 페이징된 결제 내역 - */ - Page findByNonMemberInfo(String name, String phoneNumber, Pageable pageable); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/LoadRefundCalculationPort.java b/src/main/java/com/sudo/railo/payment/application/port/out/LoadRefundCalculationPort.java deleted file mode 100644 index 825ab395..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/LoadRefundCalculationPort.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import com.sudo.railo.payment.domain.entity.RefundCalculation; - -import java.util.List; -import java.util.Optional; - -/** - * 환불 계산 조회 포트 - * - * 애플리케이션 계층에서 환불 계산 정보를 조회하기 위한 출력 포트 - * 인프라 계층에서 구현 - */ -public interface LoadRefundCalculationPort { - - Optional findByPaymentId(Long paymentId); - - List findByPaymentIds(List paymentIds); - - List findByMemberId(Long memberId); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/LoadTrainSchedulePort.java b/src/main/java/com/sudo/railo/payment/application/port/out/LoadTrainSchedulePort.java deleted file mode 100644 index b3c7848c..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/LoadTrainSchedulePort.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import java.time.LocalDateTime; - -/** - * 열차 스케줄 정보 조회 포트 - * - * 헥사고날 아키텍처의 출력 포트로, Train 도메인에서 - * 마일리지 적립에 필요한 열차 스케줄 정보를 조회하는 기능을 정의합니다. - */ -public interface LoadTrainSchedulePort { - - /** - * 열차 노선 정보를 조회합니다. - * - * @param trainScheduleId 열차 스케줄 ID - * @return 노선 정보 (예: "서울-부산") - */ - String getRouteInfo(Long trainScheduleId); - - /** - * 열차의 실제 도착 시간을 조회합니다. - * - * @param trainScheduleId 열차 스케줄 ID - * @return 실제 도착 시간 - */ - LocalDateTime getActualArrivalTime(Long trainScheduleId); - - /** - * 열차의 예정 도착 시간을 조회합니다. - * - * @param trainScheduleId 열차 스케줄 ID - * @return 예정 도착 시간 - */ - LocalDateTime getScheduledArrivalTime(Long trainScheduleId); - - /** - * 열차 지연 시간을 분 단위로 조회합니다. - * - * @param trainScheduleId 열차 스케줄 ID - * @return 지연 시간 (분) - */ - int getDelayMinutes(Long trainScheduleId); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/MileageEarningEventPort.java b/src/main/java/com/sudo/railo/payment/application/port/out/MileageEarningEventPort.java deleted file mode 100644 index ac974724..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/MileageEarningEventPort.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import java.math.BigDecimal; - -/** - * 마일리지 적립 이벤트 발행 포트 - * - * 헥사고날 아키텍처의 출력 포트로, 마일리지 적립 관련 - * 도메인 이벤트를 발행하는 기능을 정의합니다. - */ -public interface MileageEarningEventPort { - - /** - * 마일리지 적립 준비 완료 이벤트를 발행합니다. - */ - void publishMileageEarningReadyEvent( - Long scheduleId, - Long memberId, - String aggregateId - ); - - /** - * 기본 마일리지 적립 완료 이벤트를 발행합니다. - */ - void publishMileageEarnedEvent( - Long transactionId, - Long memberId, - String pointsAmount, - String earningType - ); - - /** - * 지연 보상 마일리지 적립 완료 이벤트를 발행합니다. - */ - void publishDelayCompensationEarnedEvent( - Long transactionId, - Long memberId, - String compensationAmount, - int delayMinutes - ); - - /** - * 마일리지 적립 실패 이벤트를 발행합니다. - */ - void publishMileageEarningFailedEvent( - Long scheduleId, - Long memberId, - String reason - ); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/SaveMemberInfoPort.java b/src/main/java/com/sudo/railo/payment/application/port/out/SaveMemberInfoPort.java deleted file mode 100644 index c05d5635..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/SaveMemberInfoPort.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -/** - * 회원 정보 저장 포트 - * - * 헥사고날 아키텍처의 출력 포트로, Member 도메인에서 - * 마일리지 관련 정보를 업데이트하는 기능을 정의합니다. - */ -public interface SaveMemberInfoPort { - - /** - * 회원의 마일리지를 추가합니다. - * - * @param memberId 회원 ID - * @param amount 추가할 마일리지 금액 - */ - void addMileage(Long memberId, Long amount); - - /** - * 회원의 마일리지를 차감합니다. - * - * @param memberId 회원 ID - * @param amount 차감할 마일리지 금액 - */ - void useMileage(Long memberId, Long amount); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/SaveMileageEarningSchedulePort.java b/src/main/java/com/sudo/railo/payment/application/port/out/SaveMileageEarningSchedulePort.java deleted file mode 100644 index 07ce8c9d..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/SaveMileageEarningSchedulePort.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 마일리지 적립 스케줄 저장 포트 - * - * 헥사고날 아키텍처의 출력 포트로, 영속성 계층에서 - * 마일리지 적립 스케줄을 저장하고 업데이트하는 기능을 정의합니다. - */ -public interface SaveMileageEarningSchedulePort { - - /** - * 마일리지 적립 스케줄을 저장합니다. - */ - MileageEarningSchedule save(MileageEarningSchedule schedule); - - /** - * 스케줄 상태를 원자적으로 변경합니다. - * 예상 상태일 때만 새로운 상태로 변경하여 동시성 문제를 방지합니다. - * - * @return 업데이트된 행 수 (0이면 이미 다른 프로세스가 처리) - */ - int updateStatusAtomically( - Long scheduleId, - MileageEarningSchedule.EarningStatus expectedStatus, - MileageEarningSchedule.EarningStatus newStatus - ); - - /** - * 스케줄 처리 완료 시 트랜잭션 정보와 함께 원자적으로 업데이트합니다. - */ - int updateWithTransactionAtomically( - Long scheduleId, - MileageEarningSchedule.EarningStatus expectedStatus, - MileageEarningSchedule.EarningStatus newStatus, - Long transactionId, - boolean isFullyCompleted - ); - - /** - * 여러 스케줄을 한 번에 저장합니다. - */ - List saveAll(List schedules); - - /** - * 오래된 완료 스케줄을 삭제합니다. - */ - int deleteCompletedSchedulesBeforeTime(LocalDateTime beforeTime); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/SaveMileageTransactionPort.java b/src/main/java/com/sudo/railo/payment/application/port/out/SaveMileageTransactionPort.java deleted file mode 100644 index fbf42355..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/SaveMileageTransactionPort.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import com.sudo.railo.payment.domain.entity.MileageTransaction; - -/** - * 마일리지 거래 저장 포트 - * - * 애플리케이션 계층에서 마일리지 거래 정보를 저장하기 위한 출력 포트 - * 인프라 계층에서 구현 - */ -public interface SaveMileageTransactionPort { - - MileageTransaction save(MileageTransaction transaction); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/SavePaymentPort.java b/src/main/java/com/sudo/railo/payment/application/port/out/SavePaymentPort.java deleted file mode 100644 index 0da1c7da..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/SavePaymentPort.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import com.sudo.railo.payment.domain.entity.Payment; - -/** - * 결제 정보 저장 포트 - * - * 애플리케이션 계층에서 결제 정보를 저장하기 위한 출력 포트 - * 인프라 계층에서 구현 - */ -public interface SavePaymentPort { - - /** - * 결제 정보 저장 - * - * @param payment 결제 엔티티 - * @return 저장된 결제 엔티티 - */ - Payment save(Payment payment); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/port/out/SaveRefundCalculationPort.java b/src/main/java/com/sudo/railo/payment/application/port/out/SaveRefundCalculationPort.java deleted file mode 100644 index cc691bd0..00000000 --- a/src/main/java/com/sudo/railo/payment/application/port/out/SaveRefundCalculationPort.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sudo.railo.payment.application.port.out; - -import com.sudo.railo.payment.domain.entity.RefundCalculation; - -/** - * 환불 계산 저장 포트 - * - * 애플리케이션 계층에서 환불 계산 정보를 저장하기 위한 출력 포트 - * 인프라 계층에서 구현 - */ -public interface SaveRefundCalculationPort { - - RefundCalculation save(RefundCalculation refundCalculation); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/scheduler/MileageEarningScheduler.java b/src/main/java/com/sudo/railo/payment/application/scheduler/MileageEarningScheduler.java deleted file mode 100644 index 5862104a..00000000 --- a/src/main/java/com/sudo/railo/payment/application/scheduler/MileageEarningScheduler.java +++ /dev/null @@ -1,294 +0,0 @@ -package com.sudo.railo.payment.application.scheduler; - -import com.sudo.railo.payment.application.service.DomainEventOutboxService; -import com.sudo.railo.payment.application.port.in.ProcessMileageEarningUseCase; -import com.sudo.railo.payment.application.port.in.QueryMileageEarningUseCase; -import com.sudo.railo.payment.application.port.in.ManageMileageEarningUseCase; -import com.sudo.railo.payment.application.service.TrainArrivalMonitorService; -import com.sudo.railo.payment.domain.entity.DomainEventOutbox; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * 마일리지 적립 스케줄러 - * 실시간 마일리지 적립 시스템의 핵심 배치 작업을 담당 - */ -@Component -@RequiredArgsConstructor -@Slf4j -@ConditionalOnProperty(value = "raillo.mileage.scheduler.enabled", havingValue = "true", matchIfMissing = true) -public class MileageEarningScheduler { - - private final ProcessMileageEarningUseCase processMileageEarningUseCase; - private final QueryMileageEarningUseCase queryMileageEarningUseCase; - private final ManageMileageEarningUseCase manageMileageEarningUseCase; - private final DomainEventOutboxService domainEventOutboxService; - private final TrainArrivalMonitorService trainArrivalMonitorService; - - private static final int BATCH_SIZE = 50; - private static final int EVENT_BATCH_SIZE = 20; - - /** - * 실시간 열차 도착 모니터링 - * 매 1분마다 실행하여 도착한 열차를 체크하고 마일리지 적립 스케줄을 준비 상태로 변경 - */ - @Scheduled(fixedRate = 60000) // 1분마다 실행 - public void monitorTrainArrivals() { - try { - log.debug("실시간 열차 도착 모니터링 시작"); - - int processedCount = trainArrivalMonitorService.checkAndProcessArrivedTrains(); - - if (processedCount > 0) { - log.info("열차 도착 모니터링 완료 - 처리된 열차 수: {}", processedCount); - } else { - log.debug("도착한 열차 없음"); - } - - } catch (Exception e) { - log.error("열차 도착 모니터링 중 오류 발생", e); - } - } - - /** - * 마일리지 적립 스케줄 배치 처리 - * 매 1분마다 실행하여 준비된 마일리지 적립 스케줄을 처리 - */ - @Scheduled(fixedRate = 60000) // 1분마다 실행 - public void processReadyEarningSchedules() { - try { - log.debug("마일리지 적립 스케줄 배치 처리 시작"); - - ProcessMileageEarningUseCase.ProcessBatchCommand command = - new ProcessMileageEarningUseCase.ProcessBatchCommand(BATCH_SIZE); - ProcessMileageEarningUseCase.BatchProcessedResult result = - processMileageEarningUseCase.processReadySchedules(command); - int processedCount = result.successCount(); - - if (processedCount > 0) { - log.info("마일리지 적립 스케줄 처리 완료 - 처리된 스케줄 수: {}", processedCount); - } else { - log.debug("처리할 마일리지 적립 스케줄 없음"); - } - - } catch (Exception e) { - log.error("마일리지 적립 스케줄 처리 중 오류 발생", e); - } - } - - /** - * Outbox 이벤트 처리 - * 매 30초마다 실행하여 대기 중인 도메인 이벤트를 처리 - */ - @Scheduled(fixedRate = 30000) // 30초마다 실행 - public void processOutboxEvents() { - try { - log.debug("Outbox 이벤트 처리 시작"); - - List pendingEvents = domainEventOutboxService.getPendingEvents(EVENT_BATCH_SIZE); - - if (pendingEvents.isEmpty()) { - log.debug("처리할 Outbox 이벤트 없음"); - return; - } - - int processedCount = 0; - int failedCount = 0; - - for (DomainEventOutbox event : pendingEvents) { - try { - // 이벤트 처리 시작 - boolean started = domainEventOutboxService.startProcessingEvent(event.getId()); - - if (started) { - // 실제 이벤트 처리 로직 (현재는 단순히 완료 처리) - processEvent(event); - - // 처리 완료 표시 - domainEventOutboxService.markEventCompleted(event.getId()); - processedCount++; - - log.debug("이벤트 처리 완료 - EventID: {}, Type: {}", - event.getId(), event.getEventType()); - } - - } catch (Exception e) { - // 처리 실패 표시 - domainEventOutboxService.markEventFailed(event.getId(), e.getMessage()); - failedCount++; - - log.error("이벤트 처리 실패 - EventID: {}, Type: {}, 오류: {}", - event.getId(), event.getEventType(), e.getMessage(), e); - } - } - - log.info("Outbox 이벤트 처리 완료 - 성공: {}, 실패: {}", processedCount, failedCount); - - } catch (Exception e) { - log.error("Outbox 이벤트 처리 중 오류 발생", e); - } - } - - /** - * 실패한 이벤트 재시도 처리 - * 매 5분마다 실행하여 재시도 가능한 실패 이벤트를 다시 처리 - */ - @Scheduled(fixedRate = 300000) // 5분마다 실행 - public void retryFailedEvents() { - try { - log.debug("실패한 이벤트 재시도 처리 시작"); - - List retryableEvents = domainEventOutboxService.getRetryableFailedEvents(); - - if (retryableEvents.isEmpty()) { - log.debug("재시도할 실패 이벤트 없음"); - return; - } - - int retryCount = 0; - - for (DomainEventOutbox event : retryableEvents) { - try { - // 이벤트를 PENDING 상태로 되돌려서 다시 처리되도록 함 - boolean started = domainEventOutboxService.startProcessingEvent(event.getId()); - - if (started) { - processEvent(event); - domainEventOutboxService.markEventCompleted(event.getId()); - retryCount++; - - log.info("실패 이벤트 재시도 성공 - EventID: {}, Type: {}", - event.getId(), event.getEventType()); - } - - } catch (Exception e) { - domainEventOutboxService.markEventFailed(event.getId(), e.getMessage()); - - log.warn("실패 이벤트 재시도 실패 - EventID: {}, Type: {}, 재시도횟수: {}", - event.getId(), event.getEventType(), event.getRetryCount() + 1, e); - } - } - - if (retryCount > 0) { - log.info("실패한 이벤트 재시도 완료 - 성공한 재시도: {}/{}", retryCount, retryableEvents.size()); - } - - } catch (Exception e) { - log.error("실패한 이벤트 재시도 처리 중 오류 발생", e); - } - } - - /** - * 타임아웃된 처리 중 이벤트 복구 - * 매 10분마다 실행하여 처리 중 상태로 오래 남아있는 이벤트를 복구 - */ - @Scheduled(fixedRate = 600000) // 10분마다 실행 - public void recoverTimeoutEvents() { - try { - log.debug("타임아웃된 처리 중 이벤트 복구 시작"); - - int recoveredCount = domainEventOutboxService.recoverTimeoutProcessingEvents(); - - if (recoveredCount > 0) { - log.warn("타임아웃된 이벤트 복구 완료 - 복구된 이벤트 수: {}", recoveredCount); - } else { - log.debug("타임아웃된 이벤트 없음"); - } - - } catch (Exception e) { - log.error("타임아웃된 이벤트 복구 중 오류 발생", e); - } - } - - /** - * 일일 데이터 정리 작업 - * 매일 새벽 2시에 실행하여 오래된 완료 이벤트와 스케줄을 정리 - */ - @Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시 - public void dailyDataCleanup() { - try { - log.info("일일 데이터 정리 작업 시작"); - - // 30일 이상 된 완료 이벤트 삭제 - int deletedEvents = domainEventOutboxService.cleanupOldCompletedEvents(30); - - // 90일 이상 된 완료 스케줄 삭제 - ManageMileageEarningUseCase.CleanupOldSchedulesCommand cleanupCommand = - new ManageMileageEarningUseCase.CleanupOldSchedulesCommand(90); - ManageMileageEarningUseCase.CleanupResult cleanupResult = - manageMileageEarningUseCase.cleanupOldCompletedSchedules(cleanupCommand); - int deletedSchedules = cleanupResult.deletedCount(); - - log.info("일일 데이터 정리 작업 완료 - 삭제된 이벤트: {}, 삭제된 스케줄: {}", - deletedEvents, deletedSchedules); - - } catch (Exception e) { - log.error("일일 데이터 정리 작업 중 오류 발생", e); - } - } - - /** - * 시간별 통계 생성 - * 매 시간 정각에 실행하여 마일리지 적립 통계를 생성 - */ - @Scheduled(cron = "0 0 * * * *") // 매 시간 정각 - public void generateHourlyStatistics() { - try { - log.debug("시간별 통계 생성 시작"); - - LocalDateTime endTime = LocalDateTime.now(); - LocalDateTime startTime = endTime.minusHours(1); - - // 이벤트 처리 통계 - var eventStats = domainEventOutboxService.getEventStatistics(startTime); - - // 마일리지 적립 통계 - var earningStats = queryMileageEarningUseCase.getEarningStatistics(startTime, endTime); - - // 지연 보상 통계 - var delayStats = queryMileageEarningUseCase.getDelayCompensationStatistics(startTime, endTime); - - log.info("시간별 통계 생성 완료 - 기간: {} ~ {}, 이벤트: {}, 적립: {}, 지연보상: {}", - startTime, endTime, eventStats, earningStats, delayStats); - - } catch (Exception e) { - log.error("시간별 통계 생성 중 오류 발생", e); - } - } - - /** - * 개별 이벤트 처리 로직 - */ - private void processEvent(DomainEventOutbox event) { - log.debug("이벤트 처리 중 - EventID: {}, Type: {}, Data: {}", - event.getId(), event.getEventType(), event.getEventData()); - - // 실제 이벤트 처리 로직은 이벤트 타입에 따라 분기 - // 현재는 단순히 로깅만 수행 - switch (event.getEventType()) { - case TRAIN_ARRIVED: - log.debug("열차 도착 이벤트 처리 완료"); - break; - case TRAIN_DELAYED: - log.debug("열차 지연 이벤트 처리 완료"); - break; - case MILEAGE_EARNING_READY: - log.debug("마일리지 적립 준비 이벤트 처리 완료"); - break; - case MILEAGE_EARNED: - log.debug("마일리지 적립 완료 이벤트 처리 완료"); - break; - case DELAY_COMPENSATION_EARNED: - log.debug("지연 보상 지급 완료 이벤트 처리 완료"); - break; - default: - log.warn("알 수 없는 이벤트 타입 - Type: {}", event.getEventType()); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/BankAccountVerificationService.java b/src/main/java/com/sudo/railo/payment/application/service/BankAccountVerificationService.java deleted file mode 100644 index 3713d30d..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/BankAccountVerificationService.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.dto.request.BankAccountVerificationRequest; -import com.sudo.railo.payment.application.dto.response.BankAccountVerificationResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.HashMap; -import java.util.Map; - -/** - * 은행 계좌 검증 서비스 - * Mock 환경에서는 항상 성공 반환 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class BankAccountVerificationService { - - // 은행 코드 매핑 - private static final Map BANK_NAMES = new HashMap<>() {{ - put("004", "국민은행"); - put("088", "신한은행"); - put("020", "우리은행"); - put("081", "하나은행"); - put("011", "농협은행"); - put("032", "부산은행"); - put("031", "대구은행"); - put("034", "광주은행"); - put("037", "전북은행"); - put("039", "경남은행"); - put("035", "제주은행"); - put("090", "카카오뱅크"); - put("089", "케이뱅크"); - put("092", "토스뱅크"); - put("003", "IBK기업은행"); - put("국민은행", "국민은행"); - put("신한은행", "신한은행"); - put("우리은행", "우리은행"); - put("하나은행", "하나은행"); - put("농협은행", "농협은행"); - put("부산은행", "부산은행"); - put("대구은행", "대구은행"); - put("광주은행", "광주은행"); - put("전북은행", "전북은행"); - put("경남은행", "경남은행"); - put("제주은행", "제주은행"); - put("카카오뱅크", "카카오뱅크"); - put("케이뱅크", "케이뱅크"); - put("토스뱅크", "토스뱅크"); - put("IBK기업은행", "IBK기업은행"); - }}; - - /** - * 계좌 유효성 검증 - * Mock 환경에서는 항상 성공 - */ - public BankAccountVerificationResponse verifyAccount(BankAccountVerificationRequest request) { - log.info("계좌 검증 시작 - 은행: {}, 계좌번호: {}", - request.getBankCode(), - maskAccountNumber(request.getAccountNumber())); - - // Mock 환경에서는 항상 성공 - String bankName = BANK_NAMES.getOrDefault(request.getBankCode(), request.getBankCode()); - String maskedAccountNumber = maskAccountNumber(request.getAccountNumber()); - - log.info("계좌 검증 성공 (Mock) - 은행: {}, 계좌번호: {}", bankName, maskedAccountNumber); - - // Mock 환경에서는 고정된 예금주명 사용 - String accountHolderName = "홍길동"; - - return BankAccountVerificationResponse.success( - accountHolderName, - maskedAccountNumber, - bankName - ); - } - - /** - * 계좌번호 마스킹 - */ - private String maskAccountNumber(String accountNumber) { - if (accountNumber == null || accountNumber.length() < 8) { - return "****"; - } - int length = accountNumber.length(); - return accountNumber.substring(0, 4) + "****" + accountNumber.substring(length - 4); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/DomainEventOutboxService.java b/src/main/java/com/sudo/railo/payment/application/service/DomainEventOutboxService.java deleted file mode 100644 index 3e638736..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/DomainEventOutboxService.java +++ /dev/null @@ -1,308 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.domain.entity.DomainEventOutbox; -import com.sudo.railo.payment.domain.repository.DomainEventOutboxRepository; -import com.sudo.railo.payment.exception.PaymentException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronizationAdapter; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -/** - * 도메인 이벤트 Outbox 서비스 - * Outbox Pattern을 통한 안정적인 이벤트 발행 및 처리를 담당 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class DomainEventOutboxService { - - private final DomainEventOutboxRepository domainEventOutboxRepository; - private static final int MAX_RETRY_COUNT = 5; - private static final int PROCESSING_TIMEOUT_MINUTES = 10; - - /** - * 열차 도착 이벤트 발행 - */ - @Transactional - public void publishTrainArrivedEvent(Long trainScheduleId, LocalDateTime actualArrivalTime) { - log.info("열차 도착 이벤트 발행 - 열차스케줄ID: {}, 도착시간: {}", trainScheduleId, actualArrivalTime); - - DomainEventOutbox event = DomainEventOutbox.builder() - .id(generateEventId()) - .eventType(DomainEventOutbox.EventType.TRAIN_ARRIVED) - .aggregateType(DomainEventOutbox.AggregateType.TRAIN_SCHEDULE) - .aggregateId(trainScheduleId.toString()) - .eventData(String.format("{\"trainScheduleId\":%d,\"actualArrivalTime\":\"%s\"}", - trainScheduleId, actualArrivalTime)) - .status(DomainEventOutbox.EventStatus.PENDING) - .retryCount(0) - .build(); - - domainEventOutboxRepository.save(event); - - // 트랜잭션 커밋 후 비동기 처리 등록 - registerPostCommitCallback(() -> log.debug("열차 도착 이벤트 발행 완료 - EventID: {}", event.getId())); - - log.debug("열차 도착 이벤트 저장 완료 - EventID: {}", event.getId()); - } - - /** - * 열차 지연 이벤트 발행 - */ - @Transactional - public void publishTrainDelayedEvent(Long trainScheduleId, int delayMinutes, LocalDateTime actualArrivalTime) { - log.info("열차 지연 이벤트 발행 - 열차스케줄ID: {}, 지연시간: {}분, 도착시간: {}", - trainScheduleId, delayMinutes, actualArrivalTime); - - DomainEventOutbox event = DomainEventOutbox.builder() - .id(generateEventId()) - .eventType(DomainEventOutbox.EventType.TRAIN_DELAYED) - .aggregateType(DomainEventOutbox.AggregateType.TRAIN_SCHEDULE) - .aggregateId(trainScheduleId.toString()) - .eventData(String.format("{\"trainScheduleId\":%d,\"delayMinutes\":%d,\"actualArrivalTime\":\"%s\"}", - trainScheduleId, delayMinutes, actualArrivalTime)) - .status(DomainEventOutbox.EventStatus.PENDING) - .retryCount(0) - .build(); - - domainEventOutboxRepository.save(event); - log.debug("열차 지연 이벤트 저장 완료 - EventID: {}", event.getId()); - } - - /** - * 마일리지 적립 준비 이벤트 발행 - */ - @Transactional - public void publishMileageEarningReadyEvent(Long earningScheduleId, Long memberId, String paymentId) { - log.info("마일리지 적립 준비 이벤트 발행 - 적립스케줄ID: {}, 회원ID: {}, 결제ID: {}", - earningScheduleId, memberId, paymentId); - - DomainEventOutbox event = DomainEventOutbox.builder() - .id(generateEventId()) - .eventType(DomainEventOutbox.EventType.MILEAGE_EARNING_READY) - .aggregateType(DomainEventOutbox.AggregateType.PAYMENT) - .aggregateId(paymentId) - .eventData(String.format("{\"earningScheduleId\":%d,\"memberId\":%d,\"paymentId\":\"%s\"}", - earningScheduleId, memberId, paymentId)) - .status(DomainEventOutbox.EventStatus.PENDING) - .retryCount(0) - .build(); - - domainEventOutboxRepository.save(event); - log.debug("마일리지 적립 준비 이벤트 저장 완료 - EventID: {}", event.getId()); - } - - /** - * 마일리지 적립 완료 이벤트 발행 - */ - @Transactional - public void publishMileageEarnedEvent(Long transactionId, Long memberId, String amount, String earningType) { - log.info("마일리지 적립 완료 이벤트 발행 - 거래ID: {}, 회원ID: {}, 금액: {}, 타입: {}", - transactionId, memberId, amount, earningType); - - DomainEventOutbox event = DomainEventOutbox.builder() - .id(generateEventId()) - .eventType(DomainEventOutbox.EventType.MILEAGE_EARNED) - .aggregateType(DomainEventOutbox.AggregateType.MILEAGE_TRANSACTION) - .aggregateId(transactionId.toString()) - .eventData(String.format("{\"transactionId\":%d,\"memberId\":%d,\"amount\":\"%s\",\"earningType\":\"%s\"}", - transactionId, memberId, amount, earningType)) - .status(DomainEventOutbox.EventStatus.PENDING) - .retryCount(0) - .build(); - - domainEventOutboxRepository.save(event); - log.debug("마일리지 적립 완료 이벤트 저장 완료 - EventID: {}", event.getId()); - } - - /** - * 지연 보상 지급 완료 이벤트 발행 - */ - @Transactional - public void publishDelayCompensationEarnedEvent(Long transactionId, Long memberId, String compensationAmount, int delayMinutes) { - log.info("지연 보상 지급 완료 이벤트 발행 - 거래ID: {}, 회원ID: {}, 보상금액: {}, 지연시간: {}분", - transactionId, memberId, compensationAmount, delayMinutes); - - DomainEventOutbox event = DomainEventOutbox.builder() - .id(generateEventId()) - .eventType(DomainEventOutbox.EventType.DELAY_COMPENSATION_EARNED) - .aggregateType(DomainEventOutbox.AggregateType.MILEAGE_TRANSACTION) - .aggregateId(transactionId.toString()) - .eventData(String.format("{\"transactionId\":%d,\"memberId\":%d,\"compensationAmount\":\"%s\",\"delayMinutes\":%d}", - transactionId, memberId, compensationAmount, delayMinutes)) - .status(DomainEventOutbox.EventStatus.PENDING) - .retryCount(0) - .build(); - - domainEventOutboxRepository.save(event); - log.debug("지연 보상 지급 완료 이벤트 저장 완료 - EventID: {}", event.getId()); - } - - /** - * 처리 대기 중인 이벤트 조회 (배치 처리용) - */ - @Transactional(readOnly = true) - public List getPendingEvents(int limit) { - log.debug("처리 대기 이벤트 조회 - 제한: {}개", limit); - - return domainEventOutboxRepository.findPendingEventsWithLimit(limit); - } - - /** - * 이벤트 처리 시작 (상태를 PROCESSING으로 변경) - */ - @Transactional - public boolean startProcessingEvent(String eventId) { - log.debug("이벤트 처리 시작 - EventID: {}", eventId); - - return domainEventOutboxRepository.findById(eventId) - .map(event -> { - if (event.getStatus() == DomainEventOutbox.EventStatus.PENDING) { - event.startProcessing(); - domainEventOutboxRepository.save(event); - log.debug("이벤트 처리 상태 변경 완료 - EventID: {}", eventId); - return true; - } else { - log.warn("이미 처리 중이거나 완료된 이벤트 - EventID: {}, 현재상태: {}", eventId, event.getStatus()); - return false; - } - }) - .orElseThrow(() -> new PaymentException("이벤트를 찾을 수 없습니다 - EventID: " + eventId)); - } - - /** - * 이벤트 처리 완료 - */ - @Transactional - public void markEventCompleted(String eventId) { - log.debug("이벤트 처리 완료 - EventID: {}", eventId); - - domainEventOutboxRepository.findById(eventId) - .ifPresent(event -> { - event.complete(); - domainEventOutboxRepository.save(event); - log.info("이벤트 처리 완료 - EventID: {}, 타입: {}", eventId, event.getEventType()); - }); - } - - /** - * 이벤트 처리 실패 - */ - @Transactional - public void markEventFailed(String eventId, String errorMessage) { - log.error("이벤트 처리 실패 - EventID: {}, 오류: {}", eventId, errorMessage); - - domainEventOutboxRepository.findById(eventId) - .ifPresent(event -> { - event.fail(errorMessage); - domainEventOutboxRepository.save(event); - - // 최대 재시도 횟수 초과 시 알림 - if (event.getRetryCount() >= MAX_RETRY_COUNT) { - log.error("최대 재시도 횟수 초과 - EventID: {}, 재시도횟수: {}", eventId, event.getRetryCount()); - // TODO: 알림 시스템 연동 (Slack, Email 등) - } - }); - } - - /** - * 재시도 가능한 실패 이벤트 조회 - */ - @Transactional(readOnly = true) - public List getRetryableFailedEvents() { - log.debug("재시도 가능한 실패 이벤트 조회 - 최대재시도: {}", MAX_RETRY_COUNT); - - return domainEventOutboxRepository.findRetryableFailedEvents(MAX_RETRY_COUNT); - } - - /** - * 타임아웃된 처리 중 이벤트 복구 - */ - @Transactional - public int recoverTimeoutProcessingEvents() { - LocalDateTime timeoutTime = LocalDateTime.now().minusMinutes(PROCESSING_TIMEOUT_MINUTES); - - log.info("타임아웃된 처리 중 이벤트 복구 시작 - 타임아웃 기준: {} 이전", timeoutTime); - - int recoveredCount = domainEventOutboxRepository.resetTimeoutProcessingEventsToPending(timeoutTime); - - if (recoveredCount > 0) { - log.warn("타임아웃된 이벤트 복구 완료 - 복구된 이벤트 수: {}", recoveredCount); - } else { - log.debug("타임아웃된 이벤트 없음"); - } - - return recoveredCount; - } - - /** - * 이벤트 처리 통계 조회 - */ - @Transactional(readOnly = true) - public Map getEventStatistics(LocalDateTime fromTime) { - log.debug("이벤트 처리 통계 조회 - 기준시간: {}", fromTime); - - Object statisticsObj = domainEventOutboxRepository.getEventStatistics(fromTime); - - if (statisticsObj instanceof Map) { - @SuppressWarnings("unchecked") - Map statistics = (Map) statisticsObj; - log.debug("이벤트 통계 조회 완료 - 대기: {}, 처리중: {}, 완료: {}, 실패: {}", - statistics.get("pendingCount"), statistics.get("processingCount"), - statistics.get("completedCount"), statistics.get("failedCount")); - return statistics; - } - - return Map.of( - "pendingCount", 0L, - "processingCount", 0L, - "completedCount", 0L, - "failedCount", 0L - ); - } - - /** - * 오래된 완료 이벤트 정리 (배치 작업용) - */ - @Transactional - public int cleanupOldCompletedEvents(int retentionDays) { - LocalDateTime cutoffTime = LocalDateTime.now().minusDays(retentionDays); - - log.info("완료된 이벤트 정리 시작 - 보관기간: {}일, 기준시간: {}", retentionDays, cutoffTime); - - int deletedCount = domainEventOutboxRepository.deleteCompletedEventsBeforeTime(cutoffTime); - - log.info("완료된 이벤트 정리 완료 - 삭제된 이벤트 수: {}", deletedCount); - - return deletedCount; - } - - /** - * 이벤트 ID 생성 - */ - private String generateEventId() { - return UUID.randomUUID().toString(); - } - - /** - * 트랜잭션 커밋 후 콜백 등록 - */ - private void registerPostCommitCallback(Runnable callback) { - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { - @Override - public void afterCommit() { - callback.run(); - } - }); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/MileageBalanceService.java b/src/main/java/com/sudo/railo/payment/application/service/MileageBalanceService.java deleted file mode 100644 index 84adf657..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/MileageBalanceService.java +++ /dev/null @@ -1,235 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.dto.response.MileageBalanceInfo; -import com.sudo.railo.payment.application.dto.response.MileageStatistics; -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.domain.repository.MileageTransactionRepository; -import com.sudo.railo.payment.domain.service.MileageExecutionService; -import com.sudo.railo.payment.exception.PaymentValidationException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.security.core.userdetails.UserDetails; -import com.sudo.railo.member.infra.MemberRepository; -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.member.exception.MemberError; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -/** - * 마일리지 잔액 조회 애플리케이션 서비스 - * 회원의 마일리지 잔액, 거래 내역 등을 조회하는 기능 제공 - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -@Slf4j -public class MileageBalanceService { - - private final MileageTransactionRepository mileageTransactionRepository; - private final MileageExecutionService mileageExecutionService; - private final MemberRepository memberRepository; - - /** - * 회원의 마일리지 잔액 정보 조회 - */ - public MileageBalanceInfo getMileageBalance(UserDetails userDetails) { - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.debug("마일리지 잔액 조회 - 회원ID: {}", memberId); - - // 현재 총 잔액을 Member 엔티티에서 조회합니다. - // MileageTransaction 합계는 동기화 문제가 있을 수 있으므로 Member의 totalMileage를 사용 - BigDecimal currentBalance = BigDecimal.valueOf(member.getTotalMileage()); - - // 트랜잭션 합계와 비교를 위한 로그 (디버깅용) - BigDecimal transactionSum = mileageExecutionService.getCurrentBalance(memberId); - if (currentBalance.compareTo(transactionSum) != 0) { - log.warn("마일리지 불일치 - Member.totalMileage: {}, MileageTransaction 합계: {}", - currentBalance, transactionSum); - } - - // 활성 잔액을 조회합니다. 만료되지 않은 마일리지만 포함합니다. - BigDecimal activeBalance = mileageExecutionService.getActiveBalance(memberId); - - // 최근 거래 내역을 조회합니다. 최근 10건의 거래를 조회합니다. - List recentTransactions = - mileageTransactionRepository.findRecentTransactionsByMemberId(memberId, 10); - - // 마지막 거래 시간을 조회합니다. - LocalDateTime lastTransactionAt = recentTransactions.stream() - .map(MileageTransaction::getProcessedAt) - .filter(java.util.Objects::nonNull) - .max(LocalDateTime::compareTo) - .orElse(null); - - // 통계 정보를 계산합니다. - MileageStatistics statistics = calculateStatistics(memberId); - - // 만료 예정 마일리지를 조회합니다. - BigDecimal expiringMileage = calculateExpiringMileage(memberId); - - log.debug("마일리지 잔액 조회 완료 - 회원ID: {}, 현재잔액: {}, 활성잔액: {}, 만료예정: {}", - memberId, currentBalance, activeBalance, expiringMileage); - - return MileageBalanceInfo.builder() - .memberId(memberId) - .currentBalance(currentBalance) - .activeBalance(activeBalance) - .expiringMileage(expiringMileage) - .lastTransactionAt(lastTransactionAt) - .statistics(statistics) - .recentTransactions(recentTransactions.stream() - .map(this::convertToTransactionSummary) - .toList()) - .build(); - } - - /** - * 회원의 마일리지 통계 정보 계산 - */ - private MileageStatistics calculateStatistics(Long memberId) { - log.debug("마일리지 통계 계산 - 회원ID: {}", memberId); - - // 전체 거래 내역을 조회합니다. - List allTransactions = - mileageTransactionRepository.findByMemberIdOrderByCreatedAtDesc(memberId); - - if (allTransactions.isEmpty()) { - return MileageStatistics.builder() - .totalEarned(BigDecimal.ZERO) - .totalUsed(BigDecimal.ZERO) - .totalTransactions(0) - .averageEarningPerTransaction(BigDecimal.ZERO) - .averageUsagePerTransaction(BigDecimal.ZERO) - .firstTransactionAt(null) - .build(); - } - - // 적립 통계를 계산합니다. - List earnTransactions = allTransactions.stream() - .filter(t -> t.getType() == MileageTransaction.TransactionType.EARN) - .filter(t -> t.getStatus() == MileageTransaction.TransactionStatus.COMPLETED) - .toList(); - - BigDecimal totalEarned = earnTransactions.stream() - .map(MileageTransaction::getPointsAmount) - .reduce(BigDecimal.ZERO, BigDecimal::add); - - // 사용 통계를 계산합니다. - List useTransactions = allTransactions.stream() - .filter(t -> t.getType() == MileageTransaction.TransactionType.USE) - .filter(t -> t.getStatus() == MileageTransaction.TransactionStatus.COMPLETED) - .toList(); - - BigDecimal totalUsed = useTransactions.stream() - .map(MileageTransaction::getPointsAmount) - .map(BigDecimal::abs) // 사용은 음수로 저장되므로 절댓값 - .reduce(BigDecimal.ZERO, BigDecimal::add); - - // 평균을 계산합니다. - BigDecimal averageEarning = earnTransactions.isEmpty() - ? BigDecimal.ZERO - : totalEarned.divide(BigDecimal.valueOf(earnTransactions.size()), 0, BigDecimal.ROUND_HALF_UP); - - BigDecimal averageUsage = useTransactions.isEmpty() - ? BigDecimal.ZERO - : totalUsed.divide(BigDecimal.valueOf(useTransactions.size()), 0, BigDecimal.ROUND_HALF_UP); - - // 첫 거래 시간을 조회합니다. - LocalDateTime firstTransactionAt = allTransactions.stream() - .map(MileageTransaction::getCreatedAt) - .min(LocalDateTime::compareTo) - .orElse(null); - - return MileageStatistics.builder() - .totalEarned(totalEarned) - .totalUsed(totalUsed) - .totalTransactions(allTransactions.size()) - .earnTransactionCount(earnTransactions.size()) - .useTransactionCount(useTransactions.size()) - .averageEarningPerTransaction(averageEarning) - .averageUsagePerTransaction(averageUsage) - .firstTransactionAt(firstTransactionAt) - .build(); - } - - /** - * 30일 이내 만료 예정 마일리지 계산 - */ - private BigDecimal calculateExpiringMileage(Long memberId) { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime thirtyDaysLater = now.plusDays(30); - - List expiringTransactions = - mileageTransactionRepository.findExpiringMileage(now, thirtyDaysLater); - - return expiringTransactions.stream() - .filter(t -> t.getMemberId().equals(memberId)) - .filter(t -> t.getType() == MileageTransaction.TransactionType.EARN) - .filter(t -> t.getStatus() == MileageTransaction.TransactionStatus.COMPLETED) - .map(MileageTransaction::getPointsAmount) - .reduce(BigDecimal.ZERO, BigDecimal::add); - } - - /** - * 마일리지 거래를 요약 정보로 변환 - */ - private MileageBalanceInfo.TransactionSummary convertToTransactionSummary(MileageTransaction transaction) { - return MileageBalanceInfo.TransactionSummary.builder() - .transactionId(transaction.getId()) - .type(transaction.getType().getDescription()) - .amount(transaction.getPointsAmount()) - .description(transaction.getDescription()) - .processedAt(transaction.getProcessedAt()) - .status(transaction.getStatus().getDescription()) - .build(); - } - - /** - * 회원의 사용 가능한 마일리지 내역 조회 (FIFO 순서) - */ - public List getAvailableMileageForUsage(Long memberId) { - log.debug("사용 가능한 마일리지 조회 - 회원ID: {}", memberId); - - return mileageTransactionRepository.findAvailableMileageForUsage(memberId, LocalDateTime.now()); - } - - /** - * 특정 기간의 마일리지 거래 통계 - */ - public MileageStatistics getMileageStatisticsByPeriod(Long memberId, LocalDateTime startDate, LocalDateTime endDate) { - log.debug("기간별 마일리지 통계 조회 - 회원ID: {}, 기간: {} ~ {}", memberId, startDate, endDate); - - Object statisticsObj = mileageTransactionRepository.getMileageStatistics(memberId, startDate, endDate); - - if (statisticsObj instanceof Map) { - @SuppressWarnings("unchecked") - Map stats = (Map) statisticsObj; - - BigDecimal totalEarned = (BigDecimal) stats.get("totalEarned"); - BigDecimal totalUsed = (BigDecimal) stats.get("totalUsed"); - Long transactionCount = (Long) stats.get("transactionCount"); - - return MileageStatistics.builder() - .totalEarned(totalEarned != null ? totalEarned : BigDecimal.ZERO) - .totalUsed(totalUsed != null ? totalUsed : BigDecimal.ZERO) - .totalTransactions(transactionCount != null ? transactionCount.intValue() : 0) - .build(); - } - - return MileageStatistics.builder() - .totalEarned(BigDecimal.ZERO) - .totalUsed(BigDecimal.ZERO) - .totalTransactions(0) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/MileageEarningApplicationService.java b/src/main/java/com/sudo/railo/payment/application/service/MileageEarningApplicationService.java deleted file mode 100644 index 8132e523..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/MileageEarningApplicationService.java +++ /dev/null @@ -1,447 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.global.redis.annotation.DistributedLock; -import com.sudo.railo.payment.application.port.in.*; -import com.sudo.railo.payment.application.port.out.*; -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.domain.service.MileageEarningDomainService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * 마일리지 적립 애플리케이션 서비스 - * - * 헥사고날 아키텍처의 애플리케이션 계층으로, 유스케이스를 구현하고 - * 포트들을 조합하여 비즈니스 플로우를 실행합니다. - * 트랜잭션 경계와 분산 락 관리를 담당합니다. - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class MileageEarningApplicationService implements - CreateMileageEarningScheduleUseCase, - ProcessMileageEarningUseCase, - UpdateMileageEarningScheduleUseCase, - QueryMileageEarningUseCase, - ManageMileageEarningUseCase { - - // 출력 포트 - private final LoadMileageEarningSchedulePort loadSchedulePort; - private final SaveMileageEarningSchedulePort saveSchedulePort; - private final MileageEarningEventPort eventPort; - private final LoadMemberInfoPort memberInfoPort; - private final LoadTrainSchedulePort trainSchedulePort; - private final SaveMileageTransactionPort saveMileageTransactionPort; - - // 도메인 서비스 - private final MileageEarningDomainService domainService; - private final MileageTransactionService mileageTransactionService; - - @Override - @Transactional - public ScheduleCreatedResult createEarningSchedule(CreateScheduleCommand command) { - log.info("마일리지 적립 스케줄 생성 - 열차스케줄ID: {}, 결제ID: {}, 회원ID: {}, 결제금액: {}", - command.trainScheduleId(), command.paymentId(), - command.memberId(), command.paymentAmount()); - - // 노선 정보 조회 - String routeInfo = trainSchedulePort.getRouteInfo(command.trainScheduleId()); - - // 도메인 서비스를 통해 스케줄 생성 - MileageEarningSchedule schedule = domainService.createNormalEarningSchedule( - command.trainScheduleId(), - command.paymentId(), - command.memberId(), - command.paymentAmount(), - command.expectedArrivalTime(), - routeInfo - ); - - // 스케줄 저장 - schedule = saveSchedulePort.save(schedule); - - log.info("마일리지 적립 스케줄 생성 완료 - 스케줄ID: {}, 기본적립: {}P", - schedule.getId(), schedule.getBaseMileageAmount()); - - return new ScheduleCreatedResult( - schedule.getId(), - schedule.getBaseMileageAmount(), - schedule.getScheduledEarningTime(), - schedule.getStatus().name() - ); - } - - @Override - @Transactional - public BatchProcessedResult processReadySchedules(ProcessBatchCommand command) { - log.debug("마일리지 적립 스케줄 배치 처리 시작 - 배치크기: {}", command.batchSize()); - - LocalDateTime now = LocalDateTime.now(); - // 비관적 락을 사용하여 안전하게 조회 - List readySchedules = - loadSchedulePort.findReadySchedulesWithLock(now, command.batchSize()); - - if (readySchedules.isEmpty()) { - log.debug("처리할 마일리지 적립 스케줄이 없습니다"); - return new BatchProcessedResult(0, 0, 0); - } - - int successCount = 0; - int failureCount = 0; - - // 각 스케줄을 개별 트랜잭션으로 처리 - for (MileageEarningSchedule schedule : readySchedules) { - try { - processEarningSchedule(new ProcessScheduleCommand(schedule.getId())); - successCount++; - } catch (Exception e) { - log.error("마일리지 적립 스케줄 처리 실패 - 스케줄ID: {}, 오류: {}", - schedule.getId(), e.getMessage(), e); - failureCount++; - } - } - - log.info("마일리지 적립 스케줄 배치 처리 완료 - 총 처리: {}, 성공: {}, 실패: {}", - readySchedules.size(), successCount, failureCount); - - return new BatchProcessedResult(readySchedules.size(), successCount, failureCount); - } - - @Override - @Transactional(propagation = Propagation.REQUIRES_NEW) - @DistributedLock(key = "#command.scheduleId()", prefix = "mileage:schedule", waitTime = 3) - public void processEarningSchedule(ProcessScheduleCommand command) { - log.debug("마일리지 적립 스케줄 처리 시작 (with lock) - 스케줄ID: {}", command.scheduleId()); - - // 스케줄 조회 - MileageEarningSchedule schedule = loadSchedulePort.findById(command.scheduleId()) - .orElseThrow(() -> new IllegalArgumentException("스케줄을 찾을 수 없습니다: " + command.scheduleId())); - - // 원자적 상태 변경 (READY → BASE_PROCESSING) - int updated = saveSchedulePort.updateStatusAtomically( - command.scheduleId(), - MileageEarningSchedule.EarningStatus.READY, - MileageEarningSchedule.EarningStatus.BASE_PROCESSING - ); - - if (updated == 0) { - log.debug("스케줄이 이미 처리 중이거나 처리되었습니다 - 스케줄ID: {}", command.scheduleId()); - return; - } - - try { - processEarningScheduleInternal(schedule); - } catch (Exception e) { - handleScheduleFailure(schedule, e); - throw e; - } - } - - @Override - @Transactional - public ScheduleUpdateResult markScheduleReady(MarkScheduleReadyCommand command) { - log.info("마일리지 적립 스케줄 준비 완료 처리 - 열차스케줄ID: {}, 실제도착시간: {}", - command.trainScheduleId(), command.actualArrivalTime()); - - List schedules = - loadSchedulePort.findByTrainScheduleId(command.trainScheduleId()); - - if (schedules.isEmpty()) { - log.warn("해당 열차의 마일리지 적립 스케줄이 없습니다 - 열차스케줄ID: {}", command.trainScheduleId()); - return new ScheduleUpdateResult(0, "NO_SCHEDULES"); - } - - int processedCount = 0; - - for (MileageEarningSchedule schedule : schedules) { - // SCHEDULED 상태인 스케줄만 READY로 변경 - if (schedule.getStatus() == MileageEarningSchedule.EarningStatus.SCHEDULED) { - schedule.markReady(); - schedule.updateScheduledEarningTime(command.actualArrivalTime()); - saveSchedulePort.save(schedule); - - // 마일리지 적립 준비 이벤트 발행 - eventPort.publishMileageEarningReadyEvent( - schedule.getId(), - schedule.getMemberId(), - String.valueOf(schedule.getId()) - ); - - processedCount++; - log.debug("마일리지 적립 스케줄 READY 상태 변경 완료 - 스케줄ID: {}", schedule.getId()); - } else { - log.debug("스케줄이 이미 처리되었거나 취소되었습니다 - 스케줄ID: {}, 현재상태: {}", - schedule.getId(), schedule.getStatus()); - } - } - - log.info("열차별 마일리지 적립 스케줄 준비 완료 - 열차스케줄ID: {}, 처리된 스케줄 수: {}/{}", - command.trainScheduleId(), processedCount, schedules.size()); - - return new ScheduleUpdateResult(processedCount, "READY"); - } - - @Override - @Transactional - public ScheduleUpdateResult updateDelayCompensation(UpdateDelayCompensationCommand command) { - log.info("지연 보상 마일리지 스케줄 업데이트 - 열차스케줄ID: {}, 지연시간: {}분", - command.trainScheduleId(), command.delayMinutes()); - - List schedules = - loadSchedulePort.findByTrainScheduleId(command.trainScheduleId()); - - if (schedules.isEmpty()) { - log.warn("해당 열차의 마일리지 적립 스케줄이 없습니다 - 열차스케줄ID: {}", command.trainScheduleId()); - return new ScheduleUpdateResult(0, "NO_SCHEDULES"); - } - - // 지연 보상이 필요한지 확인 - if (!domainService.requiresDelayCompensation(command.delayMinutes())) { - log.info("지연 보상이 필요하지 않습니다 - 지연시간: {}분", command.delayMinutes()); - return new ScheduleUpdateResult(0, "NO_COMPENSATION_REQUIRED"); - } - - // 지연 보상 비율 계산 - BigDecimal compensationRate = new BigDecimal( - String.valueOf(command.delayMinutes() >= 60 ? 0.5 : - command.delayMinutes() >= 40 ? 0.25 : 0.125) - ); - - int processedCount = 0; - - for (MileageEarningSchedule schedule : schedules) { - // 지연 보상은 SCHEDULED, READY, BASE_COMPLETED 상태에서만 업데이트 - if (schedule.getStatus() == MileageEarningSchedule.EarningStatus.SCHEDULED || - schedule.getStatus() == MileageEarningSchedule.EarningStatus.READY || - schedule.getStatus() == MileageEarningSchedule.EarningStatus.BASE_COMPLETED) { - - schedule.updateDelayInfo(command.delayMinutes(), compensationRate); - schedule.updateScheduledEarningTime(command.actualArrivalTime()); - saveSchedulePort.save(schedule); - - processedCount++; - log.debug("지연 보상 스케줄 업데이트 완료 - 스케줄ID: {}, 보상금액: {}P", - schedule.getId(), schedule.getDelayCompensationAmount()); - } else { - log.debug("스케줄이 이미 완료되었거나 취소되었습니다 - 스케줄ID: {}, 현재상태: {}", - schedule.getId(), schedule.getStatus()); - } - } - - log.info("지연 보상 마일리지 스케줄 업데이트 완료 - 열차스케줄ID: {}, 처리된 스케줄 수: {}/{}, 보상비율: {}%", - command.trainScheduleId(), processedCount, schedules.size(), compensationRate.multiply(new BigDecimal("100"))); - - return new ScheduleUpdateResult(processedCount, "DELAY_COMPENSATION_UPDATED"); - } - - @Override - @Transactional(readOnly = true) - public BigDecimal getPendingMileageByMemberId(Long memberId) { - log.debug("회원의 적립 예정 마일리지 조회 - 회원ID: {}", memberId); - return loadSchedulePort.calculatePendingMileageByMemberId(memberId); - } - - @Override - @Transactional(readOnly = true) - public List getEarningSchedulesByMemberId( - Long memberId, MileageEarningSchedule.EarningStatus status) { - log.debug("회원의 마일리지 적립 스케줄 조회 - 회원ID: {}, 상태: {}", memberId, status); - - if (status == null) { - return loadSchedulePort.findByMemberId(memberId); - } else { - return loadSchedulePort.findByMemberIdAndStatus(memberId, status); - } - } - - @Override - @Transactional(readOnly = true) - public Optional getEarningScheduleByPaymentId(String paymentId) { - log.debug("결제별 마일리지 적립 스케줄 조회 - 결제ID: {}", paymentId); - return loadSchedulePort.findByPaymentId(paymentId); - } - - @Override - @Transactional(readOnly = true) - public Map getEarningStatistics(LocalDateTime startTime, LocalDateTime endTime) { - log.debug("마일리지 적립 통계 조회 - 기간: {} ~ {}", startTime, endTime); - - Object result = loadSchedulePort.getMileageEarningStatistics(startTime, endTime); - if (result instanceof Map) { - @SuppressWarnings("unchecked") - Map map = (Map) result; - return map; - } - return Map.of(); - } - - @Override - @Transactional(readOnly = true) - public Map getDelayCompensationStatistics(LocalDateTime startTime, LocalDateTime endTime) { - log.debug("지연 보상 통계 조회 - 기간: {} ~ {}", startTime, endTime); - - Object result = loadSchedulePort.getDelayCompensationStatistics(startTime, endTime); - if (result instanceof Map) { - @SuppressWarnings("unchecked") - Map map = (Map) result; - return map; - } - return Map.of(); - } - - /** - * 마일리지 적립 스케줄 내부 처리 로직 - */ - private void processEarningScheduleInternal(MileageEarningSchedule schedule) { - log.debug("마일리지 적립 내부 처리 시작 - 스케줄ID: {}", schedule.getId()); - - // 회원의 현재 마일리지 잔액 조회 - BigDecimal balanceBefore = memberInfoPort.getMileageBalance(schedule.getMemberId()); - log.debug("회원의 현재 마일리지 잔액 - 회원ID: {}, 잔액: {}P", schedule.getMemberId(), balanceBefore); - - // 1단계: 기본 마일리지 적립 처리 - MileageTransaction baseTransaction = mileageTransactionService.createBaseEarningTransaction( - schedule.getMemberId(), - String.valueOf(schedule.getId()), - schedule.getBaseMileageAmount(), - schedule.getTrainScheduleId(), - schedule.getId(), - balanceBefore - ); - - // 원자적으로 상태와 트랜잭션 ID 업데이트 - MileageEarningSchedule.EarningStatus nextStatus = schedule.hasDelayCompensation() - ? MileageEarningSchedule.EarningStatus.BASE_COMPLETED - : MileageEarningSchedule.EarningStatus.FULLY_COMPLETED; - - int updated = saveSchedulePort.updateWithTransactionAtomically( - schedule.getId(), - MileageEarningSchedule.EarningStatus.BASE_PROCESSING, - nextStatus, - baseTransaction.getId(), - !schedule.hasDelayCompensation() - ); - - if (updated == 0) { - throw new IllegalStateException("스케줄 상태 업데이트 실패 - 동시성 문제 발생"); - } - - // 기본 적립 완료 이벤트 발행 - eventPort.publishMileageEarnedEvent( - baseTransaction.getId(), - baseTransaction.getMemberId(), - baseTransaction.getPointsAmount().toString(), - baseTransaction.getEarningType().name() - ); - - // 2단계: 지연 보상이 있는 경우 처리 - if (schedule.hasDelayCompensation()) { - processDelayCompensation(schedule, balanceBefore.add(baseTransaction.getPointsAmount())); - } - - log.info("마일리지 적립 스케줄 처리 완료 - 스케줄ID: {}, 기본적립: {}P, 지연보상: {}P", - schedule.getId(), schedule.getBaseMileageAmount(), - schedule.getDelayCompensationAmount() != null ? schedule.getDelayCompensationAmount() : BigDecimal.ZERO); - } - - /** - * 지연 보상 마일리지 처리 - */ - private void processDelayCompensation(MileageEarningSchedule schedule, BigDecimal balanceBeforeCompensation) { - // 상태를 COMPENSATION_PROCESSING으로 변경 - int updated = saveSchedulePort.updateStatusAtomically( - schedule.getId(), - MileageEarningSchedule.EarningStatus.BASE_COMPLETED, - MileageEarningSchedule.EarningStatus.COMPENSATION_PROCESSING - ); - - if (updated == 0) { - log.warn("지연 보상 처리를 시작할 수 없습니다 - 스케줄ID: {}", schedule.getId()); - return; - } - - MileageTransaction compensationTransaction = mileageTransactionService.createDelayCompensationTransaction( - schedule.getMemberId(), - String.valueOf(schedule.getId()), - schedule.getDelayCompensationAmount(), - schedule.getTrainScheduleId(), - schedule.getId(), - schedule.getDelayMinutes(), - schedule.getDelayCompensationRate(), - balanceBeforeCompensation - ); - - // 완료 상태로 변경 - saveSchedulePort.updateWithTransactionAtomically( - schedule.getId(), - MileageEarningSchedule.EarningStatus.COMPENSATION_PROCESSING, - MileageEarningSchedule.EarningStatus.FULLY_COMPLETED, - compensationTransaction.getId(), - true - ); - - // 지연 보상 이벤트 발행 - eventPort.publishDelayCompensationEarnedEvent( - compensationTransaction.getId(), - compensationTransaction.getMemberId(), - compensationTransaction.getPointsAmount().toString(), - schedule.getDelayMinutes() - ); - } - - /** - * 스케줄 처리 실패 시 처리 - */ - private void handleScheduleFailure(MileageEarningSchedule schedule, Exception e) { - log.error("마일리지 적립 스케줄 처리 실패 - 스케줄ID: {}", schedule.getId(), e); - - try { - saveSchedulePort.updateStatusAtomically( - schedule.getId(), - MileageEarningSchedule.EarningStatus.BASE_PROCESSING, - MileageEarningSchedule.EarningStatus.FAILED - ); - - // 실패 정보 저장을 위해 엔티티 업데이트 - schedule.fail(e.getMessage()); - saveSchedulePort.save(schedule); - - // 실패 이벤트 발행 - eventPort.publishMileageEarningFailedEvent( - schedule.getId(), - schedule.getMemberId(), - e.getMessage() - ); - } catch (Exception updateException) { - log.error("실패 상태 업데이트 중 오류 발생 - 스케줄ID: {}", schedule.getId(), updateException); - } - } - - @Override - @Transactional - public CleanupResult cleanupOldCompletedSchedules(CleanupOldSchedulesCommand command) { - LocalDateTime cutoffTime = LocalDateTime.now().minusDays(command.retentionDays()); - - log.info("완료된 마일리지 적립 스케줄 정리 시작 - 보관기간: {}일, 기준시간: {}", - command.retentionDays(), cutoffTime); - - int deletedCount = loadSchedulePort.deleteCompletedSchedulesBeforeTime(cutoffTime); - - log.info("완료된 마일리지 적립 스케줄 정리 완료 - 삭제된 스케줄 수: {}", deletedCount); - - return new CleanupResult( - deletedCount, - String.format("%d개의 오래된 마일리지 적립 스케줄이 정리되었습니다.", deletedCount) - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/MileageSchedulerService.java b/src/main/java/com/sudo/railo/payment/application/service/MileageSchedulerService.java deleted file mode 100644 index 0338a5d4..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/MileageSchedulerService.java +++ /dev/null @@ -1,273 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.payment.domain.repository.MileageEarningScheduleRepository; -import com.sudo.railo.payment.domain.repository.PaymentRepository; -import com.sudo.railo.payment.domain.service.MileageExecutionService; -import com.sudo.railo.train.application.TrainScheduleService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -/** - * 마일리지 적립 스케줄러 서비스 - * 열차 도착 시점에 마일리지를 적립하는 배치 서비스 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class MileageSchedulerService { - - private final MileageEarningScheduleRepository scheduleRepository; - private final PaymentRepository paymentRepository; - private final MileageExecutionService mileageExecutionService; - private final TrainScheduleService trainScheduleService; - - /** - * 5분마다 실행되는 스케줄러 - * 도착 시간이 지난 SCHEDULED 상태의 스케줄을 READY로 변경 - */ - @Scheduled(fixedDelay = 300000) // 5분 - @Transactional - public void updateReadySchedules() { - LocalDateTime now = LocalDateTime.now(); - log.debug("마일리지 적립 스케줄 상태 업데이트 시작 - 현재시간: {}", now); - - List scheduledList = scheduleRepository.findScheduledBeforeTime(now); - - if (!scheduledList.isEmpty()) { - log.info("READY 상태로 변경할 스케줄 수: {}", scheduledList.size()); - - List validSchedules = new ArrayList<>(); - - for (MileageEarningSchedule schedule : scheduledList) { - // 결제 정보 조회 - Payment payment = paymentRepository.findById(Long.valueOf(schedule.getPaymentId())) - .orElse(null); - - if (payment == null) { - log.warn("결제 정보를 찾을 수 없음 - scheduleId: {}, paymentId: {}", - schedule.getId(), schedule.getPaymentId()); - continue; - } - - // 환불된 결제는 READY로 변경하지 않고 CANCELLED로 변경 - if (payment.getPaymentStatus() == PaymentExecutionStatus.REFUNDED || - payment.getPaymentStatus() == PaymentExecutionStatus.CANCELLED) { - schedule.cancel("환불된 결제로 인한 적립 취소"); - validSchedules.add(schedule); - log.info("환불된 결제의 스케줄 취소 - scheduleId: {}, paymentId: {}", - schedule.getId(), payment.getId()); - } else { - schedule.markReady(); - validSchedules.add(schedule); - log.debug("스케줄 READY 변경 - scheduleId: {}, trainScheduleId: {}, memberId: {}", - schedule.getId(), schedule.getTrainScheduleId(), schedule.getMemberId()); - } - } - - if (!validSchedules.isEmpty()) { - scheduleRepository.saveAll(validSchedules); - } - } - } - - /** - * 5분마다 실행되는 스케줄러 - * READY 상태의 스케줄에 대해 기본 마일리지 적립 처리 - */ - @Scheduled(fixedDelay = 300000, initialDelay = 60000) // 5분 간격, 1분 후 시작 - public void processMileageEarning() { - log.debug("마일리지 적립 처리 시작"); - - List readySchedules = scheduleRepository.findReadySchedules(); - - if (readySchedules.isEmpty()) { - return; - } - - log.info("처리할 READY 스케줄 수: {}", readySchedules.size()); - - for (MileageEarningSchedule schedule : readySchedules) { - // READY 상태인 스케줄만 처리 (이중 체크) - if (schedule.getStatus() != MileageEarningSchedule.EarningStatus.READY) { - log.warn("READY가 아닌 스케줄이 조회됨 - scheduleId: {}, status: {}", - schedule.getId(), schedule.getStatus()); - continue; - } - - try { - processIndividualSchedule(schedule); - } catch (Exception e) { - log.error("마일리지 적립 처리 실패 - scheduleId: {}", schedule.getId(), e); - } - } - } - - /** - * 개별 스케줄 처리 - */ - @Transactional - protected void processIndividualSchedule(MileageEarningSchedule schedule) { - try { - // 결제 정보 조회 - Payment payment = paymentRepository.findById(Long.valueOf(schedule.getPaymentId())) - .orElseThrow(() -> new IllegalStateException("결제 정보를 찾을 수 없습니다: " + schedule.getPaymentId())); - - // 환불된 결제는 처리하지 않음 - if (payment.getPaymentStatus() == PaymentExecutionStatus.REFUNDED || - payment.getPaymentStatus() == PaymentExecutionStatus.CANCELLED) { - schedule.setStatus(MileageEarningSchedule.EarningStatus.CANCELLED); - schedule.setErrorMessage("환불된 결제로 인한 적립 취소"); - schedule.setProcessedAt(LocalDateTime.now()); - scheduleRepository.save(schedule); - log.info("환불된 결제의 마일리지 적립 취소 - paymentId: {}", payment.getId()); - return; - } - - // 기본 마일리지 적립 - schedule.startBaseProcessing(); - scheduleRepository.save(schedule); - - MileageTransaction transaction = mileageExecutionService.executeEarning(payment); - - if (transaction != null) { - schedule.completeBaseEarning(transaction.getId()); - - // 지연 정보 확인 및 보상 계산 - checkAndUpdateDelayCompensation(schedule); - - scheduleRepository.save(schedule); - log.info("기본 마일리지 적립 완료 - scheduleId: {}, transactionId: {}, amount: {}", - schedule.getId(), transaction.getId(), schedule.getBaseMileageAmount()); - } - - } catch (Exception e) { - schedule.fail(e.getMessage()); - scheduleRepository.save(schedule); - throw new RuntimeException("마일리지 적립 처리 실패", e); - } - } - - /** - * 5분마다 실행되는 스케줄러 - * 지연 보상 마일리지 처리 - */ - @Scheduled(fixedDelay = 300000, initialDelay = 120000) // 5분 간격, 2분 후 시작 - @Transactional - public void processDelayCompensation() { - log.debug("지연 보상 마일리지 처리 시작"); - - List compensationSchedules = - scheduleRepository.findBaseCompletedWithCompensation(); - - if (compensationSchedules.isEmpty()) { - return; - } - - log.info("처리할 지연 보상 스케줄 수: {}", compensationSchedules.size()); - - for (MileageEarningSchedule schedule : compensationSchedules) { - // BASE_COMPLETED 상태인 스케줄만 처리 (이중 체크) - if (schedule.getStatus() != MileageEarningSchedule.EarningStatus.BASE_COMPLETED) { - log.warn("BASE_COMPLETED가 아닌 스케줄이 조회됨 - scheduleId: {}, status: {}", - schedule.getId(), schedule.getStatus()); - continue; - } - - try { - processDelayCompensationForSchedule(schedule); - } catch (Exception e) { - log.error("지연 보상 처리 실패 - scheduleId: {}", schedule.getId(), e); - } - } - } - - /** - * 개별 지연 보상 처리 - */ - @Transactional - protected void processDelayCompensationForSchedule(MileageEarningSchedule schedule) { - try { - schedule.startCompensationProcessing(); - scheduleRepository.save(schedule); - - // 결제 정보 조회 - Payment payment = paymentRepository.findById(Long.valueOf(schedule.getPaymentId())) - .orElseThrow(() -> new IllegalStateException("결제 정보를 찾을 수 없습니다")); - - // 지연 보상 마일리지 적립 - MileageTransaction compensation = mileageExecutionService.restoreMileageUsage( - payment.getId().toString(), - payment.getMemberId(), - schedule.getDelayCompensationAmount(), - String.format("열차 지연 보상 마일리지 (%d분 지연, %s)", - schedule.getDelayMinutes(), schedule.getRouteInfo()) - ); - - schedule.completeCompensationEarning(compensation.getId()); - scheduleRepository.save(schedule); - - log.info("지연 보상 마일리지 적립 완료 - scheduleId: {}, amount: {}, delayMinutes: {}", - schedule.getId(), schedule.getDelayCompensationAmount(), schedule.getDelayMinutes()); - - } catch (Exception e) { - schedule.fail("지연 보상 처리 실패: " + e.getMessage()); - scheduleRepository.save(schedule); - throw new RuntimeException("지연 보상 처리 실패", e); - } - } - - /** - * 지연 시간에 따른 보상율 계산 - * 20-40분: 12.5%, 40-60분: 25%, 60분 이상: 50% - */ - public BigDecimal calculateCompensationRate(int delayMinutes) { - if (delayMinutes < 20) { - return BigDecimal.ZERO; - } else if (delayMinutes < 40) { - return new BigDecimal("0.125"); - } else if (delayMinutes < 60) { - return new BigDecimal("0.25"); - } else { - return new BigDecimal("0.5"); - } - } - - /** - * 지연 정보 확인 및 보상 업데이트 - */ - private void checkAndUpdateDelayCompensation(MileageEarningSchedule schedule) { - try { - // TrainScheduleService에서 실시간 지연 정보 조회 - TrainScheduleService.TrainTimeInfo timeInfo = - trainScheduleService.getTrainTimeInfo(schedule.getTrainScheduleId()); - - if (timeInfo != null && timeInfo.delayMinutes() > 0) { - int delayMinutes = timeInfo.delayMinutes(); - BigDecimal compensationRate = calculateCompensationRate(delayMinutes); - - if (compensationRate.compareTo(BigDecimal.ZERO) > 0) { - // 지연 보상 정보 업데이트 - schedule.updateDelayInfo(delayMinutes, compensationRate); - log.info("지연 보상 정보 업데이트 - scheduleId: {}, delayMinutes: {}, compensationRate: {}%", - schedule.getId(), delayMinutes, compensationRate.multiply(new BigDecimal("100"))); - } - } - } catch (Exception e) { - log.error("지연 정보 조회 실패 - scheduleId: {}", schedule.getId(), e); - // 지연 정보 조회 실패는 기본 적립을 막지 않음 - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/MileageTransactionService.java b/src/main/java/com/sudo/railo/payment/application/service/MileageTransactionService.java deleted file mode 100644 index 6c617ae2..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/MileageTransactionService.java +++ /dev/null @@ -1,496 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.global.redis.annotation.DistributedLock; -import com.sudo.railo.payment.application.dto.response.MileageStatistics; -import com.sudo.railo.payment.application.dto.response.MileageStatisticsResponse; -import com.sudo.railo.payment.application.port.out.LoadMileageTransactionPort; -import com.sudo.railo.payment.application.port.out.SaveMileageTransactionPort; -import com.sudo.railo.payment.application.port.out.SaveMemberInfoPort; -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.exception.PaymentException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.security.core.userdetails.UserDetails; -import com.sudo.railo.member.infra.MemberRepository; -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.member.exception.MemberError; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * 마일리지 거래 서비스 - * 새로운 EarningType을 지원하는 마일리지 거래 생성 및 관리 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class MileageTransactionService { - - private final LoadMileageTransactionPort loadMileageTransactionPort; - private final SaveMileageTransactionPort saveMileageTransactionPort; - private final SaveMemberInfoPort saveMemberInfoPort; - private final MemberRepository memberRepository; - - /** - * 기본 마일리지 적립 거래 생성 - */ - @Transactional - @DistributedLock(key = "#memberId", prefix = "mileage:earn", waitTime = 3) - public MileageTransaction createBaseEarningTransaction( - Long memberId, - String paymentId, - BigDecimal amount, - Long trainScheduleId, - Long earningScheduleId, - BigDecimal balanceBefore) { - - log.info("기본 마일리지 적립 거래 생성 - 회원ID: {}, 결제ID: {}, 금액: {}P", - memberId, paymentId, amount); - - String description = String.format("기차 이용 기본 마일리지 적립 (1%%) - 결제ID: %s", paymentId); - - MileageTransaction transaction = MileageTransaction.createBaseEarningTransaction( - memberId, - paymentId, - trainScheduleId, - earningScheduleId, - amount, - balanceBefore, - description - ); - - transaction = saveMileageTransactionPort.save(transaction); - - // 거래 즉시 완료 처리 - // TODO: 열차 도착 발생 시 completed로 변경 필요 - transaction.complete(); - transaction = saveMileageTransactionPort.save(transaction); - - // 회원 마일리지 잔액 동기화 - saveMemberInfoPort.addMileage(memberId, amount.longValue()); - - log.info("기본 마일리지 적립 거래 생성 완료 - 거래ID: {}, 금액: {}P, 상태: {}", - transaction.getId(), amount, transaction.getStatus()); - - return transaction; - } - - /** - * 지연 보상 마일리지 적립 거래 생성 - */ - @Transactional - @DistributedLock(key = "#memberId", prefix = "mileage:delay", waitTime = 3) - public MileageTransaction createDelayCompensationTransaction( - Long memberId, - String paymentId, - BigDecimal compensationAmount, - Long trainScheduleId, - Long earningScheduleId, - int delayMinutes, - BigDecimal compensationRate, - BigDecimal balanceBefore) { - - log.info("지연 보상 마일리지 적립 거래 생성 - 회원ID: {}, 결제ID: {}, 보상금액: {}P, 지연시간: {}분", - memberId, paymentId, compensationAmount, delayMinutes); - - String description = String.format("열차 지연 보상 마일리지 (지연 %d분, %.1f%% 보상) - 결제ID: %s", - delayMinutes, compensationRate.multiply(new BigDecimal("100")), paymentId); - - MileageTransaction transaction = MileageTransaction.createDelayCompensationTransaction( - memberId, - paymentId, - trainScheduleId, - earningScheduleId, - compensationAmount, - balanceBefore, - delayMinutes, - compensationRate, - description - ); - - transaction = saveMileageTransactionPort.save(transaction); - - // 거래 즉시 완료 처리 - // TODO: 열차 도착 발생 시 completed로 변경 필요 - transaction.complete(); - transaction = saveMileageTransactionPort.save(transaction); - - // 회원 마일리지 잔액 동기화 - saveMemberInfoPort.addMileage(memberId, compensationAmount.longValue()); - - log.info("지연 보상 마일리지 적립 거래 생성 완료 - 거래ID: {}, 보상금액: {}P, 상태: {}", - transaction.getId(), compensationAmount, transaction.getStatus()); - - return transaction; - } - - /** - * 프로모션 마일리지 적립 거래 생성 - */ - /* // TODO: 프로모션 기능 구현 시 활성화 - @Transactional - public MileageTransaction createPromotionEarningTransaction( - Long memberId, - String paymentId, - BigDecimal promotionAmount, - String promotionCode, - String description) { - - log.info("프로모션 마일리지 적립 거래 생성 - 회원ID: {}, 결제ID: {}, 프로모션금액: {}P, 코드: {}", - memberId, paymentId, promotionAmount, promotionCode); - - MileageTransaction transaction = MileageTransaction.createPromotionEarningTransaction( - memberId, - paymentId, - promotionAmount, - promotionCode, - description - ); - - transaction = saveMileageTransactionPort.save(transaction); - - log.info("프로모션 마일리지 적립 거래 생성 완료 - 거래ID: {}, 프로모션금액: {}P", - transaction.getId(), promotionAmount); - - return transaction; - } - */ - - /** - * 수동 조정 마일리지 거래 생성 (관리자용) - */ - /* // TODO: 관리자 기능 구현 시 활성화 - @Transactional - public MileageTransaction createManualAdjustmentTransaction( - Long memberId, - BigDecimal adjustmentAmount, - String reason, - String adminId) { - - log.info("수동 조정 마일리지 거래 생성 - 회원ID: {}, 조정금액: {}P, 사유: {}, 관리자ID: {}", - memberId, adjustmentAmount, reason, adminId); - - MileageTransaction transaction = MileageTransaction.createManualAdjustmentTransaction( - memberId, - adjustmentAmount, - reason, - adminId - ); - - transaction = saveMileageTransactionPort.save(transaction); - - log.info("수동 조정 마일리지 거래 생성 완료 - 거래ID: {}, 조정금액: {}P", - transaction.getId(), adjustmentAmount); - - return transaction; - } - */ - - /** - * 특정 열차 스케줄의 마일리지 거래 조회 - */ - @Transactional(readOnly = true) - public List getTransactionsByTrainSchedule(Long trainScheduleId) { - log.debug("열차 스케줄별 마일리지 거래 조회 - 열차스케줄ID: {}", trainScheduleId); - - return loadMileageTransactionPort.findByTrainScheduleId(trainScheduleId); - } - - /** - * 특정 적립 스케줄의 마일리지 거래 조회 - */ - @Transactional(readOnly = true) - public List getTransactionsByEarningSchedule(Long earningScheduleId) { - log.debug("적립 스케줄별 마일리지 거래 조회 - 적립스케줄ID: {}", earningScheduleId); - - return loadMileageTransactionPort.findByEarningScheduleId(earningScheduleId); - } - - /** - * 특정 적립 스케줄의 기본 마일리지 거래 조회 - */ - @Transactional(readOnly = true) - public Optional getBaseEarningTransaction(Long earningScheduleId) { - log.debug("기본 마일리지 거래 조회 - 적립스케줄ID: {}", earningScheduleId); - - return loadMileageTransactionPort.findBaseEarningByScheduleId(earningScheduleId); - } - - /** - * 특정 적립 스케줄의 지연 보상 거래 조회 - */ - @Transactional(readOnly = true) - public Optional getDelayCompensationTransaction(Long earningScheduleId) { - log.debug("지연 보상 마일리지 거래 조회 - 적립스케줄ID: {}", earningScheduleId); - - return loadMileageTransactionPort.findDelayCompensationByScheduleId(earningScheduleId); - } - - /** - * 회원의 적립 타입별 마일리지 거래 조회 - */ - @Transactional(readOnly = true) - public List getTransactionsByEarningType( - Long memberId, MileageTransaction.EarningType earningType) { - log.debug("적립 타입별 마일리지 거래 조회 - 회원ID: {}, 타입: {}", memberId, earningType); - - return loadMileageTransactionPort.findByMemberIdAndEarningType(memberId, earningType); - } - - /** - * 회원의 지연 보상 총액 계산 - */ - @Transactional(readOnly = true) - public BigDecimal calculateTotalDelayCompensation(Long memberId) { - log.debug("회원의 지연 보상 총액 계산 - 회원ID: {}", memberId); - - return loadMileageTransactionPort.calculateTotalDelayCompensationByMemberId(memberId); - } - - /** - * 지연 보상 마일리지 거래 조회 (통계용) - */ - @Transactional(readOnly = true) - public List getDelayCompensationTransactions( - LocalDateTime startTime, LocalDateTime endTime) { - log.debug("지연 보상 마일리지 거래 조회 - 기간: {} ~ {}", startTime, endTime); - - return loadMileageTransactionPort.findDelayCompensationTransactions(startTime, endTime); - } - - /** - * 적립 타입별 통계 조회 - */ - @Transactional(readOnly = true) - public List getEarningTypeStatistics(LocalDateTime startTime, LocalDateTime endTime) { - log.debug("적립 타입별 통계 조회 - 기간: {} ~ {}", startTime, endTime); - - return loadMileageTransactionPort.getEarningTypeStatistics(startTime, endTime); - } - - /** - * 지연 시간대별 보상 마일리지 통계 - */ - @Transactional(readOnly = true) - public List getDelayCompensationStatisticsByDelayTime( - LocalDateTime startTime, LocalDateTime endTime) { - log.debug("지연 시간대별 보상 마일리지 통계 조회 - 기간: {} ~ {}", startTime, endTime); - - return loadMileageTransactionPort.getDelayCompensationStatisticsByDelayTime(startTime, endTime); - } - - /** - * 회원의 열차 관련 마일리지 적립 내역 조회 - */ - @Transactional(readOnly = true) - public List getTrainRelatedEarnings(Long memberId) { - log.debug("회원의 열차 관련 마일리지 적립 내역 조회 - 회원ID: {}", memberId); - - return loadMileageTransactionPort.findByMemberIdAndEarningType( - memberId, MileageTransaction.EarningType.BASE_EARN); - } - - /** - * 특정 열차 스케줄의 총 지급된 마일리지 계산 - */ - @Transactional(readOnly = true) - public BigDecimal calculateTotalMileageByTrainSchedule(Long trainScheduleId) { - log.debug("열차 스케줄별 총 지급 마일리지 계산 - 열차스케줄ID: {}", trainScheduleId); - - return loadMileageTransactionPort.calculateTotalMileageByTrainSchedule(trainScheduleId); - } - - /** - * 특정 결제의 모든 관련 마일리지 거래 조회 - */ - @Transactional(readOnly = true) - public List getAllMileageTransactionsByPayment(String paymentId) { - log.debug("결제별 모든 마일리지 거래 조회 - 결제ID: {}", paymentId); - - return loadMileageTransactionPort.findAllMileageTransactionsByPaymentId(paymentId); - } - - /** - * 미처리된 마일리지 거래 조회 (재처리용) - */ - @Transactional(readOnly = true) - public List getPendingTransactions(int hours) { - LocalDateTime beforeTime = LocalDateTime.now().minusHours(hours); - - log.debug("미처리된 마일리지 거래 조회 - {}시간 이전", hours); - - return loadMileageTransactionPort.findPendingTransactionsBeforeTime(beforeTime); - } - - /** - * 마일리지 거래 상태 업데이트 - */ - /* // TODO: 범용 상태 업데이트 대신 complete(), cancel() 등 명시적 메서드 사용 - @Transactional - public void updateTransactionStatus(Long transactionId, - MileageTransaction.TransactionStatus newStatus) { - log.info("마일리지 거래 상태 업데이트 - 거래ID: {}, 새 상태: {}", transactionId, newStatus); - - MileageTransaction transaction = mileageTransactionRepository.findById(transactionId) - .orElseThrow(() -> new PaymentException("마일리지 거래를 찾을 수 없습니다 - 거래ID: " + transactionId)); - - transaction.updateStatus(newStatus); - mileageTransactionRepository.save(transaction); - - log.info("마일리지 거래 상태 업데이트 완료 - 거래ID: {}, 상태: {}", transactionId, newStatus); - } - */ - - /** - * 마일리지 거래 처리 완료 - */ - @Transactional - public void completeTransaction(Long transactionId) { - log.info("마일리지 거래 처리 완료 - 거래ID: {}", transactionId); - - MileageTransaction transaction = loadMileageTransactionPort.findById(transactionId) - .orElseThrow(() -> new PaymentException("마일리지 거래를 찾을 수 없습니다 - 거래ID: " + transactionId)); - - transaction.complete(); - saveMileageTransactionPort.save(transaction); - - log.info("마일리지 거래 처리 완료됨 - 거래ID: {}, 처리시간: {}", - transactionId, transaction.getProcessedAt()); - } - - /** - * 마일리지 통계 조회 - */ - @Transactional(readOnly = true) - public MileageStatisticsResponse getMileageStatistics(UserDetails userDetails, LocalDateTime startDate, LocalDateTime endDate) { - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.debug("마일리지 통계 조회 - 회원ID: {}, 기간: {} ~ {}", memberId, startDate, endDate); - - // 기간 내 적립 총액 - BigDecimal totalEarned = loadMileageTransactionPort.calculateTotalEarnedInPeriod( - memberId, startDate, endDate); - - // 기간 내 사용 총액 - BigDecimal totalUsed = loadMileageTransactionPort.calculateTotalUsedInPeriod( - memberId, startDate, endDate); - - // 전체 거래 내역 조회하여 통계 계산 - List allTransactions = loadMileageTransactionPort - .findByMemberIdOrderByCreatedAtDesc(memberId); - - // 적립/사용 건수 계산 - int earnCount = 0; - int useCount = 0; - LocalDateTime firstTransactionAt = null; - LocalDateTime lastEarningAt = null; - LocalDateTime lastUsageAt = null; - - for (MileageTransaction tx : allTransactions) { - if (firstTransactionAt == null || tx.getCreatedAt().isBefore(firstTransactionAt)) { - firstTransactionAt = tx.getCreatedAt(); - } - - if (tx.getType() == MileageTransaction.TransactionType.EARN) { - earnCount++; - if (lastEarningAt == null || tx.getCreatedAt().isAfter(lastEarningAt)) { - lastEarningAt = tx.getCreatedAt(); - } - } else if (tx.getType() == MileageTransaction.TransactionType.USE) { - useCount++; - if (lastUsageAt == null || tx.getCreatedAt().isAfter(lastUsageAt)) { - lastUsageAt = tx.getCreatedAt(); - } - } - } - - // MileageStatistics 객체 생성 - MileageStatistics statistics = MileageStatistics.builder() - .totalTransactions(allTransactions.size()) - .earnTransactionCount(earnCount) - .useTransactionCount(useCount) - .totalEarned(totalEarned != null ? totalEarned : BigDecimal.ZERO) - .totalUsed(totalUsed != null ? totalUsed : BigDecimal.ZERO) - .averageEarningPerTransaction(earnCount > 0 ? totalEarned.divide(BigDecimal.valueOf(earnCount), 2, BigDecimal.ROUND_HALF_UP) : BigDecimal.ZERO) - .averageUsagePerTransaction(useCount > 0 ? totalUsed.divide(BigDecimal.valueOf(useCount), 2, BigDecimal.ROUND_HALF_UP) : BigDecimal.ZERO) - .firstTransactionAt(firstTransactionAt) - .lastEarningAt(lastEarningAt) - .lastUsageAt(lastUsageAt) - .build(); - - return MileageStatisticsResponse.from(memberId, statistics); - } - - /** - * 마일리지 거래 내역 조회 (페이징) - */ - @Transactional(readOnly = true) - public Page getMileageTransactions(UserDetails userDetails, Pageable pageable) { - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.debug("마일리지 거래 내역 조회 - 회원ID: {}, 페이지: {}", memberId, pageable.getPageNumber()); - - return loadMileageTransactionPort.findByMemberIdOrderByCreatedAtDesc(memberId, pageable); - } - - /** - * 마일리지 적립 이력 조회 - */ - @Transactional(readOnly = true) - public List getEarningHistory(UserDetails userDetails, String trainId, - LocalDateTime startDate, LocalDateTime endDate) { - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.debug("마일리지 적립 이력 조회 - 회원ID: {}, 기차ID: {}, 기간: {} ~ {}", - memberId, trainId, startDate, endDate); - - // 기차ID가 지정된 경우 - if (trainId != null && !trainId.isEmpty()) { - return loadMileageTransactionPort.findEarningHistoryByTrainId( - memberId, trainId, startDate, endDate); - } - - // 기간만 지정된 경우 - if (startDate != null && endDate != null) { - return loadMileageTransactionPort.findEarningHistoryByPeriod( - memberId, startDate, endDate); - } - - // 모든 적립 이력 - return loadMileageTransactionPort.findAllEarningHistory(memberId); - } - - /** - * 회원별 지연 보상 마일리지 거래 조회 - */ - @Transactional(readOnly = true) - public List getDelayCompensationTransactions(UserDetails userDetails) { - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.debug("회원별 지연 보상 마일리지 거래 조회 - 회원ID: {}", memberId); - - return loadMileageTransactionPort.findByMemberIdAndEarningType( - memberId, MileageTransaction.EarningType.DELAY_COMPENSATION); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/MileageValidationService.java b/src/main/java/com/sudo/railo/payment/application/service/MileageValidationService.java deleted file mode 100644 index 44997415..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/MileageValidationService.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.exception.PaymentValidationException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.math.BigDecimal; - -/** - * 마일리지 검증 서비스 - * - * 마일리지 사용에 대한 모든 검증 로직을 담당합니다. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class MileageValidationService { - - private static final BigDecimal MIN_MILEAGE_USE = BigDecimal.valueOf(1000); - private static final BigDecimal MILEAGE_UNIT = BigDecimal.valueOf(100); - - /** - * 마일리지 사용 검증 - * - * @param mileageToUse 사용하려는 마일리지 - * @param availableMileage 사용 가능한 마일리지 - * @throws PaymentValidationException 검증 실패 시 - */ - public void validate(BigDecimal mileageToUse, BigDecimal availableMileage) { - // null 체크 - if (mileageToUse == null) { - return; // 마일리지 미사용 - } - - // 음수 체크 - if (mileageToUse.compareTo(BigDecimal.ZERO) < 0) { - throw new PaymentValidationException("마일리지는 음수일 수 없습니다"); - } - - // 0원 체크 - if (mileageToUse.compareTo(BigDecimal.ZERO) == 0) { - return; // 마일리지 미사용 - } - - // 최소 사용 금액 체크 - if (mileageToUse.compareTo(MIN_MILEAGE_USE) < 0) { - throw new PaymentValidationException( - String.format("마일리지는 최소 %s포인트 이상 사용해야 합니다", MIN_MILEAGE_USE)); - } - - // 사용 단위 체크 - if (mileageToUse.remainder(MILEAGE_UNIT).compareTo(BigDecimal.ZERO) != 0) { - throw new PaymentValidationException( - String.format("마일리지는 %s포인트 단위로 사용해야 합니다", MILEAGE_UNIT)); - } - - // 보유 마일리지 체크 - if (availableMileage == null) { - throw new PaymentValidationException("사용 가능한 마일리지 정보가 없습니다"); - } - - if (mileageToUse.compareTo(availableMileage) > 0) { - throw new PaymentValidationException( - String.format("보유 마일리지가 부족합니다. 보유: %s, 사용 요청: %s", - availableMileage, mileageToUse)); - } - - log.info("마일리지 검증 성공 - 사용: {}, 보유: {}", mileageToUse, availableMileage); - } - - /** - * 결제 금액 대비 마일리지 사용 가능 여부 검증 - * - * @param mileageToUse 사용하려는 마일리지 - * @param payableAmount 결제 가능 금액 - */ - public void validateAgainstPayableAmount(BigDecimal mileageToUse, BigDecimal payableAmount) { - if (mileageToUse == null || mileageToUse.compareTo(BigDecimal.ZERO) == 0) { - return; - } - - // 마일리지가 결제 금액보다 큰 경우 - if (mileageToUse.compareTo(payableAmount) > 0) { - throw new PaymentValidationException( - String.format("마일리지 사용액이 결제 금액을 초과합니다. 결제금액: %s, 마일리지: %s", - payableAmount, mileageToUse)); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PaymentCalculationService.java b/src/main/java/com/sudo/railo/payment/application/service/PaymentCalculationService.java deleted file mode 100644 index ba83705b..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PaymentCalculationService.java +++ /dev/null @@ -1,297 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.dto.request.PaymentCalculationRequest; -import com.sudo.railo.payment.application.dto.response.PaymentCalculationResponse; -import com.sudo.railo.payment.domain.entity.PaymentCalculation; -import com.sudo.railo.payment.domain.entity.CalculationStatus; -import com.sudo.railo.payment.domain.repository.PaymentCalculationRepository; -import com.sudo.railo.payment.domain.service.PaymentValidationService; -import com.sudo.railo.payment.domain.service.MileageService; -import com.sudo.railo.payment.exception.PaymentValidationException; -import com.sudo.railo.payment.application.event.PaymentEventPublisher; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.*; - -/** - * 결제 계산 애플리케이션 서비스 - * - * 마일리지 시스템이 완전히 통합된 결제 계산 로직을 제공합니다. - * - 마일리지 사용 검증 및 계산 - * - 30분 만료 세션 관리 - * - 프로모션 적용 및 스냅샷 저장 - * - 이벤트 기반 아키텍처 - */ -@Service -@RequiredArgsConstructor -@Transactional -@Slf4j -public class PaymentCalculationService { - - private final PaymentCalculationRepository calculationRepository; - private final PaymentValidationService validationService; - private final MileageService mileageService; - private final PaymentEventPublisher eventPublisher; - - /** - * 결제 금액 계산 (마일리지 통합) - */ - public PaymentCalculationResponse calculatePayment(PaymentCalculationRequest request) { - log.debug("결제 계산 시작 - 주문ID: {}, 사용자: {}, 원본금액: {}, 마일리지사용: {}", - request.getExternalOrderId(), request.getUserId(), - request.getOriginalAmount(), request.getMileageToUse()); - - validationService.validateCalculationRequest(request); - - // 비회원이 마일리지를 사용하려고 하는 경우 검증 - if ("guest_user".equals(request.getUserId()) && - request.getMileageToUse() != null && - request.getMileageToUse().compareTo(BigDecimal.ZERO) > 0) { - throw new PaymentValidationException("비회원은 마일리지를 사용할 수 없습니다"); - } - - boolean mileageValid = mileageService.validateMileageUsage( - request.getMileageToUse(), - request.getAvailableMileage(), - request.getOriginalAmount() - ); - - if (!mileageValid) { - log.debug("마일리지 사용 검증 실패 - 요청: {}, 보유: {}, 결제금액: {}", - request.getMileageToUse(), request.getAvailableMileage(), request.getOriginalAmount()); - throw new PaymentValidationException("마일리지 사용 조건을 만족하지 않습니다"); - } - - // 계산 ID 생성 - String calculationId = UUID.randomUUID().toString(); - - // 마일리지 할인 적용한 최종 금액 계산 - BigDecimal finalAmount = mileageService.calculateFinalAmount( - request.getOriginalAmount(), - request.getMileageToUse() - ); - - // 마일리지 정보 생성 - PaymentCalculationResponse.MileageInfo mileageInfo = buildMileageInfo(request); - - // 프로모션 적용 (기존 로직 + 마일리지 통합) - List appliedPromotions = - applyPromotions(request, finalAmount); - - // 계산 결과 저장 (마일리지 정보 포함) - // 예약 ID 처리 - Optional이므로 null 체크 - String reservationIdStr = null; - if (request.getReservationId() != null) { - reservationIdStr = String.valueOf(request.getReservationId()); - log.info("🔍 PaymentCalculation 생성 - reservationId 사용: {} (원본: {}), externalOrderId: {}", - reservationIdStr, request.getReservationId(), request.getExternalOrderId()); - } else { - log.info("🔍 PaymentCalculation 생성 - reservationId 없음, 열차 정보 직접 사용. externalOrderId: {}", - request.getExternalOrderId()); - } - - // PG 주문번호 생성 (고유성 보장) - String pgOrderId = generatePgOrderId(request.getExternalOrderId()); - - PaymentCalculation calculation = PaymentCalculation.builder() - .id(calculationId) - .reservationId(reservationIdStr) - .externalOrderId(request.getExternalOrderId()) - .userIdExternal(request.getUserId()) - .originalAmount(request.getOriginalAmount()) - .finalAmount(finalAmount) - .mileageToUse(request.getMileageToUse()) - .availableMileage(request.getAvailableMileage()) - .mileageDiscount(mileageService.convertMileageToWon(request.getMileageToUse())) - .promotionSnapshot(serializePromotions(appliedPromotions)) - .status(CalculationStatus.CALCULATED) - .expiresAt(LocalDateTime.now().plusMinutes(30)) // 30분 후 만료 - // 열차 정보 추가 (예약 삭제 시에도 결제 가능하도록) - .trainScheduleId(request.getTrainScheduleId()) - .trainDepartureTime(request.getTrainDepartureTime()) - .trainArrivalTime(request.getTrainArrivalTime()) - // .trainOperator(request.getTrainOperator()) // 제거됨 - .routeInfo(request.getRouteInfo()) - // 보안 강화 필드 추가 - .seatNumber(request.getSeatNumber()) - .pgOrderId(pgOrderId) - .createdByIp(request.getClientIp()) - .userAgent(request.getUserAgent()) - .build(); - - calculationRepository.save(calculation); - - // 이벤트 발행 - eventPublisher.publishCalculationEvent(calculationId, request.getExternalOrderId(), request.getUserId()); - - log.debug("결제 계산 완료 - 계산ID: {}, 원본금액: {}, 최종금액: {}, 마일리지할인: {}", - calculationId, request.getOriginalAmount(), finalAmount, mileageInfo.getMileageDiscount()); - - // 응답 생성 - return PaymentCalculationResponse.builder() - .calculationId(calculationId) - .reservationId(String.valueOf(request.getReservationId())) - .externalOrderId(request.getExternalOrderId()) - .originalAmount(request.getOriginalAmount()) - .finalPayableAmount(finalAmount) - .expiresAt(calculation.getExpiresAt()) - .pgOrderId(pgOrderId) // PG 주문번호 추가 - .mileageInfo(mileageInfo) - .appliedPromotions(appliedPromotions) - .validationErrors(Collections.emptyList()) - .build(); - } - - /** - * 계산 세션 조회 - */ - public PaymentCalculationResponse getCalculation(String calculationId) { - PaymentCalculation calculation = calculationRepository.findById(calculationId) - .orElseThrow(() -> new PaymentValidationException("계산 세션을 찾을 수 없습니다")); - - // 만료된 세션은 상태를 업데이트하고 예외 발생 - if (calculation.isExpired()) { - calculation.markAsExpired(); - calculationRepository.save(calculation); - throw new PaymentValidationException("계산 세션이 만료되었습니다"); - } - - List promotions = - deserializePromotions(calculation.getPromotionSnapshot()); - - // 저장된 마일리지 정보로 MileageInfo 재구성 - PaymentCalculationResponse.MileageInfo mileageInfo = PaymentCalculationResponse.MileageInfo.builder() - .usedMileage(calculation.getMileageToUse()) - .mileageDiscount(calculation.getMileageDiscount()) - .availableMileage(calculation.getAvailableMileage()) - .maxUsableMileage(mileageService.calculateMaxUsableAmount(calculation.getOriginalAmount())) - .recommendedMileage(mileageService.calculateRecommendedUsage(calculation.getAvailableMileage(), calculation.getOriginalAmount())) - .expectedEarning(mileageService.calculateEarningAmount(calculation.getFinalAmount())) - .usageRate(mileageService.calculateUsageRate(calculation.getMileageToUse(), calculation.getOriginalAmount())) - .usageRateDisplay(String.format("%.1f%%", mileageService.calculateUsageRate(calculation.getMileageToUse(), calculation.getOriginalAmount()).multiply(new BigDecimal("100")))) - .build(); - - return PaymentCalculationResponse.builder() - .calculationId(calculation.getId()) - .reservationId(calculation.getReservationId()) - .externalOrderId(calculation.getExternalOrderId()) - .originalAmount(calculation.getOriginalAmount()) - .finalPayableAmount(calculation.getFinalAmount()) - .expiresAt(calculation.getExpiresAt()) - .mileageInfo(mileageInfo) - .appliedPromotions(promotions) - .validationErrors(Collections.emptyList()) - .build(); - } - - /** - * 마일리지 정보 생성 - */ - private PaymentCalculationResponse.MileageInfo buildMileageInfo(PaymentCalculationRequest request) { - BigDecimal usedMileage = request.getMileageToUse(); - BigDecimal availableMileage = request.getAvailableMileage(); - BigDecimal originalAmount = request.getOriginalAmount(); - - BigDecimal mileageDiscount = mileageService.convertMileageToWon(usedMileage); - BigDecimal maxUsableMileage = mileageService.calculateMaxUsableAmount(originalAmount); - BigDecimal recommendedMileage = mileageService.calculateRecommendedUsage(availableMileage, originalAmount); - BigDecimal finalAmount = mileageService.calculateFinalAmount(originalAmount, usedMileage); - BigDecimal expectedEarning = mileageService.calculateEarningAmount(finalAmount); - BigDecimal usageRate = mileageService.calculateUsageRate(usedMileage, originalAmount); - String usageRateDisplay = String.format("%.1f%%", usageRate.multiply(new BigDecimal("100"))); - - return PaymentCalculationResponse.MileageInfo.builder() - .usedMileage(usedMileage) - .mileageDiscount(mileageDiscount) - .availableMileage(availableMileage) - .maxUsableMileage(maxUsableMileage) - .recommendedMileage(recommendedMileage) - .expectedEarning(expectedEarning) - .usageRate(usageRate) - .usageRateDisplay(usageRateDisplay) - .build(); - } - - /** - * 프로모션 적용 (마일리지 통합) - */ - private List applyPromotions( - PaymentCalculationRequest request, BigDecimal finalAmount) { - - List applied = new ArrayList<>(); - - // 마일리지 사용이 있는 경우 프로모션에 추가 - if (request.getMileageToUse() != null && request.getMileageToUse().compareTo(BigDecimal.ZERO) > 0) { - BigDecimal mileageDiscount = mileageService.convertMileageToWon(request.getMileageToUse()); - - applied.add(PaymentCalculationResponse.AppliedPromotion.builder() - .type("MILEAGE") - .identifier("MILEAGE_USAGE") - .description(String.format("마일리지 %s포인트 사용", request.getMileageToUse())) - .pointsUsed(request.getMileageToUse()) - .amountDeducted(mileageDiscount) - .status("APPLIED") - .build()); - } - - // 기존 프로모션 로직 - if (request.getRequestedPromotions() != null) { - for (PaymentCalculationRequest.PromotionRequest promotionRequest : request.getRequestedPromotions()) { - if ("COUPON".equals(promotionRequest.getType())) { - // 쿠폰 적용 로직 (향후 구현) - applied.add(PaymentCalculationResponse.AppliedPromotion.builder() - .type("COUPON") - .identifier(promotionRequest.getIdentifier()) - .description("쿠폰 할인") - .status("PENDING") - .build()); - } - } - } - - return applied; - } - - /** - * 프로모션 JSON 직렬화 - */ - private String serializePromotions(List promotions) { - try { - ObjectMapper mapper = new ObjectMapper(); - return mapper.writeValueAsString(promotions); - } catch (Exception e) { - log.error("프로모션 직렬화 실패", e); - return "[]"; - } - } - - /** - * 프로모션 JSON 역직렬화 - */ - private List deserializePromotions(String json) { - try { - ObjectMapper mapper = new ObjectMapper(); - return mapper.readValue(json, - new TypeReference>() {}); - } catch (Exception e) { - log.error("프로모션 역직렬화 실패", e); - return Collections.emptyList(); - } - } - - /** - * PG 주문번호 생성 - * 형식: PG-{timestamp}-{random} - */ - private String generatePgOrderId(String externalOrderId) { - String timestamp = String.valueOf(System.currentTimeMillis()); - String random = String.format("%04d", new Random().nextInt(10000)); - return String.format("PG-%s-%s", timestamp, random); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PaymentConfirmationService.java b/src/main/java/com/sudo/railo/payment/application/service/PaymentConfirmationService.java deleted file mode 100644 index 5a77712d..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PaymentConfirmationService.java +++ /dev/null @@ -1,327 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentCalculation; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.payment.domain.entity.CalculationStatus; -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.domain.entity.MemberType; -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import com.sudo.railo.payment.domain.repository.PaymentCalculationRepository; -import com.sudo.railo.payment.domain.repository.PaymentRepository; -import com.sudo.railo.payment.domain.repository.MileageEarningScheduleRepository; -import com.sudo.railo.payment.exception.PaymentValidationException; -import com.sudo.railo.payment.exception.DuplicatePgAuthException; -import com.sudo.railo.payment.infrastructure.client.PgApiClient; -import com.sudo.railo.payment.infrastructure.client.dto.PgVerificationResult; -import com.sudo.railo.payment.interfaces.dto.request.PaymentConfirmRequest; -import com.sudo.railo.payment.interfaces.dto.response.PaymentResponse; -import com.sudo.railo.payment.application.event.PaymentEventPublisher; -import com.sudo.railo.payment.domain.service.MileageExecutionService; -import com.sudo.railo.payment.application.dto.PaymentResult.MileageExecutionResult; -import com.sudo.railo.member.infra.MemberRepository; -import com.sudo.railo.member.domain.Member; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -/** - * 결제 확인 서비스 - * PG 결제 후 최종 확인 및 검증 처리 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class PaymentConfirmationService { - - private final PaymentCalculationRepository calculationRepository; - private final PaymentRepository paymentRepository; - private final MileageEarningScheduleRepository mileageEarningScheduleRepository; - private final PgApiClient pgApiClient; - private final PasswordEncoder passwordEncoder; - private final PaymentEventPublisher paymentEventPublisher; - private final MileageExecutionService mileageExecutionService; - private final MemberRepository memberRepository; - - /** - * PG 결제 확인 및 최종 처리 - * - * @param request 결제 확인 요청 (calculationId + pgAuthNumber) - * @return 결제 응답 - */ - @Transactional - public PaymentResponse confirmPayment(PaymentConfirmRequest request) { - log.info("결제 확인 시작: calculationId={}, pgAuthNumber={}", - request.getCalculationId(), request.getPgAuthNumber()); - - // 1. 계산 세션 조회 및 검증 - PaymentCalculation calculation = getAndValidateCalculation(request.getCalculationId()); - - // 2. PG 승인번호 중복 확인 - validatePgAuthNumber(request.getPgAuthNumber()); - - // 3. PG사 API로 직접 검증 - PgVerificationResult pgResult = verifyWithPg( - request.getPgAuthNumber(), - calculation.getPgOrderId() - ); - - // 4. 금액 일치 확인 - validateAmount(calculation.getFinalAmount(), pgResult.getAmount(), - calculation.getId(), calculation.getPgOrderId()); - - // 5. 결제 생성 및 완료 처리 - Payment payment = createPayment(calculation, request, pgResult); - - // 6. 계산 세션 사용 처리 - markCalculationAsUsed(calculation); - - // 7. 마일리지 차감 (있는 경우) - processMileageUsage(calculation); - - // 8. 마일리지 적립 스케줄은 이벤트 리스너에서 처리 (중복 방지) - // createMileageEarningSchedule(payment, calculation); - - log.info("결제 확인 완료: paymentId={}", payment.getId()); - - return PaymentResponse.builder() - .paymentId(payment.getId()) - .status("SUCCESS") - .amount(payment.getAmountPaid()) - .paymentMethod(payment.getPaymentMethod().name()) - .completedAt(payment.getUpdatedAt()) - .pgTransactionId(payment.getPgTransactionId()) - .pgApprovalNumber(payment.getPgApprovalNo()) - .build(); - } - - /** - * 계산 세션 조회 및 검증 - */ - private PaymentCalculation getAndValidateCalculation(String calculationId) { - PaymentCalculation calculation = calculationRepository.findById(calculationId) - .orElseThrow(() -> new PaymentValidationException("유효하지 않은 계산 세션입니다")); - - // 상태 확인 - if (calculation.getStatus() != CalculationStatus.CALCULATED) { - if (calculation.getStatus() == CalculationStatus.USED || - calculation.getStatus() == CalculationStatus.CONSUMED) { - throw new PaymentValidationException("이미 사용된 계산 세션입니다"); - } - throw new PaymentValidationException("유효하지 않은 계산 세션 상태입니다"); - } - - // 만료 확인 - if (calculation.isExpired()) { - calculation.markAsExpired(); - calculationRepository.save(calculation); - throw new PaymentValidationException("계산 세션이 만료되었습니다"); - } - - return calculation; - } - - /** - * PG 승인번호 중복 확인 - */ - private void validatePgAuthNumber(String pgAuthNumber) { - // Repository에 existsByPgApprovalNo 메서드가 없으므로 findByPgApprovalNo로 대체 - if (paymentRepository.findByPgApprovalNo(pgAuthNumber).isPresent()) { - log.error("PG 승인번호 중복 사용 시도: {}", pgAuthNumber); - throw new DuplicatePgAuthException("이미 사용된 승인번호입니다"); - } - } - - /** - * PG사 API로 검증 - */ - private PgVerificationResult verifyWithPg(String authNumber, String pgOrderId) { - try { - PgVerificationResult result = pgApiClient.verifyPayment(authNumber, pgOrderId); - - if (!result.isSuccess()) { - throw new PaymentValidationException("PG 승인 검증 실패: " + result.getMessage()); - } - - return result; - } catch (Exception e) { - log.error("PG 검증 중 오류 발생", e); - throw new PaymentValidationException("PG 검증 처리 중 오류가 발생했습니다", e); - } - } - - /** - * 금액 일치 확인 - */ - private void validateAmount(java.math.BigDecimal calculatedAmount, java.math.BigDecimal pgAmount, - String calculationId, String pgOrderId) { - if (calculatedAmount.compareTo(pgAmount) != 0) { - log.error("결제 금액 불일치: calculated={}, pg={}", calculatedAmount, pgAmount); - - // 금액 불일치 알림 이벤트 발행 - paymentEventPublisher.publishAmountMismatchAlert( - calculationId, - calculatedAmount, - pgAmount, - pgOrderId - ); - - throw new PaymentValidationException("결제 금액이 일치하지 않습니다"); - } - } - - /** - * 결제 생성 - */ - private Payment createPayment(PaymentCalculation calculation, PaymentConfirmRequest request, - PgVerificationResult pgResult) { - - // Payment 엔티티 빌더 사용 - Payment.PaymentBuilder paymentBuilder = Payment.builder() - .reservationId(calculation.getReservationId() != null ? - Long.parseLong(calculation.getReservationId()) : null) - .externalOrderId(calculation.getExternalOrderId()) - .paymentMethod(PaymentMethod.valueOf(request.getPaymentMethod())) - .amountOriginalTotal(calculation.getOriginalAmount()) - .amountPaid(calculation.getFinalAmount()) - .paymentStatus(PaymentExecutionStatus.SUCCESS) - .pgTransactionId(pgResult.getAuthNumber()) - .pgApprovalNo(pgResult.getAuthNumber()) - .paidAt(LocalDateTime.now()) - .idempotencyKey(calculation.getId()); // 계산 ID를 멱등성 키로 사용 - - // 회원 설정 (회원인 경우) - if (determineMemberType(calculation.getUserIdExternal()) == MemberType.MEMBER) { - Member member = memberRepository.findByMemberNo(calculation.getUserIdExternal()) - .orElseThrow(() -> new PaymentValidationException( - String.format("회원 정보를 찾을 수 없습니다. memberNo: %s", calculation.getUserIdExternal()))); - paymentBuilder.member(member); - log.debug("회원 결제 설정 완료: memberNo={}, memberId={}", - calculation.getUserIdExternal(), member.getId()); - } - - // 비회원인 경우 추가 정보 설정 - if (determineMemberType(calculation.getUserIdExternal()) == MemberType.NON_MEMBER) { - paymentBuilder - .nonMemberName(request.getNonMemberName()) - .nonMemberPhone(request.getNonMemberPhone()) - .nonMemberPassword(request.getNonMemberPassword() != null ? - passwordEncoder.encode(request.getNonMemberPassword()) : null); - } - - // 열차 정보 설정 - paymentBuilder - .trainScheduleId(calculation.getTrainScheduleId()) - .trainDepartureTime(calculation.getTrainDepartureTime()) - .trainArrivalTime(calculation.getTrainArrivalTime()) - // .trainOperator(calculation.getTrainOperator()); // 제거됨 - ; - - // 마일리지 정보 설정 - if (calculation.getMileageToUse() != null && - calculation.getMileageToUse().compareTo(java.math.BigDecimal.ZERO) > 0) { - paymentBuilder - .mileagePointsUsed(calculation.getMileageToUse()) - .mileageAmountDeducted(calculation.getMileageDiscount()); - } - - Payment payment = paymentBuilder.build(); - return paymentRepository.save(payment); - } - - /** - * 계산 세션을 사용됨으로 표시 - */ - private void markCalculationAsUsed(PaymentCalculation calculation) { - calculation.markAsUsed(); - calculationRepository.save(calculation); - } - - /** - * 마일리지 차감 처리 - */ - private void processMileageUsage(PaymentCalculation calculation) { - if (calculation.getMileageToUse() != null && - calculation.getMileageToUse().compareTo(java.math.BigDecimal.ZERO) > 0) { - - // 회원 여부 확인 - if (determineMemberType(calculation.getUserIdExternal()) != MemberType.MEMBER) { - log.info("비회원 결제로 마일리지 차감 건너뛰기: userId={}", calculation.getUserIdExternal()); - return; - } - - // memberNo로 회원 조회 - Member member = memberRepository.findByMemberNo(calculation.getUserIdExternal()) - .orElseThrow(() -> new PaymentValidationException( - String.format("회원 정보를 찾을 수 없습니다. memberNo: %s", calculation.getUserIdExternal()))); - - // 마일리지 차감은 Payment 객체 생성 후 PaymentExecutionService에서 처리됨 - // 여기서는 계산 세션에 마일리지 사용 정보만 기록 - log.info("마일리지 차감 예정: memberNo={}, memberId={}, amount={}", - calculation.getUserIdExternal(), member.getId(), calculation.getMileageToUse()); - - // 실제 차감은 PaymentConfirmRequest를 통해 Payment 생성 후 - // PaymentExecutionService.execute()에서 mileageExecutionService.executeUsage(payment) 호출로 처리 - } - } - - /** - * 회원 타입 판단 - */ - private MemberType determineMemberType(String userId) { - return "guest_user".equals(userId) ? MemberType.NON_MEMBER : MemberType.MEMBER; - } - - /** - * 마일리지 적립 스케줄 생성 - * 열차 도착 시점에 마일리지가 적립되도록 스케줄 생성 - * @deprecated 이벤트 리스너에서 처리하도록 변경 (중복 방지) - */ - @Deprecated - private void createMileageEarningSchedule(Payment payment, PaymentCalculation calculation) { - log.info("마일리지 적립 스케줄 생성 시작 - paymentId: {}, memberId: {}", - payment.getId(), payment.getMemberId()); - - // 회원 결제인 경우에만 마일리지 적립 - if (payment.getMemberId() == null) { - log.info("비회원 결제로 마일리지 적립 스케줄 생성 건너뛰기 - paymentId: {}", payment.getId()); - return; - } - - // 열차 정보가 없으면 스케줄 생성 불가 - if (calculation.getTrainArrivalTime() == null || calculation.getTrainScheduleId() == null) { - log.warn("열차 정보 부족으로 마일리지 적립 스케줄 생성 불가 - paymentId: {}, trainScheduleId: {}, trainArrivalTime: {}", - payment.getId(), calculation.getTrainScheduleId(), calculation.getTrainArrivalTime()); - return; - } - - try { - // 노선 정보 생성 (기본값 사용) - // TODO: PaymentCalculation에 출발역/도착역 정보 추가 필요 - String routeInfo = "서울-부산"; // 임시로 고정값 사용 - - // 열차 도착 시점에 적립되도록 스케줄 생성 - MileageEarningSchedule schedule = MileageEarningSchedule.createNormalEarningSchedule( - calculation.getTrainScheduleId(), - payment.getId().toString(), - payment.getMemberId(), - payment.getAmountPaid(), - calculation.getTrainArrivalTime(), // 도착 시점에 적립 - routeInfo - ); - - mileageEarningScheduleRepository.save(schedule); - - log.info("마일리지 적립 스케줄 생성 완료 - scheduleId: {}, paymentId: {}, memberId: {}, scheduledTime: {}", - schedule.getId(), payment.getId(), payment.getMemberId(), calculation.getTrainArrivalTime()); - - } catch (Exception e) { - // 마일리지 적립 스케줄 생성 실패가 결제를 실패시키지 않도록 함 - log.error("마일리지 적립 스케줄 생성 실패 - paymentId: {}", payment.getId(), e); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PaymentCreationService.java b/src/main/java/com/sudo/railo/payment/application/service/PaymentCreationService.java deleted file mode 100644 index cdff0355..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PaymentCreationService.java +++ /dev/null @@ -1,298 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.context.PaymentContext; -import com.sudo.railo.payment.application.dto.request.PaymentExecuteRequest; -import com.sudo.railo.payment.application.dto.response.PaymentCalculationResponse; -import com.sudo.railo.payment.application.port.out.LoadPaymentPort; -import com.sudo.railo.payment.application.port.out.SavePaymentPort; -import com.sudo.railo.payment.application.port.out.LoadMemberPort; -import com.sudo.railo.payment.domain.entity.CashReceipt; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.domain.entity.PaymentCalculation; -import com.sudo.railo.booking.infra.ReservationRepository; -import com.sudo.railo.train.application.TrainScheduleService; -import com.sudo.railo.booking.domain.Reservation; -// import com.sudo.railo.train.domain.type.TrainOperator; // 제거됨 -import com.sudo.railo.payment.domain.repository.PaymentCalculationRepository; -import org.springframework.security.crypto.password.PasswordEncoder; -import com.sudo.railo.payment.exception.PaymentValidationException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; - -/** - * 결제 생성 전용 서비스 - * - * PaymentContext를 기반으로 Payment 엔티티를 생성하고 저장 - * 중복 결제 방지 및 비회원 정보 처리 포함 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class PaymentCreationService { - - private final LoadPaymentPort loadPaymentPort; - private final SavePaymentPort savePaymentPort; - private final LoadMemberPort loadMemberPort; - private final PasswordEncoder passwordEncoder; - - // 열차 정보를 위한 의존성 추가 - private final ReservationRepository reservationRepository; - private final TrainScheduleService trainScheduleService; - private final PaymentCalculationRepository calculationRepository; - - /** - * Payment 엔티티 생성 및 저장 - * - * @param context 검증된 결제 컨텍스트 - * @return 저장된 Payment 엔티티 - */ - @Transactional - public Payment createPayment(PaymentContext context) { - log.info("결제 생성 시작 - idempotencyKey: {}", context.getIdempotencyKey()); - - // 1. 중복 결제 체크 (멱등성 보장) - validateIdempotency(context.getIdempotencyKey()); - - // 2. Payment 엔티티 생성 - Payment payment = buildPayment(context); - - // 3. 비회원 정보는 이미 builder에서 처리됨 - - // 4. 저장 - Payment savedPayment = savePaymentPort.save(payment); - log.info("결제 엔티티 생성 완료 - paymentId: {}", savedPayment.getId()); - - return savedPayment; - } - - /** - * 멱등성 검증 - 중복 결제 방지 - */ - private void validateIdempotency(String idempotencyKey) { - if (loadPaymentPort.existsByIdempotencyKey(idempotencyKey)) { - log.warn("중복 결제 요청 감지 - idempotencyKey: {}", idempotencyKey); - throw new PaymentValidationException("이미 처리된 결제 요청입니다"); - } - } - - /** - * Payment 엔티티 빌드 - */ - private Payment buildPayment(PaymentContext context) { - // reservationId 파싱 (문자열 형태인 경우 처리) - Long reservationId = parseReservationId(context.getCalculation().getReservationId()); - log.info("🎯 Payment 생성 - calculationId: {}, reservationId: {}, externalOrderId: {}", - context.getCalculation().getId(), reservationId, context.getCalculation().getExternalOrderId()); - - // 마일리지 정보 추출 - BigDecimal mileagePointsUsed = BigDecimal.ZERO; - BigDecimal mileageAmountDeducted = BigDecimal.ZERO; - if (context.hasMileageUsage()) { - mileagePointsUsed = context.getMileageResult().getUsageAmount(); - mileageAmountDeducted = context.getMileageResult().getUsageAmount(); // 1포인트 = 1원 고정 - } - - // 최종 결제 금액 (이미 마일리지가 차감된 금액) - BigDecimal finalPayableAmount = context.getFinalPayableAmount(); - - // 마일리지 적립 예정 금액 계산 (회원인 경우만) - BigDecimal mileageToEarn = BigDecimal.ZERO; - if (context.isForMember()) { - // 1% 적립 (추후 정책에 따라 변경 가능) - mileageToEarn = finalPayableAmount.multiply(BigDecimal.valueOf(0.01)) - .setScale(0, java.math.RoundingMode.DOWN); - } - - // 열차 정보 조회 (예약이 삭제되어도 환불 가능하도록) - Long trainScheduleId = null; - java.time.LocalDateTime trainDepartureTime = null; - java.time.LocalDateTime trainArrivalTime = null; - // TrainOperator 제거됨 - 환불 정책은 내부 로직으로 처리 - - // 1차: 예약에서 열차 정보 조회 시도 (reservationId가 있는 경우만) - if (reservationId != null) { - try { - Reservation reservation = reservationRepository.findById(reservationId) - .orElse(null); - if (reservation != null && reservation.getTrainSchedule() != null) { - trainScheduleId = reservation.getTrainSchedule().getId(); - - // TrainScheduleService에서 시간 정보 가져오기 - TrainScheduleService.TrainTimeInfo timeInfo = - trainScheduleService.getTrainTimeInfo(trainScheduleId); - if (timeInfo != null) { - trainDepartureTime = timeInfo.departureTime(); - trainArrivalTime = timeInfo.actualArrivalTime() != null - ? timeInfo.actualArrivalTime() - : timeInfo.scheduledArrivalTime(); - } - - // TrainOperator 제거됨 - 운영사 정보는 열차명으로 직접 판단 - } - } catch (Exception e) { - log.warn("예약에서 열차 정보 조회 실패 - reservationId: {}, error: {}", - reservationId, e.getMessage()); - } - } - - // 2차: 예약 조회 실패 시 PaymentCalculation에서 열차 정보 가져오기 - if (trainScheduleId == null && context.getCalculation() != null) { - PaymentCalculationResponse calculation = context.getCalculation(); - try { - PaymentCalculation calcEntity = - calculationRepository.findById(calculation.getId()).orElse(null); - if (calcEntity != null) { - trainScheduleId = calcEntity.getTrainScheduleId(); - trainDepartureTime = calcEntity.getTrainDepartureTime(); - trainArrivalTime = calcEntity.getTrainArrivalTime(); - // trainOperator = calcEntity.getTrainOperator(); // 제거됨 - log.info("예약 조회 실패, PaymentCalculation에서 열차 정보 사용 - trainScheduleId: {}", - trainScheduleId); - } - } catch (Exception e) { - log.warn("PaymentCalculation에서 열차 정보 조회 실패 - calculationId: {}, error: {}", - calculation.getId(), e.getMessage()); - } - } - - Payment.PaymentBuilder builder = Payment.builder() - .reservationId(reservationId) - .externalOrderId(context.getCalculation().getExternalOrderId()) - .amountOriginalTotal(context.getCalculation().getOriginalAmount()) - .totalDiscountAmountApplied(context.getCalculation().getTotalDiscountAmount()) - .mileagePointsUsed(mileagePointsUsed) - .mileageAmountDeducted(mileageAmountDeducted) - .amountPaid(finalPayableAmount) - .mileageToEarn(mileageToEarn) - .paymentMethod(PaymentMethod.valueOf( - context.getRequest().getPaymentMethod().getType())) - .pgProvider(context.getRequest().getPaymentMethod().getPgProvider()) - .paymentStatus(PaymentExecutionStatus.PENDING) - .idempotencyKey(context.getIdempotencyKey()) - .trainScheduleId(trainScheduleId) - .trainDepartureTime(trainDepartureTime) - .trainArrivalTime(trainArrivalTime) - // .trainOperator(trainOperator) // 제거됨 - ; - - // 회원/비회원별 추가 정보 설정 - if (context.isForMember()) { - // PaymentContext에서 회원 ID 가져오기 - Long memberId = context.getMemberId(); - if (memberId == null) { - throw new PaymentValidationException("회원 결제에 회원 ID가 없습니다"); - } - - // 회원 엔티티 조회 - com.sudo.railo.member.domain.Member member = loadMemberPort.findById(memberId) - .orElseThrow(() -> new PaymentValidationException("회원 정보를 찾을 수 없습니다: " + memberId)); - - // Payment 엔티티에 Member 설정 - builder.member(member); - - log.debug("회원 결제 설정 - memberId: {}", memberId); - } else { - // 비회원 정보 검증 및 암호화 - validateNonMemberInfo(context.getRequest()); - String encodedPassword = passwordEncoder.encode(context.getRequest().getNonMemberPassword()); - - builder.nonMemberName(context.getRequest().getNonMemberName().trim()) - .nonMemberPhone(normalizePhoneNumber(context.getRequest().getNonMemberPhone())) - .nonMemberPassword(encodedPassword); - log.debug("비회원 결제 설정 - name: {}", context.getRequest().getNonMemberName()); - } - - // 현금영수증 정보 설정 - CashReceipt cashReceipt = buildCashReceipt(context); - if (cashReceipt != null) { - builder.cashReceipt(cashReceipt); - } - - return builder.build(); - } - - /** - * 예약 ID 파싱 - Optional 처리 - */ - private Long parseReservationId(String reservationIdStr) { - log.info("🔍 예약 ID 파싱 시도 - 입력값: '{}', null여부: {}, 'null'문자열여부: {}", - reservationIdStr, - reservationIdStr == null, - "null".equals(reservationIdStr)); - - // null 또는 "null" 문자열 체크 - 이제 null 허용 - if (reservationIdStr == null || "null".equals(reservationIdStr) || reservationIdStr.trim().isEmpty()) { - log.info("⚠️ 예약 ID가 없습니다. 열차 정보는 PaymentCalculation에서 가져옵니다."); - return null; // null 반환 허용 - } - - try { - // 'R' 접두사 제거 (있는 경우) - String cleanId = reservationIdStr.startsWith("R") ? - reservationIdStr.substring(1) : reservationIdStr; - - Long reservationId = Long.parseLong(cleanId); - log.info("✅ 예약 ID 파싱 성공 - 원본: '{}', 파싱결과: {}", reservationIdStr, reservationId); - return reservationId; - } catch (NumberFormatException e) { - log.error("❌ 예약 ID 파싱 실패 - 입력값: '{}', 오류: {}", reservationIdStr, e.getMessage()); - throw new PaymentValidationException( - "잘못된 예약 ID 형식입니다: " + reservationIdStr); - } - } - - /** - * 현금영수증 정보 생성 - */ - private CashReceipt buildCashReceipt(PaymentContext context) { - var cashReceiptInfo = context.getRequest().getCashReceiptInfo(); - - if (cashReceiptInfo == null || !cashReceiptInfo.isRequested()) { - return CashReceipt.notRequested(); - } - - if ("personal".equals(cashReceiptInfo.getType())) { - return CashReceipt.createPersonalReceipt(cashReceiptInfo.getPhoneNumber()); - } else if ("business".equals(cashReceiptInfo.getType())) { - return CashReceipt.createBusinessReceipt(cashReceiptInfo.getBusinessNumber()); - } - - return CashReceipt.notRequested(); - } - - /** - * 비회원 정보 검증 - */ - private void validateNonMemberInfo(PaymentExecuteRequest request) { - if (request.getNonMemberName() == null || request.getNonMemberName().trim().isEmpty()) { - throw new PaymentValidationException("비회원 이름은 필수입니다"); - } - - if (request.getNonMemberPhone() == null || request.getNonMemberPhone().trim().isEmpty()) { - throw new PaymentValidationException("비회원 전화번호는 필수입니다"); - } - - if (request.getNonMemberPassword() == null || !request.getNonMemberPassword().matches("^[0-9]{5}$")) { - throw new PaymentValidationException("비회원 비밀번호는 5자리 숫자여야 합니다"); - } - - // 전화번호 형식 검증 - String cleanedPhone = request.getNonMemberPhone().replaceAll("[^0-9]", ""); - if (!cleanedPhone.matches("^01[016789]\\d{7,8}$")) { - throw new PaymentValidationException("올바른 전화번호 형식이 아닙니다"); - } - } - - /** - * 전화번호 정규화 - */ - private String normalizePhoneNumber(String phoneNumber) { - return phoneNumber.replaceAll("[^0-9]", ""); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PaymentDataMigrationService.java b/src/main/java/com/sudo/railo/payment/application/service/PaymentDataMigrationService.java deleted file mode 100644 index aaf9b49a..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PaymentDataMigrationService.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.repository.PaymentRepository; -import com.sudo.railo.booking.domain.Reservation; -import com.sudo.railo.booking.infra.ReservationRepository; -import com.sudo.railo.train.domain.ScheduleStop; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.lang.reflect.Field; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 기존 결제 데이터에 열차 정보를 추가하는 마이그레이션 서비스 - * - * 수동으로 실행하거나 ApplicationRunner로 실행 가능 - */ -@Service -@RequiredArgsConstructor -@Slf4j -@Transactional -public class PaymentDataMigrationService { - - private final PaymentRepository paymentRepository; - private final ReservationRepository reservationRepository; - - /** - * 열차 정보가 없는 결제 데이터를 마이그레이션 - */ - public void migratePaymentTrainInfo() { - log.info("결제 데이터 마이그레이션 시작"); - - // 1. 열차 정보가 없는 결제 조회 - List paymentsWithoutTrainInfo = paymentRepository.findAll().stream() - .filter(payment -> payment.getTrainScheduleId() == null || payment.getTrainArrivalTime() == null) - .filter(payment -> payment.getReservationId() != null) - .toList(); - - log.info("마이그레이션 대상 결제 수: {}", paymentsWithoutTrainInfo.size()); - - int migratedCount = 0; - int failedCount = 0; - - for (Payment payment : paymentsWithoutTrainInfo) { - try { - // 2. 예약 정보 조회 (삭제된 것도 포함) - Reservation reservation = reservationRepository.findById(payment.getReservationId()) - .orElse(null); - - if (reservation == null || reservation.getTrainSchedule() == null) { - log.warn("예약 정보를 찾을 수 없음 - paymentId: {}, reservationId: {}", - payment.getId(), payment.getReservationId()); - failedCount++; - continue; - } - - // 3. 열차 정보 업데이트 (리플렉션 사용) - boolean updated = false; - - if (payment.getTrainScheduleId() == null) { - updateTrainScheduleId(payment, reservation.getTrainSchedule().getId()); - updated = true; - } - - // 4. 도착 시간 계산 및 설정 - if (payment.getTrainArrivalTime() == null) { - LocalDateTime arrivalTime = calculateArrivalTime(reservation); - if (arrivalTime != null) { - updateTrainArrivalTime(payment, arrivalTime); - updated = true; - } - } - - if (!updated) { - continue; - } - - // 5. 저장 - paymentRepository.save(payment); - migratedCount++; - - log.debug("결제 데이터 마이그레이션 완료 - paymentId: {}, trainScheduleId: {}, arrivalTime: {}", - payment.getId(), payment.getTrainScheduleId(), payment.getTrainArrivalTime()); - - } catch (Exception e) { - log.error("결제 데이터 마이그레이션 실패 - paymentId: {}", payment.getId(), e); - failedCount++; - } - } - - log.info("결제 데이터 마이그레이션 완료 - 성공: {}, 실패: {}", migratedCount, failedCount); - } - - /** - * 예약 정보로부터 도착 시간 계산 - */ - private LocalDateTime calculateArrivalTime(Reservation reservation) { - if (reservation.getTrainSchedule() == null || reservation.getArrivalStation() == null) { - return null; - } - - // Schedule stops에서 도착역 찾기 - for (ScheduleStop stop : reservation.getTrainSchedule().getScheduleStops()) { - if (stop.getStation().getId().equals(reservation.getArrivalStation().getId())) { - return reservation.getTrainSchedule().getOperationDate() - .atTime(stop.getArrivalTime()); - } - } - - return null; - } - - /** - * 마이그레이션 상태 확인 - */ - public void checkMigrationStatus() { - List allPayments = paymentRepository.findAll(); - long totalPayments = allPayments.size(); - long paymentsWithTrainInfo = allPayments.stream() - .filter(payment -> payment.getTrainScheduleId() != null && payment.getTrainArrivalTime() != null) - .count(); - long paymentsMissingInfo = totalPayments - paymentsWithTrainInfo; - - log.info("마이그레이션 상태 - 전체: {}, 완료: {}, 미완료: {}", - totalPayments, paymentsWithTrainInfo, paymentsMissingInfo); - } - - /** - * 리플렉션을 사용하여 trainScheduleId 업데이트 - */ - private void updateTrainScheduleId(Payment payment, Long trainScheduleId) { - try { - Field field = Payment.class.getDeclaredField("trainScheduleId"); - field.setAccessible(true); - field.set(payment, trainScheduleId); - } catch (Exception e) { - throw new RuntimeException("trainScheduleId 설정 실패", e); - } - } - - /** - * 리플렉션을 사용하여 trainArrivalTime 업데이트 - */ - private void updateTrainArrivalTime(Payment payment, LocalDateTime arrivalTime) { - try { - Field field = Payment.class.getDeclaredField("trainArrivalTime"); - field.setAccessible(true); - field.set(payment, arrivalTime); - } catch (Exception e) { - throw new RuntimeException("trainArrivalTime 설정 실패", e); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PaymentExecutionService.java b/src/main/java/com/sudo/railo/payment/application/service/PaymentExecutionService.java deleted file mode 100644 index 2131f555..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PaymentExecutionService.java +++ /dev/null @@ -1,239 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.context.PaymentContext; -import com.sudo.railo.payment.application.dto.PaymentResult; -import com.sudo.railo.payment.application.dto.PaymentResult.MileageExecutionResult; -import com.sudo.railo.payment.application.dto.PaymentResult.PgPaymentResult; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.payment.domain.entity.MemberType; -import com.sudo.railo.payment.domain.repository.PaymentRepository; -import com.sudo.railo.payment.domain.service.MileageExecutionService; -import com.sudo.railo.payment.exception.PaymentExecutionException; -import com.sudo.railo.payment.exception.PaymentValidationException; -import com.sudo.railo.payment.application.event.PaymentEventPublisher; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; - -import java.math.BigDecimal; - -/** - * 결제 실행 전용 서비스 - * - * Payment 엔티티의 실제 실행(마일리지 차감, PG 결제)을 담당 - * 트랜잭션을 짧게 유지하여 성능 최적화 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class PaymentExecutionService { - - private final PaymentRepository paymentRepository; - private final MileageExecutionService mileageExecutionService; - private final PgPaymentService pgPaymentService; - private final PaymentEventPublisher paymentEventPublisher; - - @PersistenceContext - private EntityManager entityManager; - - /** - * 결제 실행 - * - * @param payment 생성된 Payment 엔티티 - * @param context 결제 컨텍스트 - * @return 결제 실행 결과 - */ - @Transactional(timeout = 30) - public PaymentResult execute(Payment payment, PaymentContext context) { - log.info("결제 실행 시작 - paymentId: {}, amount: {}", - payment.getId(), payment.getAmountPaid()); - - try { - // 1. 결제 상태를 PROCESSING으로 변경 - PaymentExecutionStatus previousStatus = payment.getPaymentStatus(); - payment.updateStatus(PaymentExecutionStatus.PROCESSING, "결제 처리 시작", "SYSTEM"); - payment = paymentRepository.save(payment); - - // 이벤트 발행 (AbstractAggregateRoot 제거로 인해 직접 발행) - paymentEventPublisher.publishPaymentStateChanged( - payment, previousStatus, PaymentExecutionStatus.PROCESSING, - "결제 처리 시작", "SYSTEM" - ); - - // 2. 마일리지 차감 실행 (회원이고 마일리지 사용이 있는 경우) - MileageExecutionResult mileageResult = null; - if (context.isForMember() && context.hasMileageUsage()) { - mileageResult = executeMileageUsage(payment, context); - log.info("마일리지 차감 완료 - usedPoints: {}, remaining: {}", - mileageResult.getUsedPoints(), mileageResult.getRemainingBalance()); - } - - // 3. PG 결제 실행 - PgPaymentResult pgResult = pgPaymentService.processPayment(payment, context); - if (!pgResult.isSuccess()) { - throw new PaymentExecutionException("PG 결제 실패: " + pgResult.getPgMessage()); - } - log.info("PG 결제 완료 - pgTxId: {}", pgResult.getPgTransactionId()); - - // 4. PG 정보 업데이트 - payment.updatePgInfo(pgResult.getPgTransactionId(), pgResult.getPgApprovalNo()); - - // 5. 결제 상태를 SUCCESS로 변경 - previousStatus = payment.getPaymentStatus(); - payment.updateStatus(PaymentExecutionStatus.SUCCESS, "결제 완료", "SYSTEM"); - payment = paymentRepository.save(payment); - entityManager.flush(); // 즉시 DB에 반영 - - // 이벤트 발행 (AbstractAggregateRoot 제거로 인해 직접 발행) - log.info("🎯 [이벤트 발행 시작] paymentId: {}, reservationId: {}, {} → {}", - payment.getId(), payment.getReservationId(), previousStatus, PaymentExecutionStatus.SUCCESS); - - paymentEventPublisher.publishPaymentStateChanged( - payment, previousStatus, PaymentExecutionStatus.SUCCESS, - "결제 완료", "SYSTEM" - ); - - log.info("✅ [이벤트 발행 완료] paymentId: {}, reservationId: {}", - payment.getId(), payment.getReservationId()); - - // PaymentStateChangedEvent만 발행 - PaymentEventTranslator가 처리 - // publishPaymentCompleted 제거하여 중복 이벤트 발행 방지 - - // 5. 성공 결과 반환 - return PaymentResult.success(payment, mileageResult, pgResult); - - } catch (Exception e) { - log.error("결제 실행 실패 - paymentId: {}", payment.getId(), e); - - // 6. 실패 처리 - handlePaymentFailure(payment, context, e); - - throw new PaymentExecutionException("결제 실행 실패: " + e.getMessage(), e); - } - } - - /** - * 마일리지 차감 실행 - */ - private MileageExecutionResult executeMileageUsage(Payment payment, PaymentContext context) { - try { - // 실제 마일리지 차감 실행 - MileageExecutionResult result = mileageExecutionService.executeUsage(payment); - - if (!result.isSuccess()) { - throw new PaymentExecutionException("마일리지 차감 실패"); - } - - return result; - - } catch (Exception e) { - log.error("마일리지 차감 실패 - paymentId: {}, memberId: {}", - payment.getId(), context.getMemberId(), e); - throw new PaymentExecutionException("마일리지 차감 중 오류가 발생했습니다", e); - } - } - - /** - * 결제 실패 처리 - */ - private void handlePaymentFailure(Payment payment, PaymentContext context, Exception e) { - try { - // 1. 결제 상태를 FAILED로 변경 - PaymentExecutionStatus previousStatus = payment.getPaymentStatus(); - payment.updateStatus(PaymentExecutionStatus.FAILED, - "결제 실패: " + e.getMessage(), "SYSTEM"); - paymentRepository.save(payment); - - // 이벤트 발행 (AbstractAggregateRoot 제거로 인해 직접 발행) - paymentEventPublisher.publishPaymentStateChanged( - payment, previousStatus, PaymentExecutionStatus.FAILED, - "결제 실패: " + e.getMessage(), "SYSTEM" - ); - - // 2. 마일리지 차감이 있었다면 복구 - if (context.isForMember() && context.hasMileageUsage()) { - try { - mileageExecutionService.restoreMileageUsage( - context.getCalculation().getId(), - context.getMemberId(), - context.getMileageResult().getUsageAmount(), - String.format("결제 실패로 인한 마일리지 복구 - 결제ID: %s", payment.getId()) - ); - log.info("마일리지 복구 완료 - memberId: {}, points: {}", - context.getMemberId(), context.getMileageResult().getUsageAmount()); - } catch (Exception rollbackError) { - log.error("마일리지 복구 실패 - memberId: {}", context.getMemberId(), rollbackError); - // 마일리지 복구 실패는 별도 처리 필요 (수동 복구 등) - } - } - - } catch (Exception failureHandlingError) { - log.error("결제 실패 처리 중 오류", failureHandlingError); - } - } - - /** - * 환불 실행 (전체 환불만 지원) - */ - @Transactional - public PaymentResult executeRefund(Payment payment, BigDecimal refundAmount, String reason) { - log.info("환불 실행 시작 - paymentId: {}, refundAmount: {}", - payment.getId(), refundAmount); - - try { - // 1. 환불 가능 여부 확인 - if (!payment.isRefundable()) { - throw new PaymentValidationException("환불 불가능한 상태입니다"); - } - - // 2. PG 환불 실행 - PgPaymentResult pgResult = pgPaymentService.cancelPayment(payment, refundAmount, reason); - if (!pgResult.isSuccess()) { - throw new PaymentExecutionException("PG 환불 실패: " + pgResult.getPgMessage()); - } - - // 3. Payment 상태 업데이트 - Payment.RefundRequest refundRequest = Payment.RefundRequest.builder() - .refundAmount(refundAmount) - .refundFee(BigDecimal.ZERO) // TODO: 환불 수수료 정책 적용 - .reason(reason) - .pgTransactionId(pgResult.getPgTransactionId()) - .pgApprovalNo(pgResult.getPgApprovalNo()) - .build(); - - payment.processRefund(refundRequest); - payment = paymentRepository.save(payment); - - // 4. 마일리지 복구 (전체 환불 시 전액 복구) - if (payment.getMemberId() != null && - payment.getMileagePointsUsed() != null && - payment.getMileagePointsUsed().compareTo(BigDecimal.ZERO) > 0) { - try { - com.sudo.railo.payment.domain.entity.MileageTransaction restoration = mileageExecutionService.restoreMileageUsage( - payment.getId().toString(), - payment.getMemberId(), - payment.getMileagePointsUsed(), - String.format("환불로 인한 마일리지 복구 - 결제ID: %s", payment.getId()) - ); - log.info("마일리지 복구 완료 - memberId: {}, restoredPoints: {}, transactionId: {}", - payment.getMemberId(), payment.getMileagePointsUsed(), restoration.getId()); - } catch (Exception e) { - log.error("마일리지 복구 실패 - 고객센터 문의 필요 - memberId: {}, points: {}", - payment.getMemberId(), payment.getMileagePointsUsed(), e); - throw new PaymentExecutionException( - "마일리지 복구 중 오류가 발생했습니다. 고객센터로 문의해주세요.", e); - } - } - - return PaymentResult.success(payment, null, pgResult); - - } catch (Exception e) { - log.error("환불 실행 실패 - paymentId: {}", payment.getId(), e); - throw new PaymentExecutionException("환불 실행 실패: " + e.getMessage(), e); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PaymentHistoryService.java b/src/main/java/com/sudo/railo/payment/application/service/PaymentHistoryService.java deleted file mode 100644 index a402a982..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PaymentHistoryService.java +++ /dev/null @@ -1,424 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.dto.response.PaymentHistoryResponse; -import com.sudo.railo.payment.application.dto.response.PaymentInfoResponse; -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.payment.domain.entity.RefundCalculation; -import com.sudo.railo.payment.application.port.out.LoadMileageTransactionPort; -import com.sudo.railo.payment.application.port.out.LoadPaymentPort; -import com.sudo.railo.payment.application.port.out.LoadRefundCalculationPort; -import com.sudo.railo.payment.domain.service.NonMemberService; -import com.sudo.railo.payment.exception.PaymentValidationException; -import com.sudo.railo.payment.exception.PaymentNotFoundException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.security.core.userdetails.UserDetails; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; -import com.sudo.railo.member.infra.MemberRepository; -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.member.exception.MemberError; - -/** - * 결제 내역 조회 애플리케이션 서비스 - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -@Slf4j -public class PaymentHistoryService { - - private final LoadPaymentPort loadPaymentPort; - private final LoadMileageTransactionPort loadMileageTransactionPort; - private final LoadRefundCalculationPort loadRefundCalculationPort; - private final NonMemberService nonMemberService; - private final MemberRepository memberRepository; - - /** - * 회원 결제 내역 조회 - */ - public PaymentHistoryResponse getPaymentHistory( - UserDetails userDetails, - LocalDateTime startDate, - LocalDateTime endDate, - String paymentMethod, - Pageable pageable) { - - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.debug("회원 결제 내역 조회 - 회원ID: {}, 기간: {} ~ {}, 결제방법: {}", memberId, startDate, endDate, paymentMethod); - - // 1. DB에서 페이징된 결제 내역 조회 - Page pagedPayments; - if (startDate != null && endDate != null) { - // 기간 지정된 경우 - DB 레벨에서 필터링 + 페이징 - pagedPayments = loadPaymentPort.findByMemberIdAndCreatedAtBetweenOrderByCreatedAtDesc( - memberId, startDate, endDate, pageable); - } else { - // 전체 기간 - DB 레벨에서 페이징만 - pagedPayments = loadPaymentPort.findByMemberIdOrderByCreatedAtDesc(memberId, pageable); - } - - // 2. 결제방법 필터링 (필요한 경우) - List payments = pagedPayments.getContent(); - if (paymentMethod != null && !paymentMethod.isEmpty()) { - payments = payments.stream() - .filter(payment -> payment.getPaymentMethod().name().equals(paymentMethod)) - .collect(Collectors.toList()); - } - - // 3. 마일리지 거래 내역 조회 - List paymentIds = payments.stream() - .map(payment -> payment.getId().toString()) - .collect(Collectors.toList()); - - List mileageTransactions = - loadMileageTransactionPort.findByPaymentIds(paymentIds); - - // 3-1. 환불 정보 조회 - List paymentIdsLong = payments.stream() - .map(Payment::getId) - .collect(Collectors.toList()); - - List refundCalculations = - loadRefundCalculationPort.findByPaymentIds(paymentIdsLong); - - // 4. 응답 DTO 생성 - List historyItems = - payments.stream() - .map(payment -> { - List relatedMileageTransactions = - mileageTransactions.stream() - .filter(mt -> payment.getId().toString().equals(mt.getId())) - .collect(Collectors.toList()); - - RefundCalculation refundCalculation = refundCalculations.stream() - .filter(rc -> rc.getPaymentId().equals(payment.getId())) - .findFirst() - .orElse(null); - - return PaymentHistoryResponse.PaymentHistoryItem.from(payment, relatedMileageTransactions, refundCalculation); - }) - .collect(Collectors.toList()); - - // 5. 실제 페이징 정보로 응답 생성 - return PaymentHistoryResponse.builder() - .payments(historyItems) - .totalElements(pagedPayments.getTotalElements()) - .totalPages(pagedPayments.getTotalPages()) - .currentPage(pagedPayments.getNumber()) - .pageSize(pagedPayments.getSize()) - .hasNext(pagedPayments.hasNext()) - .hasPrevious(pagedPayments.hasPrevious()) - .build(); - } - - /** - * 비회원 결제 내역 조회 - */ - public PaymentInfoResponse getNonMemberPayment( - Long reservationId, - String name, - String phoneNumber, - String password) { - - log.debug("비회원 결제 내역 조회 - 예약번호: {}, 이름: {}", reservationId, name); - - // 1. 예약번호로 결제 정보 조회 - Payment payment = loadPaymentPort.findByReservationId(reservationId) - .orElseThrow(() -> new PaymentValidationException("해당 예약의 결제 정보를 찾을 수 없습니다")); - - // 2. 회원 결제인지 확인 - if (payment.getMemberId() != null) { - throw new PaymentValidationException("회원 결제입니다. 로그인 후 조회해주세요"); - } - - // 3. 비회원 정보 검증 - boolean isValid = nonMemberService.validateNonMemberInfo(name, phoneNumber, password, payment); - - if (!isValid) { - log.warn("비회원 정보 검증 실패 - 예약번호: {}, 요청 이름: {}", reservationId, name); - throw new PaymentValidationException("입력한 정보가 일치하지 않습니다"); - } - - // 4. 응답 생성 - return PaymentInfoResponse.builder() - .paymentId(payment.getId()) - .reservationId(payment.getReservationId()) - .externalOrderId(payment.getExternalOrderId()) - .amountOriginalTotal(payment.getAmountOriginalTotal()) - .totalDiscountAmountApplied(payment.getTotalDiscountAmountApplied()) - .mileageAmountDeducted(payment.getMileageAmountDeducted()) - .amountPaid(payment.getAmountPaid()) - .paymentStatus(payment.getPaymentStatus()) - .paymentMethod(payment.getPaymentMethod().getDisplayName()) - .pgProvider(payment.getPgProvider()) - .pgTransactionId(payment.getPgTransactionId()) - .pgApprovalNo(payment.getPgApprovalNo()) - .receiptUrl(payment.getReceiptUrl()) - .paidAt(payment.getPaidAt()) - .createdAt(payment.getCreatedAt()) - .nonMemberName(payment.getNonMemberName()) - .nonMemberPhoneMasked(maskPhoneNumber(payment.getNonMemberPhone())) - .build(); - } - - /** - * 특정 예약번호로 결제 정보 조회 (회원용) - * 본인 소유 여부 검증 포함 - */ - public PaymentInfoResponse getPaymentByReservationId(Long reservationId, UserDetails userDetails) { - - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.info("예약번호로 결제 정보 조회 시작 - 예약번호: {}, 회원ID: {}", reservationId, memberId); - - // 1. 예약번호로 결제 정보 조회 - Payment payment = loadPaymentPort.findByReservationId(reservationId) - .orElseThrow(() -> { - log.error("❌ 결제 정보를 찾을 수 없음 - reservationId: {}", reservationId); - return new PaymentValidationException("해당 예약번호의 결제 정보를 찾을 수 없습니다"); - }); - - // 2. 본인 결제인지 확인 - if (payment.getMemberId() == null || !payment.getMemberId().equals(memberId)) { - throw new PaymentValidationException("본인의 결제 내역만 조회할 수 있습니다"); - } - - // 3. 결제 상태 로그 (디버깅용) - log.info("결제 정보 조회 성공 - 예약번호: {}, 결제상태: {}, 결제일시: {}", - reservationId, payment.getPaymentStatus(), payment.getPaidAt()); - - // 4. 마일리지 거래 내역 조회 - List mileageTransactions = - loadMileageTransactionPort.findByPaymentIdOrderByCreatedAtDesc(payment.getId().toString()); - - // 5. 응답 생성 - return PaymentInfoResponse.builder() - .paymentId(payment.getId()) - .reservationId(payment.getReservationId()) - .externalOrderId(payment.getExternalOrderId()) - .amountOriginalTotal(payment.getAmountOriginalTotal()) - .totalDiscountAmountApplied(payment.getTotalDiscountAmountApplied()) - .mileagePointsUsed(payment.getMileagePointsUsed()) - .mileageAmountDeducted(payment.getMileageAmountDeducted()) - .mileageToEarn(payment.getMileageToEarn()) - .amountPaid(payment.getAmountPaid()) - .paymentStatus(payment.getPaymentStatus()) - .paymentMethod(payment.getPaymentMethod().getDisplayName()) - .pgProvider(payment.getPgProvider()) - .pgTransactionId(payment.getPgTransactionId()) - .pgApprovalNo(payment.getPgApprovalNo()) - .receiptUrl(payment.getReceiptUrl()) - .paidAt(payment.getPaidAt()) - .createdAt(payment.getCreatedAt()) - .mileageTransactions(mileageTransactions.stream() - .map(PaymentInfoResponse.MileageTransactionInfo::from) - .collect(Collectors.toList())) - .build(); - } - - /** - * 특정 예약번호로 결제 정보 조회 (비회원/회원 공용) - * 소유권 검증 없이 조회 - */ - public PaymentInfoResponse getPaymentByReservationIdPublic(Long reservationId) { - - log.info("🔍 예약번호로 결제 정보 조회 시작 (공용) - 예약번호: {}", reservationId); - - // 1. 예약번호로 결제 정보 조회 - Payment payment = loadPaymentPort.findByReservationId(reservationId) - .orElseThrow(() -> { - log.error("❌ 결제 정보를 찾을 수 없음 - reservationId: {}", reservationId); - return new PaymentValidationException("해당 예약번호의 결제 정보를 찾을 수 없습니다"); - }); - - // 2. 결제 상태 로그 (디버깅용) - log.info("결제 정보 조회 성공 - 예약번호: {}, 결제상태: {}, 결제일시: {}", - reservationId, payment.getPaymentStatus(), payment.getPaidAt()); - - // 3. 마일리지 거래 내역 조회 (회원인 경우만) - List mileageTransactions = new ArrayList<>(); - if (payment.getMemberId() != null) { - mileageTransactions = loadMileageTransactionPort.findByPaymentIdOrderByCreatedAtDesc(payment.getId().toString()); - } - - // 4. 응답 생성 - return PaymentInfoResponse.builder() - .paymentId(payment.getId()) - .reservationId(payment.getReservationId()) - .externalOrderId(payment.getExternalOrderId()) - .amountOriginalTotal(payment.getAmountOriginalTotal()) - .totalDiscountAmountApplied(payment.getTotalDiscountAmountApplied()) - .mileagePointsUsed(payment.getMileagePointsUsed()) - .mileageAmountDeducted(payment.getMileageAmountDeducted()) - .mileageToEarn(payment.getMileageToEarn()) - .amountPaid(payment.getAmountPaid()) - .paymentStatus(payment.getPaymentStatus()) - .paymentMethod(payment.getPaymentMethod().getDisplayName()) - .pgProvider(payment.getPgProvider()) - .pgTransactionId(payment.getPgTransactionId()) - .pgApprovalNo(payment.getPgApprovalNo()) - .receiptUrl(payment.getReceiptUrl()) - .paidAt(payment.getPaidAt()) - .createdAt(payment.getCreatedAt()) - .mileageTransactions(mileageTransactions.stream() - .map(PaymentInfoResponse.MileageTransactionInfo::from) - .collect(Collectors.toList())) - .build(); - } - - /** - * 특정 결제의 상세 정보 조회 (회원용) - */ - public PaymentInfoResponse getPaymentDetail(Long paymentId, UserDetails userDetails) { - - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.debug("결제 상세 정보 조회 - 결제ID: {}, 회원ID: {}", paymentId, memberId); - - // 1. 결제 정보 조회 - Payment payment = loadPaymentPort.findById(paymentId) - .orElseThrow(() -> new PaymentValidationException("결제 정보를 찾을 수 없습니다")); - - // 2. 본인 결제인지 확인 - if (payment.getMemberId() == null || !payment.getMemberId().equals(memberId)) { - throw new PaymentValidationException("본인의 결제 내역만 조회할 수 있습니다"); - } - - // 3. 마일리지 거래 내역 조회 - List mileageTransactions = - loadMileageTransactionPort.findByPaymentIdOrderByCreatedAtDesc(paymentId.toString()); - - // 4. 응답 생성 - return PaymentInfoResponse.builder() - .paymentId(payment.getId()) - .reservationId(payment.getReservationId()) - .externalOrderId(payment.getExternalOrderId()) - .amountOriginalTotal(payment.getAmountOriginalTotal()) - .totalDiscountAmountApplied(payment.getTotalDiscountAmountApplied()) - .mileagePointsUsed(payment.getMileagePointsUsed()) - .mileageAmountDeducted(payment.getMileageAmountDeducted()) - .mileageToEarn(payment.getMileageToEarn()) - .amountPaid(payment.getAmountPaid()) - .paymentStatus(payment.getPaymentStatus()) - .paymentMethod(payment.getPaymentMethod().getDisplayName()) - .pgProvider(payment.getPgProvider()) - .pgTransactionId(payment.getPgTransactionId()) - .pgApprovalNo(payment.getPgApprovalNo()) - .receiptUrl(payment.getReceiptUrl()) - .paidAt(payment.getPaidAt()) - .createdAt(payment.getCreatedAt()) - .mileageTransactions(mileageTransactions.stream() - .map(this::convertToMileageInfo) - .collect(Collectors.toList())) - .build(); - } - - /** - * 전화번호 마스킹 - */ - private String maskPhoneNumber(String phone) { - if (phone == null || phone.length() < 7) { - return "****"; - } - return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); - } - - /** - * MileageTransaction을 응답용 DTO로 변환 - */ - private PaymentInfoResponse.MileageTransactionInfo convertToMileageInfo(MileageTransaction transaction) { - return PaymentInfoResponse.MileageTransactionInfo.builder() - .transactionId(transaction.getId()) - .transactionType(transaction.getType().getDescription()) - .amount(transaction.getPointsAmount()) - .description(transaction.getDescription()) - .processedAt(transaction.getProcessedAt()) - .balanceAfter(transaction.getBalanceAfter()) - .build(); - } - - /** - * 비회원 전체 결제 내역 조회 - * 이름, 전화번호, 비밀번호로 모든 예약 조회 - */ - @Transactional(readOnly = true) - public PaymentHistoryResponse getAllNonMemberPayments( - String name, String phoneNumber, String password, Pageable pageable) { - - log.info("비회원 전체 결제 내역 조회 시작 - 이름: {}, 전화번호: {}", name, phoneNumber); - - // 기본 입력값 검증 - boolean isValid = nonMemberService.validateNonMemberCredentials(name, phoneNumber, password); - if (!isValid) { - throw new PaymentNotFoundException("입력하신 정보가 올바르지 않습니다."); - } - - // 비회원의 모든 결제 내역 조회 - Page payments = loadPaymentPort.findByNonMemberInfo(name, phoneNumber, pageable); - - // 결제 내역이 없는 경우 - 빈 응답 반환 - if (payments.isEmpty()) { - log.info("비회원 전체 결제 내역 조회 완료 - 조회된 결제 없음"); - return PaymentHistoryResponse.builder() - .payments(new ArrayList<>()) - .currentPage(0) - .totalPages(0) - .totalElements(0L) - .pageSize(pageable.getPageSize()) - .hasNext(false) - .hasPrevious(false) - .build(); - } - - // 첫 번째 결제 정보로 비밀번호 검증 - Payment firstPayment = payments.getContent().get(0); - boolean passwordValid = nonMemberService.validateNonMemberInfo(name, phoneNumber, password, firstPayment); - if (!passwordValid) { - throw new PaymentNotFoundException("비밀번호가 일치하지 않습니다."); - } - - log.info("비회원 전체 결제 내역 조회 완료 - 조회된 결제 수: {}", payments.getTotalElements()); - - // 각 결제에 대한 마일리지 거래 내역 및 예약 정보 조회 - List paymentItems = payments.getContent().stream() - .map(payment -> { - List mileageTransactions = - loadMileageTransactionPort.findByPaymentId(payment.getId().toString()); - - return PaymentHistoryResponse.PaymentHistoryItem.from(payment, mileageTransactions); - }) - .collect(Collectors.toList()); - - return PaymentHistoryResponse.builder() - .payments(paymentItems) - .currentPage(payments.getNumber()) - .totalPages(payments.getTotalPages()) - .totalElements(payments.getTotalElements()) - .pageSize(payments.getSize()) - .hasNext(!payments.isLast()) - .hasPrevious(!payments.isFirst()) - .build(); - } - -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PaymentQueryService.java b/src/main/java/com/sudo/railo/payment/application/service/PaymentQueryService.java deleted file mode 100644 index 21c11e85..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PaymentQueryService.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.dto.response.PaymentExecuteResponse; -import com.sudo.railo.payment.application.port.out.LoadPaymentPort; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.exception.PaymentValidationException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * 결제 조회 전용 서비스 - * Query 패턴 적용 - 결제 조회만 담당 - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class PaymentQueryService { - - private final LoadPaymentPort loadPaymentPort; - - public PaymentExecuteResponse getPayment(Long paymentId) { - Payment payment = loadPaymentPort.findById(paymentId) - .orElseThrow(() -> new PaymentValidationException("결제 정보를 찾을 수 없습니다")); - - return PaymentExecuteResponse.builder() - .paymentId(payment.getId()) - .externalOrderId(payment.getExternalOrderId()) - .paymentStatus(payment.getPaymentStatus()) - .amountPaid(payment.getAmountPaid()) - .mileagePointsUsed(payment.getMileagePointsUsed()) - .mileageAmountDeducted(payment.getMileageAmountDeducted()) - .mileageToEarn(payment.getMileageToEarn()) - .pgTransactionId(payment.getPgTransactionId()) - .pgApprovalNo(payment.getPgApprovalNo()) - .receiptUrl(payment.getReceiptUrl()) - .paidAt(payment.getPaidAt()) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PaymentRefundService.java b/src/main/java/com/sudo/railo/payment/application/service/PaymentRefundService.java deleted file mode 100644 index 5f3d51f9..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PaymentRefundService.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.event.PaymentCancelledEvent; -import com.sudo.railo.payment.application.event.PaymentRefundedEvent; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.payment.domain.repository.PaymentRepository; -import com.sudo.railo.payment.exception.PaymentException; -import com.sudo.railo.payment.exception.PaymentValidationException; -import com.sudo.railo.payment.infrastructure.external.pg.PgPaymentService; -import com.sudo.railo.payment.infrastructure.external.pg.dto.PgPaymentCancelRequest; -import com.sudo.railo.payment.infrastructure.external.pg.dto.PgPaymentCancelResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 결제 환불/취소 서비스 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class PaymentRefundService { - - private final PaymentRepository paymentRepository; - private final PgPaymentService pgPaymentService; - private final ApplicationEventPublisher eventPublisher; - - /** - * 결제 취소 처리 (결제 전 취소) - */ - @Transactional - public void cancelPayment(Long paymentId, String reason) { - log.debug("결제 취소 처리 시작 - paymentId: {}", paymentId); - - Payment payment = paymentRepository.findById(paymentId) - .orElseThrow(() -> new PaymentValidationException("존재하지 않는 결제입니다: " + paymentId)); - - // 취소 가능 여부 확인 - if (!payment.isCancellable()) { - throw new PaymentValidationException("취소 불가능한 결제 상태입니다: " + payment.getPaymentStatus()); - } - - try { - // PG사 취소 처리 (결제 전이므로 실제로는 요청만 취소) - if (payment.getPgTransactionId() != null) { - PgPaymentCancelRequest cancelRequest = PgPaymentCancelRequest.builder() - .pgTransactionId(payment.getPgTransactionId()) - .merchantOrderId(payment.getExternalOrderId()) - .cancelAmount(payment.getAmountPaid()) - .cancelReason(reason) - .build(); - - PgPaymentCancelResponse cancelResponse = pgPaymentService.cancelPayment(payment.getPaymentMethod(), cancelRequest); - - if (!cancelResponse.isSuccess()) { - throw new PaymentException("PG사 취소 처리 실패: " + cancelResponse.getErrorMessage()); - } - } - - // Payment 상태 업데이트 - payment.cancel(reason); - paymentRepository.save(payment); - - // 취소 이벤트 발행 (마일리지 복구용) - PaymentCancelledEvent cancelledEvent = PaymentCancelledEvent.builder() - .paymentId(payment.getId()) - .reservationId(payment.getReservationId()) - .externalOrderId(payment.getExternalOrderId()) - .memberId(payment.getMemberId()) - .cancelReason(reason) - .cancelledAt(payment.getCancelledAt()) - .mileageToRestore(payment.getMileagePointsUsed()) - .mileageEarnedToCancel(payment.getMileageToEarn()) - .build(); - - eventPublisher.publishEvent(cancelledEvent); - - log.debug("결제 취소 처리 완료 - paymentId: {}", paymentId); - - } catch (Exception e) { - log.error("결제 취소 처리 중 오류 발생 - paymentId: {}", paymentId, e); - throw new PaymentException("결제 취소 처리 중 오류가 발생했습니다", e); - } - } - - /** - * 결제 환불 처리 (전체 환불) - */ - @Transactional - public void refundPayment(Long paymentId, String reason) { - log.debug("결제 환불 처리 시작 - paymentId: {}", paymentId); - - Payment payment = paymentRepository.findById(paymentId) - .orElseThrow(() -> new PaymentValidationException("존재하지 않는 결제입니다: " + paymentId)); - - // 환불 가능 여부 확인 - if (!payment.isRefundable()) { - throw new PaymentValidationException("환불 불가능한 결제 상태입니다: " + payment.getPaymentStatus()); - } - - // 이미 환불된 결제인지 확인 - if (payment.getRefundAmount().compareTo(BigDecimal.ZERO) > 0) { - throw new PaymentValidationException("이미 환불 처리된 결제입니다"); - } - - BigDecimal refundAmount = payment.getAmountPaid(); // 전체 환불 - - try { - // PG사 환불 처리 - PgPaymentCancelRequest pgCancelRequest = PgPaymentCancelRequest.builder() - .pgTransactionId(payment.getPgTransactionId()) - .merchantOrderId(payment.getExternalOrderId()) - .cancelAmount(refundAmount) - .cancelReason(reason) - .build(); - - PgPaymentCancelResponse refundResponse = pgPaymentService.cancelPayment(payment.getPaymentMethod(), pgCancelRequest); - - if (!refundResponse.isSuccess()) { - throw new PaymentException("PG사 환불 처리 실패: " + refundResponse.getErrorMessage()); - } - - // 환불 수수료 계산 - BigDecimal refundFee = calculateRefundFee(payment, refundAmount); - - // Payment 상태 업데이트 (전체 환불) - Payment.RefundRequest refundRequest = Payment.RefundRequest.builder() - .refundAmount(refundAmount) - .refundFee(refundFee) - .reason(reason) - .pgTransactionId(refundResponse.getPgTransactionId()) - .pgApprovalNo(refundResponse.getCancelApprovalNumber()) - .build(); - payment.processRefund(refundRequest); - - paymentRepository.save(payment); - - // 환불 이벤트 발행 - PaymentRefundedEvent refundedEvent = PaymentRefundedEvent.builder() - .paymentId(payment.getId()) - .reservationId(payment.getReservationId()) - .externalOrderId(payment.getExternalOrderId()) - .memberId(payment.getMemberId()) - .refundAmount(refundAmount) - .refundFee(refundFee) - .refundReason(reason) - .pgRefundTransactionId(refundResponse.getPgTransactionId()) - .refundedAt(payment.getRefundedAt()) - .mileageToRestore(payment.getMileagePointsUsed()) // 사용한 마일리지 전체 복구 - .mileageEarnedToCancel(payment.getMileageToEarn()) // 적립 예정 마일리지 전체 취소 - .build(); - - eventPublisher.publishEvent(refundedEvent); - - log.debug("결제 환불 처리 완료 - paymentId: {}, amount: {}", paymentId, refundAmount); - - } catch (Exception e) { - log.error("결제 환불 처리 중 오류 발생 - paymentId: {}", paymentId, e); - throw new PaymentException("결제 환불 처리 중 오류가 발생했습니다", e); - } - } - - /** - * 환불 수수료 계산 - */ - private BigDecimal calculateRefundFee(Payment payment, BigDecimal refundAmount) { - // 결제일로부터 24시간 이내는 무료, 이후는 환불 금액의 1% (최소 1000원) - LocalDateTime paymentTime = payment.getPaidAt(); - LocalDateTime now = LocalDateTime.now(); - - if (paymentTime != null && paymentTime.plusDays(1).isAfter(now)) { - return BigDecimal.ZERO; // 24시간 이내 무료 - } - - BigDecimal feeRate = new BigDecimal("0.01"); // 1% - BigDecimal calculatedFee = refundAmount.multiply(feeRate); - BigDecimal minFee = new BigDecimal("1000"); - - return calculatedFee.max(minFee); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PaymentService.java b/src/main/java/com/sudo/railo/payment/application/service/PaymentService.java deleted file mode 100644 index 2ada8e63..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PaymentService.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.global.redis.annotation.DistributedLock; -import com.sudo.railo.payment.application.context.PaymentContext; -import com.sudo.railo.payment.application.dto.PaymentResult; -import com.sudo.railo.payment.application.dto.request.PaymentExecuteRequest; -import com.sudo.railo.payment.application.dto.response.PaymentExecuteResponse; -import com.sudo.railo.payment.application.event.PaymentEventPublisher; -import com.sudo.railo.payment.application.mapper.PaymentResponseMapper; -import com.sudo.railo.payment.domain.entity.Payment; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * 결제 서비스 Facade - * - * 결제 프로세스의 진입점으로 각 전문 서비스를 조율하여 - * 전체 결제 플로우를 관리 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class PaymentService { - - private final PaymentValidationFacade validationFacade; - private final PaymentCreationService creationService; - private final PaymentExecutionService executionService; - private final PaymentQueryService queryService; - private final PaymentEventPublisher eventPublisher; - - /** - * 결제 실행 - 단순화된 메인 플로우 (16줄) - * - * @param request 결제 실행 요청 - * @return 결제 실행 응답 - */ - @Transactional - @DistributedLock(key = "#request.calculationId", prefix = "payment:execute", expireTime = 60) - public PaymentExecuteResponse executePayment(PaymentExecuteRequest request) { - log.info("🚀 결제 프로세스 시작 - calculationId: {}, idempotencyKey: {}", request.getId(), request.getIdempotencyKey()); - - try { - // 1. 통합 검증 및 컨텍스트 준비 - log.info("📋 1단계: 검증 시작"); - PaymentContext context = validationFacade.validateAndPrepare(request); - log.info("✅ 1단계: 검증 완료 - 회원타입: {}, 최종금액: {}", - context.getMemberType(), context.getFinalPayableAmount()); - - // 2. Payment 엔티티 생성 및 저장 - log.info("📋 2단계: Payment 엔티티 생성 시작"); - Payment payment = creationService.createPayment(context); - log.info("✅ 2단계: Payment 엔티티 생성 완료 - paymentId: {}, reservationId: {}", - payment.getId(), payment.getReservationId()); - - // 3. 결제 실행 (마일리지 차감, PG 결제) - log.info("📋 3단계: 결제 실행 시작"); - PaymentResult result = executionService.execute(payment, context); - log.info("✅ 3단계: 결제 실행 완료 - success: {}", result.isSuccess()); - - // 4. 이벤트 발행 - log.info("📋 4단계: 이벤트 발행 시작"); - publishPaymentEvents(result, context); - log.info("✅ 4단계: 이벤트 발행 완료"); - - // 5. 응답 생성 - log.info("📋 5단계: 응답 생성"); - PaymentExecuteResponse response = PaymentResponseMapper.from(result, context); - log.info("🎉 결제 프로세스 완료 - paymentId: {}, status: {}", response.getId(), response.getPaymentStatus()); - - return response; - - } catch (Exception e) { - log.error("💥 결제 프로세스 실패 - calculationId: {}, 단계: 미상, 예외: {}", - request.getId(), e.getClass().getName(), e); - throw e; - } - } - - /** - * 결제 조회 - */ - public PaymentExecuteResponse getPayment(Long paymentId) { - return queryService.getPayment(paymentId); - } - - /** - * 결제 이벤트 발행 - * - * 이벤트 발행 체계: - * 1. PaymentExecutionService에서 PaymentStateChangedEvent 발행 - * 2. PaymentEventTranslator가 BookingPaymentCompletedEvent 등으로 변환 - * 3. MileageEventListener가 PaymentStateChangedEvent를 수신하여 마일리지 처리 - * - * @deprecated publishPaymentCompleted 제거 - 중복 이벤트 발행 방지 - */ - private void publishPaymentEvents(PaymentResult result, PaymentContext context) { - Payment payment = result.getPayment(); - - // publishPaymentCompleted 제거 - PaymentExecutionService에서 이미 PaymentStateChangedEvent 발행 - // 마일리지 처리는 MileageEventListener가 PaymentStateChangedEvent를 수신하여 처리 - - // 기존 이벤트 발행 (호환성) - String userId = context.isForMember() ? - context.getMemberId().toString() : - "NON_MEMBER:" + context.getRequest().getNonMemberName(); - - eventPublisher.publishExecutionEvent( - payment.getId().toString(), - payment.getExternalOrderId(), - userId - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PaymentValidationFacade.java b/src/main/java/com/sudo/railo/payment/application/service/PaymentValidationFacade.java deleted file mode 100644 index 11045bd7..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PaymentValidationFacade.java +++ /dev/null @@ -1,203 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.context.PaymentContext; -import com.sudo.railo.payment.application.dto.MileageValidationResult; -import com.sudo.railo.payment.application.dto.request.PaymentExecuteRequest; -import com.sudo.railo.payment.application.dto.response.PaymentCalculationResponse; -import com.sudo.railo.payment.domain.entity.MemberType; -import com.sudo.railo.payment.domain.service.PaymentValidationService; -import com.sudo.railo.payment.domain.service.MemberTypeService; -import com.sudo.railo.payment.domain.service.MileageService; -import com.sudo.railo.payment.exception.PaymentValidationException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; - -/** - * 결제 검증 통합 서비스 - * - * 결제 실행에 필요한 모든 검증을 통합하여 처리하고 - * PaymentContext를 생성하여 반환 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class PaymentValidationFacade { - - private final PaymentValidationService validationService; - private final PaymentCalculationService calculationService; - private final MileageService mileageService; - private final MemberTypeService memberTypeService; - - /** - * 결제 요청 검증 및 컨텍스트 준비 - * - * @param request 결제 실행 요청 - * @return 검증된 결제 컨텍스트 - */ - @Transactional(readOnly = true) - public PaymentContext validateAndPrepare(PaymentExecuteRequest request) { - log.info("결제 검증 시작 - calculationId: {}", request.getId()); - - try { - // 1. 기본 요청 검증 - validationService.validateExecuteRequest(request); - log.debug("기본 요청 검증 완료"); - - // 2. 회원/비회원 타입 판별 - MemberType memberType = memberTypeService.determineMemberType(request); - log.info("회원 타입 확인 - {}", memberType.getDescription()); - - // 3. 계산 세션 조회 및 검증 - PaymentCalculationResponse calculation = calculationService.getCalculation( - request.getId() - ); - validateCalculation(calculation, request); - log.debug("계산 세션 검증 완료 - 예약ID: {}", calculation.getReservationId()); - - // 4. 마일리지 검증 (회원인 경우만) - MileageValidationResult mileageResult = validateMileage(request, calculation, memberType); - if (mileageResult.hasMileageUsage()) { - log.info("마일리지 사용 검증 완료 - 사용포인트: {}, 차감금액: {}", - mileageResult.getUsageAmount(), mileageResult.getDeductionAmount()); - } - - // 5. 최종 금액 검증 - validateFinalAmount(request, calculation, mileageResult); - - // 6. PaymentContext 생성 - PaymentContext context = PaymentContext.builder() - .request(request) - .calculation(calculation) - .mileageResult(convertMileageResult(mileageResult)) - .memberType(memberType) - .createdAt(java.time.LocalDateTime.now()) - .build(); - - log.info("결제 검증 완료 - 최종금액: {}", - calculation.getFinalPayableAmount().subtract( - mileageResult.getDeductionAmount() != null ? - mileageResult.getDeductionAmount() : BigDecimal.ZERO - )); - - return context; - - } catch (PaymentValidationException e) { - log.error("결제 검증 실패 - {}", e.getMessage()); - throw e; - } catch (Exception e) { - log.error("결제 검증 중 예외 발생", e); - throw new PaymentValidationException("결제 검증 중 오류가 발생했습니다: " + e.getMessage(), e); - } - } - - /** - * 계산 세션 검증 - */ - private void validateCalculation(PaymentCalculationResponse calculation, - PaymentExecuteRequest request) { - // 계산 ID 일치 확인 - if (!calculation.getId().equals(request.getId())) { - throw new PaymentValidationException("계산 ID가 일치하지 않습니다"); - } - - // 만료 시간 확인 - if (calculation.isExpired()) { - throw new PaymentValidationException("계산 세션이 만료되었습니다"); - } - - // 최소 결제 금액 확인 - if (calculation.getFinalPayableAmount().compareTo(BigDecimal.ZERO) <= 0) { - throw new PaymentValidationException("결제 금액이 0원 이하입니다"); - } - } - - /** - * 마일리지 검증 - */ - private MileageValidationResult validateMileage(PaymentExecuteRequest request, - PaymentCalculationResponse calculation, - MemberType memberType) { - // 비회원은 마일리지 사용 불가 - if (memberType != MemberType.MEMBER) { - return MileageValidationResult.notUsed(); - } - - // 마일리지 사용 요청이 없는 경우 - BigDecimal mileageToUse = request.getMileageToUse(); - if (mileageToUse == null || mileageToUse.compareTo(BigDecimal.ZERO) <= 0) { - return MileageValidationResult.notUsed(); - } - - try { - // 마일리지 사용 가능 여부 검증 - mileageService.validateMileageUsage( - mileageToUse, - request.getAvailableMileage(), - calculation.getFinalPayableAmount() - ); - - // 마일리지 -> 원화 변환 - BigDecimal deductionAmount = mileageService.convertMileageToWon(mileageToUse); - - return MileageValidationResult.success( - mileageToUse, - request.getAvailableMileage(), - deductionAmount - ); - - } catch (PaymentValidationException e) { - log.warn("마일리지 검증 실패 - {}", e.getMessage()); - throw e; - } - } - - /** - * 최종 결제 금액 검증 - */ - private void validateFinalAmount(PaymentExecuteRequest request, - PaymentCalculationResponse calculation, - MileageValidationResult mileageResult) { - BigDecimal calculatedAmount = calculation.getFinalPayableAmount(); - BigDecimal mileageDeduction = mileageResult.getDeductionAmount() != null ? - mileageResult.getDeductionAmount() : BigDecimal.ZERO; - - BigDecimal finalAmount = calculatedAmount.subtract(mileageDeduction); - - // 최종 금액이 음수인지 확인 - if (finalAmount.compareTo(BigDecimal.ZERO) < 0) { - throw new PaymentValidationException( - String.format("마일리지 차감 후 결제 금액이 음수입니다. " + - "결제금액: %s, 마일리지차감: %s", - calculatedAmount, mileageDeduction) - ); - } - - // 전액 마일리지 결제인 경우 PG 결제 정보 불필요 - if (finalAmount.compareTo(BigDecimal.ZERO) == 0 && - request.getPaymentMethod().getPgToken() != null) { - log.warn("전액 마일리지 결제에 PG 토큰이 포함되어 있습니다"); - } - } - - /** - * MileageValidationResult 타입 변환 - * dto → context 내부 클래스로 변환 - */ - private PaymentContext.MileageValidationResult convertMileageResult( - MileageValidationResult dtoResult) { - if (dtoResult == null || !dtoResult.isValid()) { - return PaymentContext.MileageValidationResult.failure( - dtoResult != null ? dtoResult.getFailureReason() : "마일리지 검증 실패" - ); - } - - return PaymentContext.MileageValidationResult.success( - dtoResult.getAvailableBalance(), - dtoResult.getUsageAmount() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/PgPaymentService.java b/src/main/java/com/sudo/railo/payment/application/service/PgPaymentService.java deleted file mode 100644 index 2dba09f3..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/PgPaymentService.java +++ /dev/null @@ -1,207 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.context.PaymentContext; -import com.sudo.railo.payment.application.dto.PaymentResult.PgPaymentResult; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.exception.PaymentExecutionException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.math.BigDecimal; -import java.util.UUID; - -/** - * PG 결제 처리 서비스 - * - * 외부 PG사와의 연동을 담당 - * 현재는 Mock 구현이며, 실제 PG 연동 시 구현체 교체 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class PgPaymentService { - - /** - * PG 결제 처리 - * - * @param payment 결제 엔티티 - * @param context 결제 컨텍스트 - * @return PG 결제 결과 - */ - public PgPaymentResult processPayment(Payment payment, PaymentContext context) { - log.info("PG 결제 시작 - paymentId: {}, amount: {}, method: {}", - payment.getId(), payment.getAmountPaid(), payment.getPaymentMethod()); - - try { - // 전액 마일리지 결제인 경우 PG 결제 불필요 - if (payment.getAmountPaid().compareTo(BigDecimal.ZERO) == 0) { - log.info("전액 마일리지 결제 - PG 결제 생략"); - return PgPaymentResult.builder() - .success(true) - .pgTransactionId("MILEAGE_ONLY") - .pgApprovalNo("MILEAGE_ONLY") - .pgMessage("전액 마일리지 결제") - .build(); - } - - // PG사별 결제 처리 - PgPaymentResult result = switch (payment.getPgProvider()) { - case "NICE_PAY" -> processNicePay(payment, context); - case "TOSS_PAYMENTS" -> processTossPayments(payment, context); - case "KG_INICIS" -> processKgInicis(payment, context); - case "KAKAO_PAY" -> processKakaoPay(payment, context); - case "NAVER_PAY" -> processNaverPay(payment, context); - case "PAYCO" -> processPayco(payment, context); - // 일반 결제수단은 기본 PG사로 처리 - case "CREDIT_CARD" -> processNicePay(payment, context); // 신용카드는 나이스페이로 처리 - case "BANK_ACCOUNT" -> processKgInicis(payment, context); // 계좌이체는 이니시스로 처리 - case "BANK_TRANSFER" -> processKgInicis(payment, context); // 무통장입금도 이니시스로 처리 - default -> throw new PaymentExecutionException( - "지원하지 않는 PG사입니다: " + payment.getPgProvider()); - }; - - // 결제 성공 시 PG 정보 업데이트 - if (result.isSuccess()) { - updatePgInfo(payment, result); - } - - log.info("PG 결제 완료 - success: {}, pgTxId: {}", - result.isSuccess(), result.getPgTransactionId()); - - return result; - - } catch (Exception e) { - log.error("PG 결제 실패 - paymentId: {}", payment.getId(), e); - throw new PaymentExecutionException("PG 결제 처리 중 오류가 발생했습니다", e); - } - } - - /** - * 나이스페이 결제 처리 (Mock) - */ - private PgPaymentResult processNicePay(Payment payment, PaymentContext context) { - log.debug("나이스페이 결제 처리 - pgToken: {}", - context.getRequest().getPaymentMethod().getPgToken()); - - // TODO: 실제 나이스페이 API 연동 - // Mock 응답 - return PgPaymentResult.builder() - .success(true) - .pgTransactionId("NICE_" + UUID.randomUUID().toString()) - .pgApprovalNo("NICE_APPROVAL_" + System.currentTimeMillis()) - .pgMessage("나이스페이 결제 성공") - .build(); - } - - /** - * 토스페이먼츠 결제 처리 (Mock) - */ - private PgPaymentResult processTossPayments(Payment payment, PaymentContext context) { - log.debug("토스페이먼츠 결제 처리 - pgToken: {}", - context.getRequest().getPaymentMethod().getPgToken()); - - // TODO: 실제 토스페이먼츠 API 연동 - // Mock 응답 - return PgPaymentResult.builder() - .success(true) - .pgTransactionId("TOSS_" + UUID.randomUUID().toString()) - .pgApprovalNo("TOSS_APPROVAL_" + System.currentTimeMillis()) - .pgMessage("토스페이먼츠 결제 성공") - .build(); - } - - /** - * KG이니시스 결제 처리 (Mock) - */ - private PgPaymentResult processKgInicis(Payment payment, PaymentContext context) { - log.debug("KG이니시스 결제 처리 - pgToken: {}", - context.getRequest().getPaymentMethod().getPgToken()); - - // TODO: 실제 KG이니시스 API 연동 - // Mock 응답 - return PgPaymentResult.builder() - .success(true) - .pgTransactionId("INICIS_" + UUID.randomUUID().toString()) - .pgApprovalNo("INICIS_APPROVAL_" + System.currentTimeMillis()) - .pgMessage("KG이니시스 결제 성공") - .build(); - } - - /** - * 카카오페이 결제 처리 (Mock) - */ - private PgPaymentResult processKakaoPay(Payment payment, PaymentContext context) { - log.debug("카카오페이 결제 처리 - pgToken: {}", - context.getRequest().getPaymentMethod().getPgToken()); - - // TODO: 실제 카카오페이 API 연동 (MockKakaoPayGateway 사용) - // Mock 응답 - return PgPaymentResult.builder() - .success(true) - .pgTransactionId("KAKAO_" + UUID.randomUUID().toString()) - .pgApprovalNo("KAKAO_APPROVAL_" + System.currentTimeMillis()) - .pgMessage("카카오페이 결제 성공") - .build(); - } - - /** - * 네이버페이 결제 처리 (Mock) - */ - private PgPaymentResult processNaverPay(Payment payment, PaymentContext context) { - log.debug("네이버페이 결제 처리 - pgToken: {}", - context.getRequest().getPaymentMethod().getPgToken()); - - // TODO: 실제 네이버페이 API 연동 - // Mock 응답 - return PgPaymentResult.builder() - .success(true) - .pgTransactionId("NAVER_" + UUID.randomUUID().toString()) - .pgApprovalNo("NAVER_APPROVAL_" + System.currentTimeMillis()) - .pgMessage("네이버페이 결제 성공") - .build(); - } - - /** - * PAYCO 결제 처리 (Mock) - */ - private PgPaymentResult processPayco(Payment payment, PaymentContext context) { - log.debug("PAYCO 결제 처리 - pgToken: {}", - context.getRequest().getPaymentMethod().getPgToken()); - - // TODO: 실제 PAYCO API 연동 - // Mock 응답 - return PgPaymentResult.builder() - .success(true) - .pgTransactionId("PAYCO_" + UUID.randomUUID().toString()) - .pgApprovalNo("PAYCO_APPROVAL_" + System.currentTimeMillis()) - .pgMessage("PAYCO 결제 성공") - .build(); - } - - /** - * Payment 엔티티에 PG 정보 업데이트 - */ - private void updatePgInfo(Payment payment, PgPaymentResult result) { - payment.updatePgInfo(result.getPgTransactionId(), result.getPgApprovalNo()); - log.debug("PG 정보 업데이트 - pgTxId: {}, pgApproval: {}", - result.getPgTransactionId(), result.getPgApprovalNo()); - } - - /** - * PG 결제 취소 - */ - public PgPaymentResult cancelPayment(Payment payment, BigDecimal cancelAmount, String reason) { - log.info("PG 결제 취소 - paymentId: {}, cancelAmount: {}, reason: {}", - payment.getId(), cancelAmount, reason); - - // TODO: 실제 PG 취소 API 연동 - // Mock 응답 - return PgPaymentResult.builder() - .success(true) - .pgTransactionId("CANCEL_" + payment.getPgTransactionId()) - .pgApprovalNo("CANCEL_APPROVAL_" + System.currentTimeMillis()) - .pgMessage("결제 취소 성공") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/RefundAlertService.java b/src/main/java/com/sudo/railo/payment/application/service/RefundAlertService.java deleted file mode 100644 index ada22922..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/RefundAlertService.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import io.micrometer.core.instrument.MeterRegistry; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * 환불 알림 서비스 - * - * 토스/당근 스타일의 실시간 모니터링과 알림을 제공합니다. - * Slack, Discord, 이메일 등으로 알림을 보낼 수 있습니다. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class RefundAlertService { - - private final MeterRegistry meterRegistry; - - @Value("${alert.refund.failure-threshold:10}") - private int failureThreshold; // 10회 이상 실패시 알림 - - @Value("${alert.refund.rate-threshold:0.1}") - private double failureRateThreshold; // 10% 이상 실패율시 알림 - - // 시간대별 실패 카운터 (실무에서는 Redis 사용) - private final ConcurrentHashMap hourlyFailureCount = new ConcurrentHashMap<>(); - - /** - * 환불 실패 알림 - */ - @Async - public void alertRefundFailure(Long paymentId, String reason, String errorDetail) { - String currentHour = LocalDateTime.now().toString().substring(0, 13); - AtomicInteger counter = hourlyFailureCount.computeIfAbsent(currentHour, k -> new AtomicInteger(0)); - int failureCount = counter.incrementAndGet(); - - // 임계값 초과시 알림 - if (failureCount == failureThreshold) { - sendUrgentAlert(String.format( - "[긴급] 환불 실패 급증 - 현재 시간대 %d건 발생\n" + - "최근 실패: paymentId=%d, 사유=%s", - failureCount, paymentId, reason - )); - } - - // 개별 중요 실패 알림 - if (isImportantFailure(reason)) { - sendAlert(String.format( - "[환불 실패] paymentId: %d\n사유: %s\n상세: %s", - paymentId, reason, errorDetail - )); - } - - log.warn("환불 실패 기록 - paymentId: {}, 사유: {}, 시간대 실패수: {}", - paymentId, reason, failureCount); - } - - /** - * 환불 거부 알림 (도착 후 환불 등) - */ - @Async - public void alertRefundDenied(Long paymentId, String denialReason) { - // 도착 후 환불 시도는 중요하므로 알림 - if (denialReason.contains("도착 후")) { - sendAlert(String.format( - "[환불 거부] 도착 후 환불 시도\npaymentId: %d\n시도 시각: %s", - paymentId, LocalDateTime.now() - )); - - // 메트릭 기록 - meterRegistry.counter("refund.denied.after_arrival.alert").increment(); - } - } - - /** - * Unknown 상태 장시간 지속 알림 - */ - @Async - public void alertLongUnknownStatus(int unknownCount) { - if (unknownCount > 5) { - sendUrgentAlert(String.format( - "[주의] Unknown 상태 환불 %d건 존재\n" + - "PG사 연동 상태 확인 필요", - unknownCount - )); - } - } - - /** - * 중요한 실패인지 판단 - */ - private boolean isImportantFailure(String reason) { - return reason.contains("중복") || - reason.contains("Unknown") || - reason.contains("PG") || - reason.contains("네트워크"); - } - - /** - * 일반 알림 발송 - * 실제로는 Slack, Discord, 이메일 등으로 발송 - */ - private void sendAlert(String message) { - // TODO: 실제 알림 채널 연동 - log.info("[ALERT] {}", message); - - // 실무에서는: - // slackClient.sendMessage(alertChannel, message); - // emailService.sendAlert(opsTeam, message); - } - - /** - * 긴급 알림 발송 - */ - private void sendUrgentAlert(String message) { - // TODO: 실제 긴급 알림 채널 연동 - log.error("[URGENT ALERT] {}", message); - - // 실무에서는: - // pagerDuty.triggerIncident(message); - // slackClient.sendMessage(urgentChannel, "@channel " + message); - } - - /** - * 시간별 카운터 초기화 (스케줄러로 매시간 실행) - */ - public void clearOldCounters() { - String currentHour = LocalDateTime.now().toString().substring(0, 13); - hourlyFailureCount.entrySet().removeIf(entry -> !entry.getKey().equals(currentHour)); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/RefundAuditLogService.java b/src/main/java/com/sudo/railo/payment/application/service/RefundAuditLogService.java deleted file mode 100644 index 0c06d752..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/RefundAuditLogService.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.domain.entity.RefundAuditLog; -import com.sudo.railo.payment.domain.entity.RefundAuditLog.AuditEventType; -import com.sudo.railo.payment.infrastructure.persistence.RefundAuditLogRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * 환불 감사 로그 서비스 - * - * 토스 스타일로 중요한 이벤트만 선택적으로 저장합니다. - * REQUIRES_NEW로 메인 트랜잭션과 독립적으로 동작합니다. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class RefundAuditLogService { - - private final RefundAuditLogRepository auditLogRepository; - - /** - * 도착 후 환불 거부 기록 - * 가장 중요한 이벤트이므로 반드시 기록 - */ - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void logRefundDeniedAfterArrival(Long paymentId, Long reservationId, Long memberId, String detail) { - try { - RefundAuditLog auditLog = RefundAuditLog.createDeniedLog( - paymentId, - reservationId, - memberId, - AuditEventType.REFUND_DENIED_AFTER_ARRIVAL, - "열차 도착 후 환불 시도", - detail - ); - - auditLogRepository.save(auditLog); - log.debug("환불 거부 감사 로그 저장 - paymentId: {}, type: {}", paymentId, AuditEventType.REFUND_DENIED_AFTER_ARRIVAL); - - } catch (Exception e) { - // 감사 로그 실패가 메인 프로세스를 방해하지 않음 - log.error("감사 로그 저장 실패 - paymentId: {}", paymentId, e); - } - } - - /** - * 중복 환불 시도 기록 - */ - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void logDuplicateRefundAttempt(Long paymentId, Long existingRefundId) { - try { - RefundAuditLog auditLog = RefundAuditLog.builder() - .paymentId(paymentId) - .eventType(AuditEventType.REFUND_DENIED_DUPLICATE) - .eventReason("중복 환불 시도") - .eventDetail(String.format("기존 환불 ID: %d", existingRefundId)) - .build(); - - auditLogRepository.save(auditLog); - - } catch (Exception e) { - log.error("중복 환불 감사 로그 저장 실패", e); - } - } - - /** - * Unknown 상태 발생 기록 - */ - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void logUnknownState(Long paymentId, String errorDetail) { - try { - RefundAuditLog auditLog = RefundAuditLog.builder() - .paymentId(paymentId) - .eventType(AuditEventType.REFUND_UNKNOWN_STATE) - .eventReason("PG사 통신 오류") - .eventDetail(errorDetail) - .build(); - - auditLogRepository.save(auditLog); - - } catch (Exception e) { - log.error("Unknown 상태 감사 로그 저장 실패", e); - } - } - - /** - * 재시도 성공 기록 - */ - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void logRetrySuccess(Long paymentId, Long refundCalculationId, int attemptCount) { - try { - RefundAuditLog auditLog = RefundAuditLog.builder() - .paymentId(paymentId) - .eventType(AuditEventType.REFUND_RETRY_SUCCESS) - .eventReason(String.format("%d번째 재시도 성공", attemptCount)) - .eventDetail(String.format("refundCalculationId: %d", refundCalculationId)) - .build(); - - auditLogRepository.save(auditLog); - - } catch (Exception e) { - log.error("재시도 성공 감사 로그 저장 실패", e); - } - } - - /** - * 특정 기간의 감사 로그 조회 (운영팀용) - */ - @Transactional(readOnly = true) - public List getAuditLogs(LocalDateTime startTime, LocalDateTime endTime) { - return auditLogRepository.findByCreatedAtBetweenOrderByCreatedAtDesc(startTime, endTime); - } - - /** - * 특정 결제의 감사 로그 조회 - */ - @Transactional(readOnly = true) - public List getAuditLogsByPaymentId(Long paymentId) { - return auditLogRepository.findByPaymentIdOrderByCreatedAtDesc(paymentId); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/RefundMetricService.java b/src/main/java/com/sudo/railo/payment/application/service/RefundMetricService.java deleted file mode 100644 index 5ef6a6bd..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/RefundMetricService.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -/** - * 환불 메트릭 서비스 - * - * 단순하게 카운터만 관리합니다. - * 실무에서 가장 많이 사용하는 패턴입니다. - */ -@Slf4j -@Service -public class RefundMetricService { - - private final Counter refundAttemptCounter; - private final Counter refundSuccessCounter; - private final Counter refundDeniedAfterArrivalCounter; - private final Counter refundDeniedInvalidStatusCounter; - - public RefundMetricService(MeterRegistry registry) { - this.refundAttemptCounter = Counter.builder("refund.attempt") - .description("환불 시도 횟수") - .register(registry); - - this.refundSuccessCounter = Counter.builder("refund.success") - .description("환불 성공 횟수") - .register(registry); - - this.refundDeniedAfterArrivalCounter = Counter.builder("refund.denied") - .tag("reason", "after_arrival") - .description("도착 후 환불 거부") - .register(registry); - - this.refundDeniedInvalidStatusCounter = Counter.builder("refund.denied") - .tag("reason", "invalid_status") - .description("잘못된 상태로 환불 거부") - .register(registry); - } - - public void recordAttempt() { - refundAttemptCounter.increment(); - } - - public void recordSuccess() { - refundSuccessCounter.increment(); - } - - public void recordDeniedAfterArrival() { - refundDeniedAfterArrivalCounter.increment(); - } - - public void recordDeniedInvalidStatus() { - refundDeniedInvalidStatusCounter.increment(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/RefundRetryService.java b/src/main/java/com/sudo/railo/payment/application/service/RefundRetryService.java deleted file mode 100644 index 44a668b2..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/RefundRetryService.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.domain.entity.RefundCalculation; -import com.sudo.railo.payment.domain.entity.RefundStatus; -import com.sudo.railo.payment.domain.repository.RefundCalculationRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * 환불 재시도 서비스 - * - * Unknown 상태의 환불을 주기적으로 확인하고 재시도합니다. - * 토스/카카오페이 스타일의 실용적 접근법입니다. - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class RefundRetryService { - - private final RefundCalculationRepository refundCalculationRepository; - private final RefundService refundService; - - /** - * Unknown 상태 환불 재시도 - * 5분마다 실행되며, 30분 이상 Unknown 상태인 환불은 실패로 처리 - */ - @Scheduled(fixedDelay = 300000) // 5분마다 - @Transactional - public void retryUnknownRefunds() { - List unknownRefunds = refundCalculationRepository - .findByRefundStatus(RefundStatus.UNKNOWN); - - if (unknownRefunds.isEmpty()) { - return; - } - - log.info("Unknown 상태 환불 재시도 시작 - 대상: {}건", unknownRefunds.size()); - - LocalDateTime thirtyMinutesAgo = LocalDateTime.now().minusMinutes(30); - - for (RefundCalculation refund : unknownRefunds) { - try { - // 30분 이상 Unknown 상태면 실패로 처리 - if (refund.getRefundRequestTime().isBefore(thirtyMinutesAgo)) { - refund.markAsFailed("30분 이상 Unknown 상태 - 자동 실패 처리"); - refundCalculationRepository.save(refund); - log.warn("환불 자동 실패 처리 - refundCalculationId: {}", refund.getId()); - continue; - } - - // 재시도 - retryRefund(refund); - - } catch (Exception e) { - log.error("환불 재시도 중 오류 - refundCalculationId: {}", refund.getId(), e); - } - } - } - - /** - * 개별 환불 재시도 - */ - private void retryRefund(RefundCalculation refund) { - log.info("환불 재시도 - refundCalculationId: {}, paymentId: {}", - refund.getId(), refund.getPaymentId()); - - try { - // RefundService의 processRefund를 호출하여 재시도 - refundService.processRefund(refund.getId()); - log.info("환불 재시도 성공 - refundCalculationId: {}", refund.getId()); - - } catch (Exception e) { - // 재시도도 실패하면 여전히 Unknown 상태 유지 - log.warn("환불 재시도 실패 - refundCalculationId: {}, 다음 스케줄에서 재시도", - refund.getId()); - } - } - - /** - * 수동 재시도 트리거 - * 운영자가 특정 환불을 수동으로 재시도할 때 사용 - */ - @Transactional - public void manualRetry(Long refundCalculationId) { - RefundCalculation refund = refundCalculationRepository.findById(refundCalculationId) - .orElseThrow(() -> new IllegalArgumentException("환불 계산을 찾을 수 없습니다: " + refundCalculationId)); - - if (refund.getRefundStatus() != RefundStatus.UNKNOWN) { - throw new IllegalStateException("Unknown 상태가 아닙니다: " + refund.getRefundStatus()); - } - - retryRefund(refund); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/RefundService.java b/src/main/java/com/sudo/railo/payment/application/service/RefundService.java deleted file mode 100644 index da17acdb..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/RefundService.java +++ /dev/null @@ -1,584 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.domain.entity.*; -import com.sudo.railo.payment.domain.repository.PaymentRepository; -import com.sudo.railo.payment.domain.repository.RefundCalculationRepository; -import com.sudo.railo.payment.domain.repository.MileageTransactionRepository; -import com.sudo.railo.payment.domain.repository.MileageEarningScheduleRepository; -import com.sudo.railo.payment.domain.service.refund.RefundPolicyFactory; -import com.sudo.railo.payment.domain.service.refund.RefundPolicyService; -import com.sudo.railo.payment.application.event.PaymentEventPublisher; -import com.sudo.railo.payment.application.port.out.SaveMemberInfoPort; -import com.sudo.railo.payment.application.port.out.LoadMemberInfoPort; -import com.sudo.railo.payment.exception.PaymentException; -import com.sudo.railo.payment.exception.RefundDeniedException; -import com.sudo.railo.booking.infra.ReservationRepository; -import com.sudo.railo.booking.domain.Reservation; -import com.sudo.railo.train.application.TrainScheduleService; -// import com.sudo.railo.train.domain.type.TrainOperator; // 제거됨 -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -/** - * 환불 처리 Application Service - */ -@Service -@RequiredArgsConstructor -@Slf4j -@Transactional -public class RefundService { - - private final PaymentRepository paymentRepository; - private final RefundCalculationRepository refundCalculationRepository; - private final MileageTransactionRepository mileageTransactionRepository; - private final MileageEarningScheduleRepository mileageEarningScheduleRepository; - private final PaymentEventPublisher eventPublisher; - private final SaveMemberInfoPort saveMemberInfoPort; - private final LoadMemberInfoPort loadMemberInfoPort; - private final ReservationRepository reservationRepository; - private final TrainScheduleService trainScheduleService; - private final RefundPolicyFactory refundPolicyFactory; - private final RefundMetricService metricService; - private final RefundAlertService alertService; - private final RefundAuditLogService auditLogService; - - /** - * 환불 계산 및 요청 - */ - public RefundCalculation calculateRefund(Long paymentId, RefundType refundType, - LocalDateTime trainDepartureTime, LocalDateTime trainArrivalTime, - String reason, String idempotencyKey) { - log.info("환불 계산 시작 - paymentId: {}, refundType: {}, idempotencyKey: {}", - paymentId, refundType, idempotencyKey); - - // 메트릭: 환불 시도 기록 - metricService.recordAttempt(); - - // 멱등성 체크: idempotencyKey가 있으면 기존 요청 확인 - if (idempotencyKey != null) { - Optional existing = refundCalculationRepository.findByIdempotencyKey(idempotencyKey); - if (existing.isPresent()) { - log.info("멱등성 키로 기존 환불 계산 반환 - idempotencyKey: {}, refundCalculationId: {}", - idempotencyKey, existing.get().getId()); - return existing.get(); - } - } - - // 1. 결제 정보 조회 - Payment payment = paymentRepository.findById(paymentId) - .orElseThrow(() -> new PaymentException("결제 정보를 찾을 수 없습니다: " + paymentId)); - - // 2. 환불 가능 여부 확인 - if (!payment.isRefundable()) { - log.warn("환불 불가 - paymentId: {}, status: {}", paymentId, payment.getPaymentStatus()); - metricService.recordDeniedInvalidStatus(); - throw new PaymentException("환불 가능한 상태가 아닙니다: " + payment.getPaymentStatus()); - } - - // 3. 이미 환불 계산이 있는지 확인 - Optional existingCalculation = refundCalculationRepository.findByPaymentId(paymentId); - if (existingCalculation.isPresent()) { - log.info("기존 환불 계산 반환 - paymentId: {}, refundCalculationId: {}", - paymentId, existingCalculation.get().getId()); - return existingCalculation.get(); - } - - // 4. Payment에 저장된 열차 정보 사용 (예약이 삭제되어도 환불 가능) - // 기존 결제는 trainScheduleId가 없을 수 있으므로 체크 - TrainScheduleService.TrainTimeInfo trainTimeInfo = null; - if (payment.getTrainScheduleId() != null) { - trainTimeInfo = trainScheduleService.getTrainTimeInfo(payment.getTrainScheduleId()); - } else { - // 하위 호환성을 위해 예약 정보에서 가져오기 시도 - try { - Reservation reservation = reservationRepository.findById(payment.getReservationId()) - .orElse(null); - if (reservation != null && reservation.getTrainSchedule() != null) { - trainTimeInfo = trainScheduleService.getTrainTimeInfo( - reservation.getTrainSchedule().getId()); - } - } catch (Exception e) { - log.warn("예약 정보 조회 실패, Payment에 저장된 시간 정보 사용 필요: {}", e.getMessage()); - } - } - - // 실제 도착 시간 사용 (actualArrivalTime이 null이면 예정 도착 시간 사용) - LocalDateTime actualArrivalTime = null; - LocalDateTime departureTime = null; - - if (trainTimeInfo != null) { - actualArrivalTime = trainTimeInfo.actualArrivalTime() != null - ? trainTimeInfo.actualArrivalTime() - : trainTimeInfo.scheduledArrivalTime(); - departureTime = trainTimeInfo.departureTime(); - } else if (payment.getTrainDepartureTime() != null && payment.getTrainArrivalTime() != null) { - // Payment에 저장된 시간 정보 사용 - departureTime = payment.getTrainDepartureTime(); - actualArrivalTime = payment.getTrainArrivalTime(); - log.info("Payment에 저장된 열차 시간 정보 사용 - departureTime: {}, arrivalTime: {}", - departureTime, actualArrivalTime); - } - - // 필수 시간 정보 검증 - 파라미터로 전달된 경우도 고려 - if (departureTime == null || actualArrivalTime == null) { - // 파라미터로 전달된 시간 정보 확인 - if (trainDepartureTime != null && trainArrivalTime != null) { - departureTime = trainDepartureTime; - actualArrivalTime = trainArrivalTime; - log.info("파라미터로 전달된 열차 시간 정보 사용 - departureTime: {}, arrivalTime: {}", - departureTime, actualArrivalTime); - } else { - throw new PaymentException("열차 시간 정보를 찾을 수 없습니다. paymentId: " + paymentId); - } - } - - // 파라미터로 받은 시간이 없으면 자동으로 조회한 시간 사용 - if (trainDepartureTime == null) { - trainDepartureTime = departureTime; - log.info("열차 출발 시간 자동 조회: {}", trainDepartureTime); - } - if (trainArrivalTime == null) { - trainArrivalTime = actualArrivalTime; - log.info("열차 도착 시간 자동 조회: {}", trainArrivalTime); - } - - // 5. 지연 정보 조회 - int delayMinutes = 0; - if (trainTimeInfo != null) { - delayMinutes = trainTimeInfo.delayMinutes(); - log.info("열차 지연 정보 - 지연시간: {}분", delayMinutes); - } - - // 6. 도착 시간 이후 환불 불가 체크 (지연 시간 고려) - LocalDateTime now = LocalDateTime.now(); - LocalDateTime refundDeadline = trainArrivalTime.plusMinutes(delayMinutes); // 지연 시간만큼 환불 가능 시간 연장 - - if (now.isAfter(refundDeadline)) { - log.warn("환불 불가 - 열차 도착 후 - paymentId: {}, 현재: {}, 도착: {}, 지연: {}분", - paymentId, now, trainArrivalTime, delayMinutes); - metricService.recordDeniedAfterArrival(); - alertService.alertRefundDenied(paymentId, "열차 도착 후 환불 시도"); - // 중요한 이벤트이므로 감사 로그 저장 - auditLogService.logRefundDeniedAfterArrival( - paymentId, - payment.getReservationId(), - payment.getMemberId(), - String.format("현재시간: %s, 도착시간: %s, 지연: %d분", now, trainArrivalTime, delayMinutes) - ); - - String message = delayMinutes > 0 - ? String.format("열차가 도착한 이후에는 환불이 불가능합니다. 도착 시간: %s (지연 %d분 포함)", - refundDeadline.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")), delayMinutes) - : String.format("열차가 도착한 이후에는 환불이 불가능합니다. 도착 시간: %s", - trainArrivalTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); - - throw new RefundDeniedException(message); - } - - // 6. 환불 수수료율 계산 (실제 도착 시간 기준) - BigDecimal refundFeeRate; - - // 강제 환불 (운행 취소, 변경 등)인 경우 수수료 없음 - if (refundType == RefundType.CHANGE) { - refundFeeRate = BigDecimal.ZERO; - } else { - // 기본 환불 정책 사용 (운영사 구분 없음) - RefundPolicyService refundPolicy = refundPolicyFactory.getDefaultPolicy(); - - // 환불 수수료율 계산 - refundFeeRate = refundPolicy.calculateRefundFeeRate(trainDepartureTime, trainArrivalTime, now); - - log.info("환불 정책 적용 - 정책: {}, 수수료율: {}%", - refundPolicy.getPolicyName(), refundFeeRate.multiply(BigDecimal.valueOf(100))); - } - - // 6. 환불 금액 계산 - BigDecimal originalAmount = payment.getAmountPaid(); - BigDecimal mileageUsed = payment.getMileagePointsUsed(); - BigDecimal refundFee = originalAmount.multiply(refundFeeRate); - BigDecimal refundAmount = originalAmount.subtract(refundFee); - - // 8. 환불 계산 생성 (실제 도착 시간 사용) - RefundCalculation refundCalculation = RefundCalculation.builder() - .paymentId(paymentId) - .reservationId(payment.getReservationId()) - .memberId(payment.getMemberId()) - .idempotencyKey(idempotencyKey != null ? idempotencyKey : generateIdempotencyKey(paymentId)) - .originalAmount(originalAmount) - .mileageUsed(mileageUsed) - .refundFeeRate(refundFeeRate) - .refundFee(refundFee) - .refundAmount(refundAmount) - .mileageRefundAmount(mileageUsed) // 사용한 마일리지는 전액 복원 - .trainDepartureTime(trainDepartureTime) // 파라미터 또는 자동 조회된 시간 - .trainArrivalTime(trainArrivalTime) // 파라미터 또는 자동 조회된 시간 - .refundRequestTime(now) - .refundType(refundType) - .refundStatus(RefundStatus.PENDING) - .refundReason(reason != null ? reason : "사용자 요청") // 환불 사유가 없으면 기본값 설정 - .build(); - - RefundCalculation savedRefundCalculation = refundCalculationRepository.save(refundCalculation); - - log.info("환불 계산 완료 - refundCalculationId: {}, refundAmount: {}, refundFee: {}", - savedRefundCalculation.getId(), refundAmount, refundFee); - - // 메트릭: 환불 계산 성공 - metricService.recordSuccess(); - - return savedRefundCalculation; - } - - /** - * 환불 처리 실행 - */ - public void processRefund(Long refundCalculationId) { - log.info("환불 처리 시작 - refundCalculationId: {}", refundCalculationId); - - try { - // 1. 환불 계산 조회 - RefundCalculation refundCalculation = refundCalculationRepository.findById(refundCalculationId) - .orElseThrow(() -> new PaymentException("환불 계산을 찾을 수 없습니다: " + refundCalculationId)); - - if (refundCalculation.getRefundStatus() != RefundStatus.PENDING) { - throw new PaymentException("처리 대기 상태가 아닙니다: " + refundCalculation.getRefundStatus()); - } - - // 2. 결제 정보 조회 - Payment payment = paymentRepository.findById(refundCalculation.getPaymentId()) - .orElseThrow(() -> new PaymentException("결제 정보를 찾을 수 없습니다: " + refundCalculation.getPaymentId())); - - // 3. 환불 처리 상태로 변경 - refundCalculation.updateRefundStatus(RefundStatus.PROCESSING); - refundCalculationRepository.save(refundCalculation); - - // 4. 마일리지 환불 처리 (사용한 마일리지 복원) - if (refundCalculation.getMileageUsed().compareTo(BigDecimal.ZERO) > 0) { - processMileageRefund(refundCalculation); - } - - // 5. PG사 환불 요청 (실제 구현 시 PG사 API 호출) - String pgRefundTransactionId; - try { - pgRefundTransactionId = processPgRefund(refundCalculation); - } catch (Exception e) { - // 네트워크 오류 등으로 결과를 알 수 없는 경우 - log.error("PG사 환불 요청 실패 - Unknown 상태로 저장", e); - refundCalculation.updateRefundStatus(RefundStatus.UNKNOWN); - refundCalculationRepository.save(refundCalculation); - alertService.alertRefundFailure(payment.getId(), "PG 통신 오류", e.getMessage()); - // Unknown 상태도 중요하므로 기록 - auditLogService.logUnknownState(payment.getId(), e.getMessage()); - throw new PaymentException("PG사 통신 오류로 환불 상태를 확인할 수 없습니다. 잠시 후 재시도됩니다."); - } - - // 6. Payment 엔티티 환불 처리 - PaymentExecutionStatus previousStatus = payment.getPaymentStatus(); - - Payment.RefundRequest refundRequest = Payment.RefundRequest.builder() - .refundAmount(refundCalculation.getRefundAmount()) - .refundFee(refundCalculation.getRefundFee()) - .reason(refundCalculation.getRefundReason()) - .pgTransactionId(pgRefundTransactionId) - .pgApprovalNo("REFUND_" + System.currentTimeMillis()) - .build(); - payment.processRefund(refundRequest); - paymentRepository.save(payment); - - // 7. 환불 상태 변경 이벤트 발행 - // PaymentEventTranslator가 PaymentStateChangedEvent를 수신하여 - // BookingPaymentRefundedEvent로 변환하므로 추가 이벤트 발행 불필요 - eventPublisher.publishPaymentStateChanged( - payment, - previousStatus, - PaymentExecutionStatus.REFUNDED, - refundCalculation.getRefundReason(), - "ADMIN" - ); - - // 8. 적립된 마일리지 회수 (FULLY_COMPLETED 상태의 경우) - 취소 전에 먼저 회수 - recoverEarnedMileage(payment.getId()); - - // 8-1. 마일리지 적립 스케줄 취소 - log.info("마일리지 적립 스케줄 취소 호출 예정 - paymentId: {}", payment.getId()); - cancelMileageEarningSchedule(payment.getId().toString()); - - // 9. 환불 완료 처리 - refundCalculation.markAsProcessed(); - refundCalculationRepository.save(refundCalculation); - - log.info("환불 처리 완료 - refundCalculationId: {}, pgRefundTransactionId: {}, paymentId: {}", - refundCalculationId, pgRefundTransactionId, payment.getId()); - - } catch (Exception e) { - log.error("환불 처리 실패 - refundCalculationId: {}", refundCalculationId, e); - - // 환불 계산이 이미 조회된 경우에만 실패 상태로 변경 - try { - RefundCalculation refundCalculation = refundCalculationRepository.findById(refundCalculationId) - .orElse(null); - if (refundCalculation != null) { - refundCalculation.markAsFailed(e.getMessage()); - refundCalculationRepository.save(refundCalculation); - } - } catch (Exception saveException) { - log.error("환불 실패 상태 저장 중 오류 발생", saveException); - } - - throw new PaymentException("환불 처리 중 오류가 발생했습니다: " + e.getMessage()); - } - } - - /** - * 마일리지 환불 처리 - */ - private void processMileageRefund(RefundCalculation refundCalculation) { - if (refundCalculation.getMemberId() == null) { - return; // 비회원은 마일리지 환불 불가 - } - - // 사용한 마일리지 복원 - if (refundCalculation.getMileageUsed().compareTo(BigDecimal.ZERO) > 0) { - // 현재 회원의 마일리지 잔액 조회 - BigDecimal balanceBefore = loadMemberInfoPort.getMileageBalance(refundCalculation.getMemberId()); - if (balanceBefore == null) { - log.warn("회원 마일리지 잔액이 null입니다. 0으로 설정합니다. 회원ID: {}", refundCalculation.getMemberId()); - balanceBefore = BigDecimal.ZERO; - } - - // 환불 후 잔액 계산 - BigDecimal balanceAfter = balanceBefore.add(refundCalculation.getMileageUsed()); - - MileageTransaction refundTransaction = MileageTransaction.builder() - .memberId(refundCalculation.getMemberId()) - .type(MileageTransaction.TransactionType.REFUND) - .pointsAmount(refundCalculation.getMileageUsed()) - .balanceBefore(balanceBefore) - .balanceAfter(balanceAfter) - .description("환불로 인한 마일리지 복원 - 예약번호: " + refundCalculation.getReservationId()) - .paymentId(refundCalculation.getPaymentId().toString()) - .expiresAt(LocalDateTime.now().plusYears(5)) // 5년 유효 - .status(MileageTransaction.TransactionStatus.COMPLETED) - .processedAt(LocalDateTime.now()) - .build(); - - mileageTransactionRepository.save(refundTransaction); - - // 회원의 마일리지 잔액 복원 - saveMemberInfoPort.addMileage(refundCalculation.getMemberId(), - refundCalculation.getMileageUsed().longValue()); - - log.info("사용한 마일리지 환불 완료 - memberId: {}, amount: {}", - refundCalculation.getMemberId(), refundCalculation.getMileageUsed()); - } - - // 적립된 마일리지 회수 처리 - recoverEarnedMileage(refundCalculation.getPaymentId()); - } - - /** - * PG사 환불 요청 (Mock 구현) - */ - private String processPgRefund(RefundCalculation refundCalculation) { - // 실제 구현 시 PG사 API 호출 - // 현재는 Mock 응답 반환 - String mockRefundTransactionId = "REFUND_" + System.currentTimeMillis(); - - log.info("PG 환불 요청 완료 - refundAmount: {}, transactionId: {}", - refundCalculation.getRefundAmount(), mockRefundTransactionId); - - return mockRefundTransactionId; - } - - /** - * 환불 계산 조회 - */ - @Transactional(readOnly = true) - public Optional getRefundCalculation(Long refundCalculationId) { - return refundCalculationRepository.findById(refundCalculationId); - } - - /** - * 결제별 환불 계산 조회 - */ - @Transactional(readOnly = true) - public Optional getRefundCalculationByPaymentId(Long paymentId) { - return refundCalculationRepository.findByPaymentId(paymentId); - } - - /** - * 회원별 환불 내역 조회 - */ - @Transactional(readOnly = true) - public List getRefundHistoryByMember(Long memberId) { - return refundCalculationRepository.findByMemberId(memberId); - } - - /** - * 처리 대기 중인 환불 목록 조회 - */ - @Transactional(readOnly = true) - public List getPendingRefunds() { - return refundCalculationRepository.findPendingRefunds(); - } - - /** - * 환불 취소 - */ - public void cancelRefund(Long refundCalculationId, String reason) { - RefundCalculation refundCalculation = refundCalculationRepository.findById(refundCalculationId) - .orElseThrow(() -> new PaymentException("환불 계산을 찾을 수 없습니다: " + refundCalculationId)); - - if (refundCalculation.getRefundStatus() != RefundStatus.PENDING) { - throw new PaymentException("대기 상태의 환불만 취소할 수 있습니다: " + refundCalculation.getRefundStatus()); - } - - refundCalculation.updateRefundStatus(RefundStatus.CANCELLED); - refundCalculation.updateRefundReason(reason); - refundCalculationRepository.save(refundCalculation); - - log.info("환불 취소 완료 - refundCalculationId: {}, reason: {}", refundCalculationId, reason); - } - - /** - * 마일리지 적립 스케줄 취소 - * 환불 처리 시 예정된 마일리지 적립을 취소 - */ - private void cancelMileageEarningSchedule(String paymentId) { - log.info("마일리지 적립 스케줄 취소 시작 - paymentId: {}", paymentId); - - try { - Optional schedule = - mileageEarningScheduleRepository.findByPaymentId(paymentId); - - if (schedule.isPresent()) { - MileageEarningSchedule earningSchedule = schedule.get(); - log.info("마일리지 적립 스케줄 조회됨 - 스케줄ID: {}, 현재 상태: {}, 결제ID: {}", - earningSchedule.getId(), earningSchedule.getStatus(), paymentId); - - // 모든 상태의 스케줄을 취소 처리 (CANCELLED 제외) - if (earningSchedule.getStatus() != MileageEarningSchedule.EarningStatus.CANCELLED) { - - log.info("마일리지 적립 스케줄 취소 진행 - 스케줄ID: {}, 이전 상태: {}", - earningSchedule.getId(), earningSchedule.getStatus()); - - // 취소 상태로 변경 - earningSchedule.cancel("환불로 인한 마일리지 적립 취소"); - MileageEarningSchedule savedSchedule = mileageEarningScheduleRepository.save(earningSchedule); - - log.info("마일리지 적립 스케줄 취소 완료 - 스케줄ID: {}, 변경된 상태: {}, 결제ID: {}", - savedSchedule.getId(), savedSchedule.getStatus(), paymentId); - } else { - log.info("마일리지 적립 스케줄이 이미 취소됨 - 스케줄ID: {}, 상태: {}", - earningSchedule.getId(), earningSchedule.getStatus()); - } - } else { - log.warn("취소할 마일리지 적립 스케줄이 없음 - 결제ID: {}", paymentId); - } - } catch (Exception e) { - log.error("마일리지 적립 스케줄 취소 중 오류 발생 - 결제ID: {}", paymentId, e); - // 마일리지 스케줄 취소 실패가 환불 전체를 실패시키지 않도록 예외를 전파하지 않음 - } - } - - /** - * 적립된 마일리지 회수 - * 이미 적립된 마일리지를 차감하는 거래 생성 - */ - private void recoverEarnedMileage(Long paymentId) { - log.info("적립된 마일리지 회수 시작 - 결제ID: {}", paymentId); - - try { - Optional schedule = - mileageEarningScheduleRepository.findByPaymentId(paymentId.toString()); - - if (schedule.isPresent()) { - MileageEarningSchedule earningSchedule = schedule.get(); - log.info("마일리지 적립 스케줄 조회됨 - 스케줄ID: {}, 현재 상태: {}, 회원ID: {}", - earningSchedule.getId(), earningSchedule.getStatus(), earningSchedule.getMemberId()); - - // FULLY_COMPLETED 상태인 경우 이미 적립된 마일리지 회수 - if (earningSchedule.getStatus() == MileageEarningSchedule.EarningStatus.FULLY_COMPLETED || - earningSchedule.getStatus() == MileageEarningSchedule.EarningStatus.BASE_COMPLETED) { - - BigDecimal earnedAmount = earningSchedule.getTotalMileageAmount(); - log.info("적립된 마일리지 회수 진행 - 총 적립액: {}P (기본: {}P, 지연보상: {}P)", - earnedAmount, - earningSchedule.getBaseMileageAmount(), - earningSchedule.getDelayCompensationAmount()); - - // 현재 회원의 마일리지 잔액 조회 - BigDecimal balanceBefore = loadMemberInfoPort.getMileageBalance(earningSchedule.getMemberId()); - // null 체크 및 기본값 설정 - if (balanceBefore == null) { - log.warn("회원 마일리지 잔액이 null입니다. 0으로 설정합니다. 회원ID: {}", earningSchedule.getMemberId()); - balanceBefore = BigDecimal.ZERO; - } - log.info("회원 마일리지 잔액 조회 - 회원ID: {}, 현재잔액: {}P", - earningSchedule.getMemberId(), balanceBefore); - - // 잔액 계산 - BigDecimal balanceAfter = balanceBefore.subtract(earnedAmount); - - // 마일리지 차감 거래 생성 (USE 타입은 음수로 저장) - MileageTransaction recoveryTransaction = MileageTransaction.builder() - .memberId(earningSchedule.getMemberId()) - .type(MileageTransaction.TransactionType.USE) - .pointsAmount(earnedAmount.negate()) // 음수로 저장 - .balanceBefore(balanceBefore) - .balanceAfter(balanceAfter) - .description("환불로 인한 적립 마일리지 회수 - 결제ID: " + paymentId) - .paymentId(paymentId.toString()) - .status(MileageTransaction.TransactionStatus.COMPLETED) - .processedAt(LocalDateTime.now()) - .build(); - - MileageTransaction savedTransaction = mileageTransactionRepository.save(recoveryTransaction); - log.info("마일리지 차감 거래 저장 완료 - 거래ID: {}, 차감액: {}P", - savedTransaction.getId(), earnedAmount); - - // 회원의 마일리지 잔액 차감 - saveMemberInfoPort.useMileage(earningSchedule.getMemberId(), earnedAmount.longValue()); - log.info("회원 마일리지 잔액 차감 완료 - 회원ID: {}, 차감액: {}P", - earningSchedule.getMemberId(), earnedAmount); - - // 스케줄 상태를 CANCELLED로 변경 - earningSchedule.cancel("환불로 인한 적립 마일리지 회수"); - MileageEarningSchedule savedSchedule = mileageEarningScheduleRepository.save(earningSchedule); - log.info("적립 스케줄 취소 완료 - 스케줄ID: {}, 변경된 상태: {}", - savedSchedule.getId(), savedSchedule.getStatus()); - - log.info("적립된 마일리지 회수 완료 - 스케줄ID: {}, 회수액: {}P", - earningSchedule.getId(), earnedAmount); - } else { - log.info("마일리지 회수 불필요 - 스케줄 상태가 완료되지 않음: {}", earningSchedule.getStatus()); - } - } else { - log.warn("마일리지 적립 스케줄이 없음 - 결제ID: {}", paymentId); - } - } catch (Exception e) { - log.error("적립된 마일리지 회수 중 오류 발생 - 결제ID: {}", paymentId, e); - // 마일리지 회수 실패가 환불 전체를 실패시키지 않도록 예외를 전파하지 않음 - } - } - - - /** - * 멱등성 키 자동 생성 - * 동일한 paymentId와 시간으로 생성하여 중복 방지 - */ - private String generateIdempotencyKey(Long paymentId) { - return String.format("refund_%d_%d_%s", - paymentId, - System.currentTimeMillis() / 1000, // 초 단위 - UUID.randomUUID().toString().substring(0, 8)); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/SavedPaymentMethodService.java b/src/main/java/com/sudo/railo/payment/application/service/SavedPaymentMethodService.java deleted file mode 100644 index aaf1dffc..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/SavedPaymentMethodService.java +++ /dev/null @@ -1,299 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.dto.SavedPaymentMethodRequestDto; -import com.sudo.railo.payment.application.dto.SavedPaymentMethodResponseDto; -import com.sudo.railo.payment.domain.entity.SavedPaymentMethod; -import com.sudo.railo.payment.domain.repository.SavedPaymentMethodRepository; -import com.sudo.railo.payment.exception.PaymentNotFoundException; -import com.sudo.railo.payment.exception.PaymentValidationException; -import com.sudo.railo.payment.infrastructure.security.PaymentCryptoService; -import com.sudo.railo.payment.infrastructure.security.PaymentSecurityAuditService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.security.core.userdetails.UserDetails; -import com.sudo.railo.member.infra.MemberRepository; -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.member.exception.MemberError; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.stream.Collectors; - -/** - * 저장된 결제수단 관리 서비스 - * - * 민감한 결제 정보를 안전하게 암호화하여 저장하고 조회하는 서비스 - */ -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class SavedPaymentMethodService { - - private final SavedPaymentMethodRepository repository; - private final PaymentCryptoService cryptoService; - private final PaymentSecurityAuditService auditService; - private final MemberRepository memberRepository; - - private static final String CURRENT_ENCRYPTION_VERSION = "1.0"; - - /** - * 새로운 결제수단 저장 (암호화 적용) - */ - @Transactional - public SavedPaymentMethodResponseDto savePaymentMethod(SavedPaymentMethodRequestDto request, UserDetails userDetails) { - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - // DTO에 memberId 설정 - request.setMemberId(memberId); - - log.info("Saving new payment method for member: {}", memberId); - - // 중복 체크 (해시값으로 검색) - String hash = getHashForPaymentMethod(request); - if (repository.existsByMemberIdAndHash(request.getMemberId(), hash)) { - throw new IllegalArgumentException("이미 등록된 결제수단입니다."); - } - - // 엔티티 생성 및 암호화 적용 - SavedPaymentMethod paymentMethod = buildEncryptedPaymentMethod(request); - - // 기본 결제수단 설정 처리 - if (request.getIsDefault() != null && request.getIsDefault()) { - repository.updateAllToNotDefault(request.getMemberId()); - paymentMethod.setAsDefault(); - } - - SavedPaymentMethod saved = repository.save(paymentMethod); - - // 감사 로깅 - auditService.logPaymentMethodSaved(saved.getMemberId(), saved.getPaymentMethodType()); - - return toResponseDto(saved, false); // 저장 직후에는 마스킹된 데이터만 반환 - } - - /** - * 회원의 저장된 결제수단 목록 조회 (마스킹 적용) - */ - public List getPaymentMethods(UserDetails userDetails) { - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.debug("Retrieving payment methods for member: {}", memberId); - - List methods = repository.findByMemberIdAndIsActive(memberId, true); - - return methods.stream() - .map(method -> toResponseDto(method, false)) // 마스킹된 데이터만 반환 - .collect(Collectors.toList()); - } - - /** - * 결제 실행을 위한 결제수단 조회 (복호화 적용) - * 특별한 권한이 필요하며 감사 로그를 남김 - */ - @Transactional - public SavedPaymentMethodResponseDto getPaymentMethodForPayment(Long paymentMethodId, UserDetails userDetails) { - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.info("Retrieving payment method for payment execution: {}", paymentMethodId); - - SavedPaymentMethod method = repository.findById(paymentMethodId) - .orElseThrow(() -> new PaymentNotFoundException("결제수단을 찾을 수 없습니다.")); - - // 소유자 확인 - if (!method.getMemberId().equals(memberId)) { - auditService.logSecurityViolation("UNAUTHORIZED_ACCESS", - "Member " + memberId + " tried to access payment method " + paymentMethodId); - throw new SecurityException("결제수단에 대한 접근 권한이 없습니다."); - } - - // 사용 시간 업데이트 - method.updateLastUsedAt(); - repository.save(method); - - // 감사 로깅 - auditService.logSensitiveDataAccess("PAYMENT_METHOD", "PAYMENT_EXECUTION"); - - return toResponseDto(method, true); // 실제 결제 시에만 복호화된 데이터 반환 - } - - /** - * 결제수단 삭제 (소프트 삭제) - */ - @Transactional - public void deletePaymentMethod(Long paymentMethodId, UserDetails userDetails) { - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.info("Deleting payment method: {}", paymentMethodId); - - SavedPaymentMethod method = repository.findById(paymentMethodId) - .orElseThrow(() -> new PaymentNotFoundException("결제수단을 찾을 수 없습니다.")); - - // 소유자 확인 - if (!method.getMemberId().equals(memberId)) { - throw new SecurityException("결제수단에 대한 접근 권한이 없습니다."); - } - - // 소프트 삭제 - method.deactivate(); - repository.save(method); - - // 감사 로깅 - auditService.logPaymentMethodDeleted(paymentMethodId); - } - - /** - * 기본 결제수단 설정 - */ - @Transactional - public void setDefaultPaymentMethod(Long paymentMethodId, UserDetails userDetails) { - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - log.info("Setting default payment method: {}", paymentMethodId); - - SavedPaymentMethod method = repository.findById(paymentMethodId) - .orElseThrow(() -> new PaymentNotFoundException("결제수단을 찾을 수 없습니다.")); - - // 소유자 확인 - if (!method.getMemberId().equals(memberId)) { - throw new SecurityException("결제수단에 대한 접근 권한이 없습니다."); - } - - // 기존 기본 결제수단 해제 - repository.updateAllToNotDefault(memberId); - - // 새로운 기본 결제수단 설정 - method.setAsDefault(); - repository.save(method); - } - - /** - * 암호화된 결제수단 엔티티 생성 - */ - private SavedPaymentMethod buildEncryptedPaymentMethod(SavedPaymentMethodRequestDto request) { - SavedPaymentMethod.SavedPaymentMethodBuilder builder = SavedPaymentMethod.builder() - .memberId(request.getMemberId()) - .paymentMethodType(request.getPaymentMethodType()) - .alias(request.getAlias()) - .isDefault(false) - .isActive(true) - .encryptionVersion(CURRENT_ENCRYPTION_VERSION); - - // 카드 정보 암호화 - if (request.getCardNumber() != null) { - String cleanCardNumber = request.getCardNumber().replaceAll("[^0-9]", ""); - builder.cardNumberEncrypted(cryptoService.encrypt(cleanCardNumber)) - .cardNumberHash(cryptoService.hash(cleanCardNumber)) - .cardLastFourDigits(cleanCardNumber.substring(cleanCardNumber.length() - 4)); - - if (request.getCardHolderName() != null) { - builder.cardHolderNameEncrypted(cryptoService.encrypt(request.getCardHolderName())); - } - if (request.getCardExpiryMonth() != null) { - builder.cardExpiryMonthEncrypted(cryptoService.encrypt(request.getCardExpiryMonth())); - } - if (request.getCardExpiryYear() != null) { - builder.cardExpiryYearEncrypted(cryptoService.encrypt(request.getCardExpiryYear())); - } - } - - // 계좌 정보 암호화 - if (request.getAccountNumber() != null) { - String cleanAccountNumber = request.getAccountNumber().replaceAll("[^0-9]", ""); - builder.accountNumberEncrypted(cryptoService.encrypt(cleanAccountNumber)) - .accountNumberHash(cryptoService.hash(cleanAccountNumber)) - .accountLastFourDigits(cleanAccountNumber.substring(cleanAccountNumber.length() - 4)) - .bankCode(request.getBankCode()); - - if (request.getAccountHolderName() != null) { - builder.accountHolderNameEncrypted(cryptoService.encrypt(request.getAccountHolderName())); - } - - // 계좌 비밀번호 암호화 - if (request.getAccountPassword() != null) { - builder.accountPasswordEncrypted(cryptoService.encrypt(request.getAccountPassword())); - } - } - - return builder.build(); - } - - /** - * 엔티티를 응답 DTO로 변환 - */ - private SavedPaymentMethodResponseDto toResponseDto(SavedPaymentMethod entity, boolean includeDecrypted) { - SavedPaymentMethodResponseDto.SavedPaymentMethodResponseDtoBuilder builder = - SavedPaymentMethodResponseDto.builder() - .id(entity.getId()) - .memberId(entity.getMemberId()) - .paymentMethodType(entity.getPaymentMethodType()) - .alias(entity.getAlias()) - .isDefault(entity.getIsDefault()) - .isActive(entity.getIsActive()) - .lastUsedAt(entity.getLastUsedAt()) - .createdAt(entity.getCreatedAt()); - - if (entity.isCard()) { - builder.maskedCardNumber(entity.getMaskedCardNumber()); - - if (includeDecrypted && entity.getCardNumberEncrypted() != null) { - // 복호화 - 실제 결제 시에만 - builder.cardNumber(cryptoService.decrypt(entity.getCardNumberEncrypted())); - if (entity.getCardHolderNameEncrypted() != null) { - builder.cardHolderName(cryptoService.decrypt(entity.getCardHolderNameEncrypted())); - } - if (entity.getCardExpiryMonthEncrypted() != null) { - builder.cardExpiryMonth(cryptoService.decrypt(entity.getCardExpiryMonthEncrypted())); - } - if (entity.getCardExpiryYearEncrypted() != null) { - builder.cardExpiryYear(cryptoService.decrypt(entity.getCardExpiryYearEncrypted())); - } - } - } - - if (entity.isAccount()) { - builder.maskedAccountNumber(entity.getMaskedAccountNumber()) - .bankCode(entity.getBankCode()); - - if (includeDecrypted && entity.getAccountNumberEncrypted() != null) { - // 복호화 - 실제 결제 시에만 - builder.accountNumber(cryptoService.decrypt(entity.getAccountNumberEncrypted())); - if (entity.getAccountHolderNameEncrypted() != null) { - builder.accountHolderName(cryptoService.decrypt(entity.getAccountHolderNameEncrypted())); - } - } - } - - return builder.build(); - } - - /** - * 결제수단 해시값 생성 (중복 체크용) - */ - private String getHashForPaymentMethod(SavedPaymentMethodRequestDto request) { - if (request.getCardNumber() != null) { - return cryptoService.hash(request.getCardNumber().replaceAll("[^0-9]", "")); - } else if (request.getAccountNumber() != null) { - return cryptoService.hash(request.getAccountNumber().replaceAll("[^0-9]", "")); - } - return null; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/service/TrainArrivalMonitorService.java b/src/main/java/com/sudo/railo/payment/application/service/TrainArrivalMonitorService.java deleted file mode 100644 index 15ac9a95..00000000 --- a/src/main/java/com/sudo/railo/payment/application/service/TrainArrivalMonitorService.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.sudo.railo.payment.application.service; - -import com.sudo.railo.payment.application.service.DomainEventOutboxService; -import com.sudo.railo.payment.application.port.in.UpdateMileageEarningScheduleUseCase; -import com.sudo.railo.train.domain.TrainSchedule; -import com.sudo.railo.train.infrastructure.TrainScheduleRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * 열차 도착 모니터링 서비스 - * TrainSchedule을 모니터링하여 도착한 열차를 체크하고 - * 마일리지 적립 스케줄을 준비 상태로 변경 - * - * 향후 확장 계획: - * - KTX 관제시스템 API 연동 - * - 실시간 열차 위치 및 도착 정보 수신 - * - 자동 스케줄러를 통한 실시간 마일리지 처리 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class TrainArrivalMonitorService { - - private final TrainScheduleRepository trainScheduleRepository; - private final UpdateMileageEarningScheduleUseCase updateMileageEarningScheduleUseCase; - private final DomainEventOutboxService domainEventOutboxService; - - /** - * 도착한 열차들을 체크하고 처리 - * @return 처리된 열차 수 - */ - @Transactional - public int checkAndProcessArrivedTrains() { - log.debug("도착한 열차 체크 및 처리 시작"); - - LocalDateTime currentTime = LocalDateTime.now(); - - // 마일리지 처리가 필요한 도착한 열차 조회 - List arrivedTrains = trainScheduleRepository - .findArrivedTrainsForMileageProcessing(currentTime); - - // 배치 크기 제한 (50개씩 처리) - if (arrivedTrains.size() > 50) { - arrivedTrains = arrivedTrains.subList(0, 50); - } - - int processedCount = 0; - - for (TrainSchedule train : arrivedTrains) { - try { - processArrivedTrain(train); - processedCount++; - } catch (Exception e) { - log.error("열차 도착 처리 실패 - TrainScheduleId: {}", train.getId(), e); - } - } - - log.debug("도착한 열차 체크 및 처리 완료 - 처리된 열차 수: {}", processedCount); - - return processedCount; - } - - /** - * 개별 도착한 열차 처리 - */ - @Transactional - public void processArrivedTrain(TrainSchedule trainSchedule) { - log.info("열차 도착 처리 시작 - TrainScheduleId: {}, 도착시간: {}", - trainSchedule.getId(), trainSchedule.getActualArrivalTime()); - - try { - // 1. 열차 도착 이벤트 발행 - domainEventOutboxService.publishTrainArrivedEvent( - trainSchedule.getId(), - trainSchedule.getActualArrivalTime() - ); - - // 2. 지연이 있는 경우 지연 이벤트도 발행 - if (trainSchedule.hasSignificantDelay()) { - domainEventOutboxService.publishTrainDelayedEvent( - trainSchedule.getId(), - trainSchedule.getDelayMinutes(), - trainSchedule.getActualArrivalTime() - ); - - // 지연 보상 마일리지 스케줄 업데이트 - UpdateMileageEarningScheduleUseCase.UpdateDelayCompensationCommand delayCommand = - new UpdateMileageEarningScheduleUseCase.UpdateDelayCompensationCommand( - trainSchedule.getId(), - trainSchedule.getDelayMinutes(), - trainSchedule.getActualArrivalTime() - ); - updateMileageEarningScheduleUseCase.updateDelayCompensation(delayCommand); - } - - // 3. 마일리지 적립 스케줄을 READY 상태로 변경 - UpdateMileageEarningScheduleUseCase.MarkScheduleReadyCommand readyCommand = - new UpdateMileageEarningScheduleUseCase.MarkScheduleReadyCommand( - trainSchedule.getId(), - trainSchedule.getActualArrivalTime() - ); - updateMileageEarningScheduleUseCase.markScheduleReady(readyCommand); - - // 4. TrainSchedule의 마일리지 처리 완료 표시 - trainSchedule.markMileageProcessed(); - trainScheduleRepository.save(trainSchedule); - - log.info("열차 도착 처리 완료 - TrainScheduleId: {}", trainSchedule.getId()); - - } catch (Exception e) { - log.error("열차 도착 처리 중 오류 발생 - TrainScheduleId: {}", trainSchedule.getId(), e); - throw e; - } - } - - /** - * 임시 메서드: Train 도메인 협업 전까지 사용 - */ - private void processArrivedTrainMock(Long trainScheduleId, LocalDateTime actualArrivalTime, int delayMinutes) { - log.info("열차 도착 처리 시작 (Mock) - TrainScheduleId: {}, 도착시간: {}", - trainScheduleId, actualArrivalTime); - - try { - // 1. 열차 도착 이벤트 발행 - domainEventOutboxService.publishTrainArrivedEvent(trainScheduleId, actualArrivalTime); - - // 2. 지연이 있는 경우 지연 이벤트도 발행 - if (delayMinutes >= 20) { - domainEventOutboxService.publishTrainDelayedEvent( - trainScheduleId, delayMinutes, actualArrivalTime); - - // 지연 보상 마일리지 스케줄 업데이트 - UpdateMileageEarningScheduleUseCase.UpdateDelayCompensationCommand delayCommand = - new UpdateMileageEarningScheduleUseCase.UpdateDelayCompensationCommand( - trainScheduleId, delayMinutes, actualArrivalTime); - updateMileageEarningScheduleUseCase.updateDelayCompensation(delayCommand); - } - - // 3. 마일리지 적립 스케줄을 READY 상태로 변경 - UpdateMileageEarningScheduleUseCase.MarkScheduleReadyCommand readyCommand = - new UpdateMileageEarningScheduleUseCase.MarkScheduleReadyCommand( - trainScheduleId, actualArrivalTime); - updateMileageEarningScheduleUseCase.markScheduleReady(readyCommand); - - log.info("열차 도착 처리 완료 (Mock) - TrainScheduleId: {}", trainScheduleId); - - } catch (Exception e) { - log.error("열차 도착 처리 중 오류 발생 (Mock) - TrainScheduleId: {}", trainScheduleId, e); - throw e; - } - } - - /** - * 테스트용 메서드: 임의의 열차 도착 시뮬레이션 - */ - @Transactional - public void simulateTrainArrival(Long trainScheduleId, LocalDateTime actualArrivalTime, int delayMinutes) { - log.info("열차 도착 시뮬레이션 - TrainScheduleId: {}, 지연: {}분", trainScheduleId, delayMinutes); - - processArrivedTrainMock(trainScheduleId, actualArrivalTime, delayMinutes); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/translator/PaymentEventTranslator.java b/src/main/java/com/sudo/railo/payment/application/translator/PaymentEventTranslator.java deleted file mode 100644 index c6bd2836..00000000 --- a/src/main/java/com/sudo/railo/payment/application/translator/PaymentEventTranslator.java +++ /dev/null @@ -1,121 +0,0 @@ -package com.sudo.railo.payment.application.translator; - -import com.sudo.railo.booking.application.event.BookingPaymentCancelledEvent; -import com.sudo.railo.booking.application.event.BookingPaymentCompletedEvent; -import com.sudo.railo.booking.application.event.BookingPaymentFailedEvent; -import com.sudo.railo.booking.application.event.BookingPaymentRefundedEvent; -import com.sudo.railo.payment.application.event.PaymentStateChangedEvent; -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; - -/** - * 결제 이벤트를 예약 도메인 이벤트로 변환하는 트랜슬레이터 - * - * Event Translator 패턴을 구현하여 Payment 도메인의 이벤트를 - * Booking 도메인이 이해할 수 있는 이벤트로 변환합니다. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class PaymentEventTranslator { - - private final ApplicationEventPublisher eventPublisher; - - /** - * 결제 상태 변경 이벤트를 처리하고 적절한 예약 이벤트로 변환 - */ - @EventListener - public void handlePaymentStateChanged(PaymentStateChangedEvent event) { - try { - log.info("🎯 [PaymentEventTranslator] 이벤트 수신 - paymentId: {}, reservationId: {}, {} → {}", - event.getPaymentId(), event.getReservationId(), event.getPreviousStatus(), event.getNewStatus()); - - // null 상태 체크 - if (event.getNewStatus() == null) { - log.warn("❌ [PaymentEventTranslator] 결제 상태가 null입니다 - paymentId: {}", event.getPaymentId()); - return; - } - - // 새로운 상태에 따라 적절한 예약 이벤트 발행 - switch (event.getNewStatus()) { - case SUCCESS: - log.info("✅ [PaymentEventTranslator] SUCCESS 상태 감지 - 결제 완료 이벤트 발행 시작"); - publishCompletedEvent(event); - break; - case FAILED: - log.info("❌ [PaymentEventTranslator] FAILED 상태 감지 - 결제 실패 이벤트 발행 시작"); - publishFailedEvent(event); - break; - case CANCELLED: - log.info("🚫 [PaymentEventTranslator] CANCELLED 상태 감지 - 결제 취소 이벤트 발행 시작"); - publishCancelledEvent(event); - break; - case REFUNDED: - log.info("💸 [PaymentEventTranslator] REFUNDED 상태 감지 - 결제 환불 이벤트 발행 시작"); - publishRefundedEvent(event); - break; - default: - log.debug("🔍 [PaymentEventTranslator] 이벤트 변환 대상이 아닌 상태 - status: {}", event.getNewStatus()); - } - } catch (Exception e) { - log.error("❌ [PaymentEventTranslator] 이벤트 처리 중 오류 발생 - paymentId: {}, reservationId: {}", - event.getPaymentId(), event.getReservationId(), e); - // 예외를 전파하지 않고 로그만 남김 - } - } - - private void publishCompletedEvent(PaymentStateChangedEvent event) { - BookingPaymentCompletedEvent completedEvent = BookingPaymentCompletedEvent.builder() - .paymentId(event.getPaymentId()) - .reservationId(event.getReservationId()) - .completedAt(event.getChangedAt()) - .build(); - - eventPublisher.publishEvent(completedEvent); - log.info("🎊 [PaymentEventTranslator] BookingPaymentCompletedEvent 발행 완료 - paymentId: {}, reservationId: {}", - event.getPaymentId(), event.getReservationId()); - } - - private void publishFailedEvent(PaymentStateChangedEvent event) { - BookingPaymentFailedEvent failedEvent = BookingPaymentFailedEvent.builder() - .paymentId(event.getPaymentId()) - .reservationId(event.getReservationId()) - .failedAt(event.getChangedAt()) - .reason(event.getReason()) - .build(); - - eventPublisher.publishEvent(failedEvent); - log.info("결제 실패 이벤트 발행 - reservationId: {}, reason: {}", - event.getReservationId(), event.getReason()); - } - - private void publishCancelledEvent(PaymentStateChangedEvent event) { - BookingPaymentCancelledEvent cancelledEvent = BookingPaymentCancelledEvent.builder() - .paymentId(event.getPaymentId()) - .reservationId(event.getReservationId()) - .cancelledAt(event.getChangedAt()) - .reason(event.getReason()) - .build(); - - eventPublisher.publishEvent(cancelledEvent); - log.info("결제 취소 이벤트 발행 - reservationId: {}, reason: {}", - event.getReservationId(), event.getReason()); - } - - private void publishRefundedEvent(PaymentStateChangedEvent event) { - BookingPaymentRefundedEvent refundedEvent = BookingPaymentRefundedEvent.builder() - .paymentId(event.getPaymentId()) - .reservationId(event.getReservationId()) - .refundedAt(event.getChangedAt()) - .reason(event.getReason()) - .build(); - - eventPublisher.publishEvent(refundedEvent); - log.info("결제 환불 이벤트 발행 - reservationId: {}, reason: {}", - event.getReservationId(), event.getReason()); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/application/util/PaymentUtils.java b/src/main/java/com/sudo/railo/payment/application/util/PaymentUtils.java deleted file mode 100644 index d985775d..00000000 --- a/src/main/java/com/sudo/railo/payment/application/util/PaymentUtils.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.sudo.railo.payment.application.util; - -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.exception.PaymentValidationException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.function.Supplier; - -/** - * 결제 도메인 공통 유틸리티 클래스 - * 중복 코드 제거 및 공통 로직 관리 - */ -@Slf4j -@Component -public class PaymentUtils { - - /** - * 전화번호 마스킹 처리 - * @param phone 원본 전화번호 - * @return 마스킹된 전화번호 - */ - public static String maskPhoneNumber(String phone) { - if (phone == null || phone.length() < 7) { - return "****"; - } - return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); - } - - /** - * 결제 소유권 검증 - * @param payment 결제 정보 - * @param memberId 회원 ID - * @throws PaymentValidationException 본인 결제가 아닌 경우 - */ - public static void validatePaymentOwnership(Payment payment, Long memberId) { - if (payment.getMemberId() == null) { - throw new PaymentValidationException("회원 결제 정보가 아닙니다"); - } - if (!payment.getMemberId().equals(memberId)) { - throw new PaymentValidationException("본인의 결제 내역만 조회할 수 있습니다"); - } - } - - /** - * 재시도 로직을 포함한 작업 실행 - * @param operation 실행할 작업 - * @param maxRetries 최대 재시도 횟수 - * @param 반환 타입 - * @return 작업 결과 - */ - public static T executeWithRetry(Supplier operation, int maxRetries) { - Exception lastException = null; - - for (int attempt = 0; attempt <= maxRetries; attempt++) { - try { - return operation.get(); - } catch (Exception e) { - lastException = e; - if (attempt < maxRetries) { - log.warn("작업 실행 실패, 재시도 중... (시도: {}/{})", attempt + 1, maxRetries + 1, e); - try { - Thread.sleep(100L * (attempt + 1)); // 점진적 대기 - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new RuntimeException("재시도 중 인터럽트 발생", ie); - } - } else { - log.error("모든 재시도 실패", e); - } - } - } - - throw new RuntimeException("최대 재시도 횟수 초과", lastException); - } - - /** - * 비회원 정보 검증 - * @param name 이름 - * @param phone 전화번호 - * @throws PaymentValidationException 검증 실패시 - */ - public static void validateNonMemberInfo(String name, String phone) { - if (name == null || name.trim().isEmpty()) { - throw new PaymentValidationException("비회원 이름은 필수입니다"); - } - if (phone == null || phone.trim().isEmpty()) { - throw new PaymentValidationException("비회원 전화번호는 필수입니다"); - } - if (!isValidPhoneNumber(phone)) { - throw new PaymentValidationException("올바른 전화번호 형식이 아닙니다"); - } - } - - /** - * 전화번호 형식 검증 - * @param phone 전화번호 - * @return 유효성 여부 - */ - public static boolean isValidPhoneNumber(String phone) { - if (phone == null) return false; - String cleanPhone = phone.replaceAll("[^0-9]", ""); - return cleanPhone.length() >= 10 && cleanPhone.length() <= 11; - } - - /** - * 전화번호 정규화 (숫자만 남기고 010xxxxxxxx 형태로) - * @param phone 원본 전화번호 - * @return 정규화된 전화번호 - */ - public static String normalizePhoneNumber(String phone) { - if (phone == null) return ""; - return phone.replaceAll("[^0-9]", ""); - } - - /** - * 안전한 문자열 비교 (null 안전) - * @param str1 문자열1 - * @param str2 문자열2 - * @return 같으면 true - */ - public static boolean safeEquals(String str1, String str2) { - if (str1 == null && str2 == null) return true; - if (str1 == null || str2 == null) return false; - return str1.equals(str2); - } -} diff --git a/src/main/java/com/sudo/railo/payment/config/PaymentDataMigrationRunner.java b/src/main/java/com/sudo/railo/payment/config/PaymentDataMigrationRunner.java deleted file mode 100644 index 18211a89..00000000 --- a/src/main/java/com/sudo/railo/payment/config/PaymentDataMigrationRunner.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.sudo.railo.payment.config; - -import com.sudo.railo.payment.application.service.PaymentDataMigrationService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -/** - * 애플리케이션 시작 시 결제 데이터 마이그레이션 실행 - * - * 개발/테스트 환경에서만 자동 실행 - * 운영 환경에서는 수동으로 실행 필요 - */ -@Component -@Profile({"dev", "test", "local"}) -@RequiredArgsConstructor -@Slf4j -public class PaymentDataMigrationRunner implements ApplicationRunner { - - private final PaymentDataMigrationService migrationService; - - @Override - public void run(ApplicationArguments args) throws Exception { - log.info("======= 결제 데이터 마이그레이션 시작 ======="); - - try { - // 마이그레이션 상태 확인 - migrationService.checkMigrationStatus(); - - // 마이그레이션 실행 - migrationService.migratePaymentTrainInfo(); - - // 마이그레이션 후 상태 확인 - migrationService.checkMigrationStatus(); - - log.info("======= 결제 데이터 마이그레이션 완료 ======="); - } catch (Exception e) { - log.error("결제 데이터 마이그레이션 실패", e); - // 마이그레이션 실패해도 애플리케이션은 계속 실행 - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/Payment.java b/src/main/java/com/sudo/railo/payment/domain/Payment.java new file mode 100644 index 00000000..4b6df64e --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/domain/Payment.java @@ -0,0 +1,140 @@ +package com.sudo.railo.payment.domain; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import org.hibernate.annotations.Comment; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import com.sudo.railo.booking.domain.Reservation; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.payment.application.dto.PaymentInfo; +import com.sudo.railo.payment.domain.status.PaymentStatus; +import com.sudo.railo.payment.domain.type.PaymentMethod; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "payment_id") + @Comment("결제 ID") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @Comment("멤버 ID") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", nullable = false) + @Comment("예약 ID") + private Reservation reservation; + + @Column(name = "payment_key", nullable = false, unique = true) + @Comment("결제 고유번호") + private String paymentKey; + + @Column(name = "amount", nullable = false) + @Comment("결제 금액") + private BigDecimal amount; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_method", nullable = false) + private PaymentMethod paymentMethod; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_status", nullable = false) + @Comment("결제 상태") + private PaymentStatus paymentStatus; + + @Column(name = "paid_at") + @Comment("결제 일자") + private LocalDateTime paidAt; + + @Column(name = "cancelled_at") + @Comment("결제 취소 일자") + private LocalDateTime cancelledAt; + + @Column(name = "refunded_at") + @Comment("환불 처리 일자") + private LocalDateTime refundedAt; + + @Column(name = "failure_reason") + @Comment("결제 실패 사유") + private String failureReason; + + private Payment(Member member, Reservation reservation, String paymentKey, BigDecimal amount, + PaymentMethod paymentMethod, PaymentStatus paymentStatus) { + this.member = member; + this.reservation = reservation; + this.paymentKey = paymentKey; + this.amount = amount; + this.paymentMethod = paymentMethod; + this.paymentStatus = paymentStatus; + } + + public static Payment create(Member member, Reservation reservation, String paymentKey, + PaymentInfo paymentInfo) { + return new Payment(member, reservation, paymentKey, paymentInfo.amount(), + paymentInfo.paymentMethod(), paymentInfo.paymentStatus()); + } + + // 결제 승인 + public void approve() { + this.paymentStatus = PaymentStatus.PAID; + this.paidAt = LocalDateTime.now(); + } + + // 결제 취소 + public void cancel(String reason) { + this.paymentStatus = PaymentStatus.CANCELLED; + this.cancelledAt = LocalDateTime.now(); + this.failureReason = reason; + } + + // 환불 처리 + public void refund() { + this.paymentStatus = PaymentStatus.REFUNDED; + this.refundedAt = LocalDateTime.now(); + } + + // 결제 실패 + public void fail(String reason) { + this.paymentStatus = PaymentStatus.FAILED; + this.failureReason = reason; + } + + // 결제 가능 여부 확인 + public boolean canBePaid() { + return this.paymentStatus.isPayable(); + } + + // 취소 가능 여부 확인 + public boolean canBeCancelled() { + return this.paymentStatus.isCancellable(); + } + + // 환불 가능 여부 확인 + public boolean canBeRefunded() { + return this.paidAt != null && this.paymentStatus.isRefundable(); + } +} diff --git a/src/main/java/com/sudo/railo/payment/domain/constant/PaymentPrecision.java b/src/main/java/com/sudo/railo/payment/domain/constant/PaymentPrecision.java deleted file mode 100644 index 92d04521..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/constant/PaymentPrecision.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.sudo.railo.payment.domain.constant; - -/** - * 결제 도메인 BigDecimal 정밀도 상수 - * - * 모든 결제 관련 엔티티에서 사용하는 BigDecimal 필드의 - * 정밀도(precision)와 소수점(scale)을 통일하여 관리 - */ -public final class PaymentPrecision { - - private PaymentPrecision() { - // 상수 클래스이므로 인스턴스화 방지 - } - - /** - * 금액(원화) 관련 필드 정밀도 - * - 최대 999,999,999,999,999원까지 표현 가능 (999조) - * - 소수점 2자리까지 표현 (전 처리용) - */ - public static final int AMOUNT_PRECISION = 15; - public static final int AMOUNT_SCALE = 2; - - /** - * 마일리지 포인트 관련 필드 정밀도 - * - 최대 9,999,999,999 포인트까지 표현 가능 - * - 소수점 없음 (정수형) - */ - public static final int MILEAGE_PRECISION = 10; - public static final int MILEAGE_SCALE = 0; - - /** - * 비율/퍼센트 관련 필드 정밀도 - * - 최대 99.999%까지 표현 가능 - * - 소수점 3자리까지 표현 - */ - public static final int RATE_PRECISION = 5; - public static final int RATE_SCALE = 3; - - /** - * 수량 관련 필드 정밀도 - * - 최대 999,999개까지 표현 가능 - * - 소수점 없음 (정수형) - */ - public static final int QUANTITY_PRECISION = 6; - public static final int QUANTITY_SCALE = 0; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/CalculationStatus.java b/src/main/java/com/sudo/railo/payment/domain/entity/CalculationStatus.java deleted file mode 100644 index 17f6664c..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/CalculationStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -public enum CalculationStatus { - CALCULATED, // 계산 완료 - EXPIRED, // 만료됨 - CONSUMED, // 사용됨 (기존 호환성 유지) - USED // 사용됨 (신규) -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/CashReceipt.java b/src/main/java/com/sudo/railo/payment/domain/entity/CashReceipt.java deleted file mode 100644 index 4a2225ff..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/CashReceipt.java +++ /dev/null @@ -1,163 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -import com.sudo.railo.payment.exception.PaymentValidationException; -import com.sudo.railo.payment.infrastructure.persistence.converter.CashReceiptTypeConverter; -import jakarta.persistence.*; -import lombok.*; - -/** - * 현금영수증 Value Object - * - * Payment 엔티티에서 현금영수증 관련 필드들을 분리하여 응집도를 높임 - */ -@Embeddable -@Getter @Builder -@NoArgsConstructor @AllArgsConstructor -public class CashReceipt { - - @Builder.Default - @Column(name = "receipt_requested") - private Boolean requested = false; - - @Convert(converter = CashReceiptTypeConverter.class) - @Column(name = "receipt_type") - private CashReceiptType type; - - @Column(name = "receipt_phone_number") - private String phoneNumber; - - @Column(name = "receipt_business_number") - private String businessNumber; - - @Column(name = "receipt_url") - private String receiptUrl; - - /** - * 현금영수증 타입 - */ - public enum CashReceiptType { - PERSONAL("personal", "개인 소득공제"), - BUSINESS("business", "사업자 증빙"); - - private final String code; - private final String description; - - CashReceiptType(String code, String description) { - this.code = code; - this.description = description; - } - - public String getCode() { - return code; - } - - public String getDescription() { - return description; - } - - public static CashReceiptType fromCode(String code) { - for (CashReceiptType type : values()) { - if (type.code.equals(code)) { - return type; - } - } - throw new IllegalArgumentException("Invalid cash receipt type code: " + code); - } - } - - /** - * 현금영수증 요청 여부 확인 - */ - public boolean isRequested() { - return Boolean.TRUE.equals(this.requested); - } - - /** - * 현금영수증 정보 검증 - */ - public void validate() { - if (!isRequested()) { - return; - } - - if (type == null) { - throw new PaymentValidationException("현금영수증 타입이 필요합니다"); - } - - if (type == CashReceiptType.PERSONAL) { - validatePersonalReceipt(); - } else if (type == CashReceiptType.BUSINESS) { - validateBusinessReceipt(); - } - } - - /** - * 개인 소득공제용 현금영수증 검증 - */ - private void validatePersonalReceipt() { - if (phoneNumber == null || phoneNumber.trim().isEmpty()) { - throw new PaymentValidationException("개인 소득공제용 현금영수증은 휴대폰 번호가 필요합니다"); - } - - // 휴대폰 번호 형식 검증 (간단한 검증) - String cleanedPhone = phoneNumber.replaceAll("[^0-9]", ""); - if (cleanedPhone.length() != 11 || !cleanedPhone.startsWith("010")) { - throw new PaymentValidationException("올바른 휴대폰 번호 형식이 아닙니다"); - } - } - - /** - * 사업자 증빙용 현금영수증 검증 - */ - private void validateBusinessReceipt() { - if (businessNumber == null || businessNumber.trim().isEmpty()) { - throw new PaymentValidationException("사업자 증빙용 현금영수증은 사업자등록번호가 필요합니다"); - } - - // 사업자등록번호 형식 검증 (간단한 검증) - String cleanedBizNo = businessNumber.replaceAll("[^0-9]", ""); - if (cleanedBizNo.length() != 10) { - throw new PaymentValidationException("올바른 사업자등록번호 형식이 아닙니다 (10자리)"); - } - } - - - /** - * 개인 소득공제용 현금영수증 생성 팩토리 메서드 - */ - public static CashReceipt createPersonalReceipt(String phoneNumber) { - return CashReceipt.builder() - .requested(true) - .type(CashReceiptType.PERSONAL) - .phoneNumber(phoneNumber) - .build(); - } - - /** - * 사업자 증빙용 현금영수증 생성 팩토리 메서드 - */ - public static CashReceipt createBusinessReceipt(String businessNumber) { - return CashReceipt.builder() - .requested(true) - .type(CashReceiptType.BUSINESS) - .businessNumber(businessNumber) - .build(); - } - - /** - * 현금영수증 미신청 생성 팩토리 메서드 - */ - public static CashReceipt notRequested() { - return CashReceipt.builder() - .requested(false) - .build(); - } - - /** - * 현금영수증 타입 조회 (하위 호환성) - * getType()의 별칭 - */ - public CashReceiptType getReceiptType() { - return type; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/DomainEventOutbox.java b/src/main/java/com/sudo/railo/payment/domain/entity/DomainEventOutbox.java deleted file mode 100644 index ba12efc9..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/DomainEventOutbox.java +++ /dev/null @@ -1,218 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -import com.sudo.railo.global.domain.BaseEntity; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -/** - * 도메인 이벤트 Outbox 엔티티 - * Outbox Pattern을 통한 안정적인 이벤트 발행 및 처리를 위한 엔티티 - */ -@Entity -@Table( - name = "domain_events_outbox", - indexes = { - @Index(name = "idx_outbox_status_created", columnList = "status, created_at"), - @Index(name = "idx_outbox_event_type", columnList = "event_type"), - @Index(name = "idx_outbox_aggregate", columnList = "aggregate_type, aggregate_id") - } -) -@Getter @Builder -@NoArgsConstructor @AllArgsConstructor -public class DomainEventOutbox extends BaseEntity { - - @Id - @Column(name = "event_id", length = 36) - private String id; // UUID 형태의 이벤트 ID - - @Enumerated(EnumType.STRING) - @Column(name = "event_type", nullable = false, length = 50) - private EventType eventType; // 이벤트 타입 - - @Enumerated(EnumType.STRING) - @Column(name = "aggregate_type", nullable = false, length = 50) - private AggregateType aggregateType; // 애그리거트 타입 - - @Column(name = "aggregate_id", nullable = false, length = 255) - private String aggregateId; // 애그리거트 ID - - @Column(name = "event_data", columnDefinition = "JSON") - private String eventData; // 이벤트 데이터 (JSON 형태) - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - private EventStatus status; // 이벤트 처리 상태 - - @Column(name = "retry_count", columnDefinition = "INT DEFAULT 0") - @Builder.Default - private int retryCount = 0; // 재시도 횟수 - - @Column(name = "processed_at") - private LocalDateTime processedAt; // 처리 완료 시간 - - @Column(name = "error_message", length = 1000) - private String errorMessage; // 에러 메시지 - - /** - * 이벤트 타입 - */ - public enum EventType { - TRAIN_ARRIVED("열차 도착"), - TRAIN_DELAYED("열차 지연"), - MILEAGE_EARNING_READY("마일리지 적립 준비"), - MILEAGE_EARNED("마일리지 적립 완료"), - DELAY_COMPENSATION_EARNED("지연 보상 마일리지 적립"), - PAYMENT_STATE_CHANGED("결제 상태 변경"); - - private final String description; - - EventType(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } - } - - /** - * 애그리거트 타입 - */ - public enum AggregateType { - TRAIN_SCHEDULE("열차 스케줄"), - PAYMENT("결제"), - MILEAGE_TRANSACTION("마일리지 거래"); - - private final String description; - - AggregateType(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } - } - - /** - * 이벤트 처리 상태 - */ - public enum EventStatus { - PENDING("처리 대기"), - PROCESSING("처리 중"), - COMPLETED("처리 완료"), - FAILED("처리 실패"); - - private final String description; - - EventStatus(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } - } - - /** - * 이벤트 처리 시작 - */ - public void startProcessing() { - this.status = EventStatus.PROCESSING; - } - - /** - * 이벤트 처리 완료 - */ - public void complete() { - this.status = EventStatus.COMPLETED; - this.processedAt = LocalDateTime.now(); - } - - /** - * 이벤트 처리 실패 - */ - public void fail(String errorMessage) { - this.status = EventStatus.FAILED; - this.errorMessage = errorMessage; - this.retryCount++; - this.processedAt = LocalDateTime.now(); - } - - /** - * 재시도 가능 여부 확인 - * @param maxRetryCount 최대 재시도 횟수 - * @return 재시도 가능 여부 - */ - public boolean canRetry(int maxRetryCount) { - return this.retryCount < maxRetryCount && this.status == EventStatus.FAILED; - } - - /** - * 열차 도착 이벤트 생성 팩토리 메서드 - */ - public static DomainEventOutbox createTrainArrivedEvent( - String eventId, - String trainScheduleId, - String eventData) { - - return DomainEventOutbox.builder() - .id(eventId) - .eventType(EventType.TRAIN_ARRIVED) - .aggregateType(AggregateType.TRAIN_SCHEDULE) - .aggregateId(trainScheduleId) - .eventData(eventData) - .status(EventStatus.PENDING) - .retryCount(0) - .build(); - } - - /** - * 마일리지 적립 이벤트 생성 팩토리 메서드 - */ - public static DomainEventOutbox createMileageEarningEvent( - String eventId, - String paymentId, - String eventData, - EventType eventType) { - - return DomainEventOutbox.builder() - .id(eventId) - .eventType(eventType) - .aggregateType(AggregateType.PAYMENT) - .aggregateId(paymentId) - .eventData(eventData) - .status(EventStatus.PENDING) - .retryCount(0) - .build(); - } - - /** - * 결제 상태 변경 이벤트 생성 팩토리 메서드 - */ - public static DomainEventOutbox createPaymentStateChangedEvent( - String eventId, - String paymentId, - String eventData) { - - return DomainEventOutbox.builder() - .id(eventId) - .eventType(EventType.PAYMENT_STATE_CHANGED) - .aggregateType(AggregateType.PAYMENT) - .aggregateId(paymentId) - .eventData(eventData) - .status(EventStatus.PENDING) - .retryCount(0) - .build(); - } - - /** - * 이벤트 ID 조회 (하위 호환성) - * getId()의 별칭 - */ - public String getEventId() { - return id; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/MemberType.java b/src/main/java/com/sudo/railo/payment/domain/entity/MemberType.java deleted file mode 100644 index a203771d..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/MemberType.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -/** - * 회원 타입 구분 Enum - */ -public enum MemberType { - MEMBER("회원"), - NON_MEMBER("비회원"); - - private final String description; - - MemberType(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/MileageEarningSchedule.java b/src/main/java/com/sudo/railo/payment/domain/entity/MileageEarningSchedule.java deleted file mode 100644 index 450af305..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/MileageEarningSchedule.java +++ /dev/null @@ -1,299 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -import com.sudo.railo.global.domain.BaseEntity; -import jakarta.persistence.*; -import lombok.*; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 마일리지 적립 스케줄 엔티티 - * TrainSchedule과 Payment를 연결하여 실시간 마일리지 적립을 관리 - */ -@Entity -@Table( - name = "mileage_earning_schedules", - indexes = { - @Index(name = "idx_mileage_schedule_train", columnList = "train_schedule_id, status"), - @Index(name = "idx_mileage_schedule_payment", columnList = "payment_id"), - @Index(name = "idx_mileage_schedule_member", columnList = "member_id, status"), - @Index(name = "idx_mileage_schedule_processing", columnList = "status, scheduled_earning_time") - } -) -@Getter @Setter @Builder -@NoArgsConstructor @AllArgsConstructor -public class MileageEarningSchedule extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "schedule_id") - private Long id; - - @Column(name = "train_schedule_id", nullable = false) - private Long trainScheduleId; // TrainSchedule ID - - @Column(name = "payment_id", nullable = false, length = 255) - private String paymentId; // Payment ID (external_order_id) - - @Column(name = "member_id", nullable = false) - private Long memberId; // 회원 ID - - @Column(name = "original_amount", precision = 12, scale = 0, nullable = false) - private BigDecimal originalAmount; // 원본 결제 금액 - - @Column(name = "base_mileage_amount", precision = 12, scale = 0, nullable = false) - private BigDecimal baseMileageAmount; // 기본 마일리지 적립액 (1%) - - @Column(name = "delay_compensation_rate", precision = 5, scale = 3) - private BigDecimal delayCompensationRate; // 지연 보상 비율 (0.125, 0.25, 0.5) - - @Column(name = "delay_compensation_amount", precision = 12, scale = 0, columnDefinition = "DECIMAL(12,0) DEFAULT 0") - @Builder.Default - private BigDecimal delayCompensationAmount = BigDecimal.ZERO; // 지연 보상 마일리지 - - @Column(name = "total_mileage_amount", precision = 12, scale = 0, nullable = false) - private BigDecimal totalMileageAmount; // 총 마일리지 적립액 - - @Column(name = "scheduled_earning_time", nullable = false) - private LocalDateTime scheduledEarningTime; // 적립 예정 시간 (실제 도착 시간) - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - private EarningStatus status; // 적립 상태 - - @Column(name = "delay_minutes", columnDefinition = "INT DEFAULT 0") - @Builder.Default - private int delayMinutes = 0; // 지연 시간(분) - - @Column(name = "processed_at") - private LocalDateTime processedAt; // 처리 완료 시간 - - private Long baseTransactionId; // 기본 마일리지 거래 ID - - private Long compensationTransactionId; // 지연 보상 거래 ID - - @Column(name = "error_message", length = 500) - private String errorMessage; // 에러 메시지 - - @Column(name = "retry_count", columnDefinition = "INT DEFAULT 0") - @Builder.Default - private int retryCount = 0; // 재시도 횟수 - - @Column(name = "route_info", length = 100) - private String routeInfo; // 노선 정보 (예: "서울-부산") - - /** - * 마일리지 적립 상태 - */ - public enum EarningStatus { - SCHEDULED("적립 예정"), // 스케줄 생성됨 - READY("적립 준비"), // 도착 시간 도달 - BASE_PROCESSING("기본 적립 중"), // 기본 마일리지 적립 중 - BASE_COMPLETED("기본 적립 완료"), // 기본 마일리지 적립 완료 - COMPENSATION_PROCESSING("보상 적립 중"), // 지연 보상 적립 중 - FULLY_COMPLETED("완전 완료"), // 모든 적립 완료 - FAILED("적립 실패"), // 적립 실패 - CANCELLED("적립 취소"); // 적립 취소 (환불 등) - - private final String description; - - EarningStatus(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } - } - - /** - * 적립 준비 상태로 변경 - */ - public void markReady() { - this.status = EarningStatus.READY; - } - - /** - * 기본 마일리지 적립 시작 - */ - public void startBaseProcessing() { - this.status = EarningStatus.BASE_PROCESSING; - } - - /** - * 기본 마일리지 적립 완료 - */ - public void completeBaseEarning(Long transactionId) { - this.baseTransactionId = transactionId; - - if (hasDelayCompensation()) { - this.status = EarningStatus.BASE_COMPLETED; - } else { - this.status = EarningStatus.FULLY_COMPLETED; - this.processedAt = LocalDateTime.now(); - } - } - - /** - * 지연 보상 적립 시작 - */ - public void startCompensationProcessing() { - this.status = EarningStatus.COMPENSATION_PROCESSING; - } - - /** - * 지연 보상 적립 완료 - */ - public void completeCompensationEarning(Long transactionId) { - this.compensationTransactionId = transactionId; - this.status = EarningStatus.FULLY_COMPLETED; - this.processedAt = LocalDateTime.now(); - } - - /** - * 적립 실패 처리 - */ - public void fail(String errorMessage) { - this.status = EarningStatus.FAILED; - this.errorMessage = errorMessage; - this.retryCount++; - this.processedAt = LocalDateTime.now(); - } - - /** - * 적립 취소 처리 (환불 등) - */ - public void cancel(String reason) { - this.status = EarningStatus.CANCELLED; - this.errorMessage = reason; - this.processedAt = LocalDateTime.now(); - } - - /** - * 지연 보상 여부 확인 - */ - public boolean hasDelayCompensation() { - return delayCompensationAmount != null && - delayCompensationAmount.compareTo(BigDecimal.ZERO) > 0; - } - - /** - * 처리 완료 여부 확인 - */ - public boolean isCompleted() { - return status == EarningStatus.FULLY_COMPLETED; - } - - /** - * 처리 가능 여부 확인 - */ - public boolean isReadyForProcessing(LocalDateTime currentTime) { - return status == EarningStatus.READY && - currentTime.isAfter(scheduledEarningTime); - } - - /** - * 지연 정보 업데이트 - */ - public void updateDelayInfo(int delayMinutes, BigDecimal compensationRate) { - this.delayMinutes = delayMinutes; - this.delayCompensationRate = compensationRate; - - if (compensationRate.compareTo(BigDecimal.ZERO) > 0) { - this.delayCompensationAmount = originalAmount - .multiply(compensationRate) - .setScale(0, BigDecimal.ROUND_DOWN); - this.totalMileageAmount = baseMileageAmount.add(delayCompensationAmount); - } - } - - /** - * 적립 예정 시간 업데이트 - */ - public void updateScheduledEarningTime(LocalDateTime newTime) { - if (newTime == null) { - throw new IllegalArgumentException("적립 예정 시간은 null일 수 없습니다"); - } - this.scheduledEarningTime = newTime; - } - - /** - * 정상 운행 마일리지 스케줄 생성 팩토리 메서드 - */ - public static MileageEarningSchedule createNormalEarningSchedule( - Long trainScheduleId, - String paymentId, - Long memberId, - BigDecimal originalAmount, - LocalDateTime scheduledEarningTime, - String routeInfo) { - - BigDecimal baseMileageAmount = originalAmount - .multiply(new BigDecimal("0.01")) - .setScale(0, BigDecimal.ROUND_DOWN); - - return MileageEarningSchedule.builder() - .trainScheduleId(trainScheduleId) - .paymentId(paymentId) - .memberId(memberId) - .originalAmount(originalAmount) - .baseMileageAmount(baseMileageAmount) - .delayCompensationRate(BigDecimal.ZERO) - .delayCompensationAmount(BigDecimal.ZERO) - .totalMileageAmount(baseMileageAmount) - .scheduledEarningTime(scheduledEarningTime) - .status(EarningStatus.SCHEDULED) - .delayMinutes(0) - .retryCount(0) - .routeInfo(routeInfo) - .build(); - } - - /** - * 지연 운행 마일리지 스케줄 생성 팩토리 메서드 - */ - public static MileageEarningSchedule createDelayedEarningSchedule( - Long trainScheduleId, - String paymentId, - Long memberId, - BigDecimal originalAmount, - LocalDateTime scheduledEarningTime, - int delayMinutes, - BigDecimal compensationRate, - String routeInfo) { - - BigDecimal baseMileageAmount = originalAmount - .multiply(new BigDecimal("0.01")) - .setScale(0, BigDecimal.ROUND_DOWN); - - BigDecimal compensationAmount = originalAmount - .multiply(compensationRate) - .setScale(0, BigDecimal.ROUND_DOWN); - - BigDecimal totalAmount = baseMileageAmount.add(compensationAmount); - - return MileageEarningSchedule.builder() - .trainScheduleId(trainScheduleId) - .paymentId(paymentId) - .memberId(memberId) - .originalAmount(originalAmount) - .baseMileageAmount(baseMileageAmount) - .delayCompensationRate(compensationRate) - .delayCompensationAmount(compensationAmount) - .totalMileageAmount(totalAmount) - .scheduledEarningTime(scheduledEarningTime) - .status(EarningStatus.SCHEDULED) - .delayMinutes(delayMinutes) - .retryCount(0) - .routeInfo(routeInfo) - .build(); - } - - /** - * 환불 상태로 업데이트 - */ - public void updateRefundStatus(EarningStatus status) { - this.status = status; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/MileageTransaction.java b/src/main/java/com/sudo/railo/payment/domain/entity/MileageTransaction.java deleted file mode 100644 index e8b983e5..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/MileageTransaction.java +++ /dev/null @@ -1,284 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -import com.sudo.railo.global.domain.BaseEntity; -import com.sudo.railo.payment.domain.constant.PaymentPrecision; -import jakarta.persistence.*; -import lombok.*; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 마일리지 거래 내역 엔티티 - * 적립, 사용, 만료, 조정 등 모든 마일리지 변동 내역을 기록 - */ -@Entity -@Table( - name = "mileage_transactions", - indexes = { - @Index(name = "idx_mileage_tx_member", columnList = "member_id, transaction_type, status"), - @Index(name = "idx_mileage_tx_payment", columnList = "payment_id"), - @Index(name = "idx_mileage_tx_schedule", columnList = "train_schedule_id"), - @Index(name = "idx_mileage_tx_earning_schedule", columnList = "earning_schedule_id") - } -) -@Getter @Builder -@NoArgsConstructor @AllArgsConstructor -public class MileageTransaction extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "transaction_id") - private Long id; - - @Column(name = "member_id", nullable = false) - private Long memberId; - - private String paymentId; // 결제와 연결 (적립/사용 시) - - // 🆕 새로운 마일리지 시스템용 필드들 - private Long trainScheduleId; // TrainSchedule과 연결 - - private Long earningScheduleId; // MileageEarningSchedule과 연결 - - @Enumerated(EnumType.STRING) - @Column(name = "transaction_type", nullable = false) - private TransactionType type; // EARN, USE, EXPIRE, ADJUST - - // 🆕 확장된 거래 타입 - @Enumerated(EnumType.STRING) - @Column(name = "earning_type") - private EarningType earningType; // 적립 타입 (기본, 지연보상) - - @Column(name = "points_amount", precision = PaymentPrecision.MILEAGE_PRECISION, scale = PaymentPrecision.MILEAGE_SCALE, nullable = false) - private BigDecimal pointsAmount; // 포인트 수량 (양수: 적립, 음수: 차감) - - @Column(name = "balance_before", precision = PaymentPrecision.MILEAGE_PRECISION, scale = PaymentPrecision.MILEAGE_SCALE, nullable = false) - private BigDecimal balanceBefore; // 거래 전 잔액 - - @Column(name = "balance_after", precision = PaymentPrecision.MILEAGE_PRECISION, scale = PaymentPrecision.MILEAGE_SCALE, nullable = false) - private BigDecimal balanceAfter; // 거래 후 잔액 - - @Column(name = "description", length = 500) - private String description; // 거래 설명 - - @Column(name = "expires_at") - private LocalDateTime expiresAt; // 적립 포인트 만료일 (적립 시에만) - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - private TransactionStatus status; // PENDING, COMPLETED, CANCELLED - - @Column(name = "processed_at") - private LocalDateTime processedAt; // 처리 완료 시간 - - // 🆕 지연 정보 필드들 - @Column(name = "delay_minutes", columnDefinition = "INT DEFAULT 0") - @Builder.Default - private int delayMinutes = 0; // 지연 시간(분) - - @Column(name = "compensation_rate", precision = PaymentPrecision.RATE_PRECISION, scale = PaymentPrecision.RATE_SCALE) - private BigDecimal compensationRate; // 지연 보상 비율 - - @Version - @Column(name = "version") - private Long version; - - /** - * 마일리지 거래 유형 - */ - public enum TransactionType { - EARN("적립"), // 구매 시 적립 - USE("사용"), // 결제 시 사용 - EXPIRE("만료"), // 유효기간 만료 - ADJUST("조정"), // 관리자 조정 - REFUND("환불"); // 환불로 인한 복구 - - private final String description; - - TransactionType(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } - } - - /** - * 🆕 적립 타입 (새로운 마일리지 시스템용) - */ - public enum EarningType { - BASE_EARN("기본 적립"), // 일반 1% 적립 - DELAY_COMPENSATION("지연 보상"), // 지연으로 인한 보상 적립 - PROMOTION("프로모션"), // 프로모션 적립 - MANUAL_ADJUST("수동 조정"); // 관리자 수동 조정 - - private final String description; - - EarningType(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } - } - - /** - * 거래 상태 - */ - public enum TransactionStatus { - PENDING("처리 대기"), // 거래 대기 중 - COMPLETED("완료"), // 거래 완료 - CANCELLED("취소"), // 거래 취소 - FAILED("실패"); // 거래 실패 - - private final String description; - - TransactionStatus(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } - } - - /** - * 거래 완료 처리 - */ - public void complete() { - this.status = TransactionStatus.COMPLETED; - this.processedAt = LocalDateTime.now(); - } - - /** - * 거래 취소 처리 - */ - public void cancel() { - this.status = TransactionStatus.CANCELLED; - this.processedAt = LocalDateTime.now(); - } - - /** - * 적립 거래 생성 팩토리 메서드 - */ - public static MileageTransaction createEarnTransaction( - Long memberId, - String paymentId, - BigDecimal pointsAmount, - BigDecimal balanceBefore, - String description) { - - return MileageTransaction.builder() - .memberId(memberId) - .paymentId(paymentId) - .type(TransactionType.EARN) - .earningType(EarningType.BASE_EARN) - .pointsAmount(pointsAmount) - .balanceBefore(balanceBefore) - .balanceAfter(balanceBefore.add(pointsAmount)) - .description(description) - .expiresAt(LocalDateTime.now().plusYears(5)) // 🆕 5년 후 만료 - .status(TransactionStatus.PENDING) - .build(); - } - - /** - * 🆕 기본 마일리지 적립 거래 생성 (새로운 시스템용) - */ - public static MileageTransaction createBaseEarningTransaction( - Long memberId, - String paymentId, - Long trainScheduleId, - Long earningScheduleId, - BigDecimal pointsAmount, - BigDecimal balanceBefore, - String description) { - - return MileageTransaction.builder() - .memberId(memberId) - .paymentId(paymentId) - .trainScheduleId(trainScheduleId) - .earningScheduleId(earningScheduleId) - .type(TransactionType.EARN) - .earningType(EarningType.BASE_EARN) - .pointsAmount(pointsAmount) - .balanceBefore(balanceBefore) - .balanceAfter(balanceBefore.add(pointsAmount)) - .description(description) - .expiresAt(LocalDateTime.now().plusYears(5)) // 5년 후 만료 - .status(TransactionStatus.PENDING) - .delayMinutes(0) - .build(); - } - - /** - * 🆕 지연 보상 마일리지 적립 거래 생성 - */ - public static MileageTransaction createDelayCompensationTransaction( - Long memberId, - String paymentId, - Long trainScheduleId, - Long earningScheduleId, - BigDecimal pointsAmount, - BigDecimal balanceBefore, - int delayMinutes, - BigDecimal compensationRate, - String description) { - - return MileageTransaction.builder() - .memberId(memberId) - .paymentId(paymentId) - .trainScheduleId(trainScheduleId) - .earningScheduleId(earningScheduleId) - .type(TransactionType.EARN) - .earningType(EarningType.DELAY_COMPENSATION) - .pointsAmount(pointsAmount) - .balanceBefore(balanceBefore) - .balanceAfter(balanceBefore.add(pointsAmount)) - .description(description) - .expiresAt(LocalDateTime.now().plusYears(5)) // 5년 후 만료 - .status(TransactionStatus.PENDING) - .delayMinutes(delayMinutes) - .compensationRate(compensationRate) - .build(); - } - - /** - * 사용 거래 생성 팩토리 메서드 - */ - public static MileageTransaction createUseTransaction( - Long memberId, - String paymentId, - BigDecimal pointsAmount, - BigDecimal balanceBefore, - String description) { - - return MileageTransaction.builder() - .memberId(memberId) - .paymentId(paymentId) - .type(TransactionType.USE) - .pointsAmount(pointsAmount.negate()) // 음수로 저장 - .balanceBefore(balanceBefore) - .balanceAfter(balanceBefore.subtract(pointsAmount)) - .description(description) - .status(TransactionStatus.PENDING) - .build(); - } - - /** - * 🆕 지연 보상 여부 확인 - */ - public boolean isDelayCompensation() { - return earningType == EarningType.DELAY_COMPENSATION; - } - - /** - * 🆕 기본 적립 여부 확인 - */ - public boolean isBaseEarning() { - return earningType == EarningType.BASE_EARN; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/Payment.java b/src/main/java/com/sudo/railo/payment/domain/entity/Payment.java deleted file mode 100644 index 1c214d6c..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/Payment.java +++ /dev/null @@ -1,401 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.payment.domain.constant.PaymentPrecision; -import com.sudo.railo.payment.domain.util.PaymentStatusMapper; -import com.sudo.railo.payment.exception.PaymentValidationException; -// import com.sudo.railo.train.domain.type.TrainOperator; // 제거됨 -// import com.sudo.railo.payment.application.event.PaymentStateChangedEvent; // AbstractAggregateRoot 제거로 인해 사용하지 않음 -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; -// import org.springframework.data.domain.AbstractAggregateRoot; // 통합테스트를 위해 제거 -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Arrays; - -/** - * 결제 엔티티 - * - * DDD Aggregate Root로서 결제와 관련된 모든 정보를 관리합니다. - * 회원/비회원 통합 처리, 결제 상태 관리, 환불 처리 등의 비즈니스 로직을 포함합니다. - */ -@Entity -@Table(name = "Payments", indexes = { - @Index(name = "idx_payment_external_order_id", columnList = "external_order_id"), - @Index(name = "idx_payment_reservation_id", columnList = "reservation_id"), - @Index(name = "idx_payment_member_id", columnList = "member_id"), - @Index(name = "idx_payment_created_at", columnList = "created_at") -}) -@Getter @Builder -@NoArgsConstructor @AllArgsConstructor -public class Payment { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "payment_id") - private Long id; - - @Column(name = "reservation_id", nullable = false) - private Long reservationId; - - @Column(name = "external_order_id", nullable = false) - private String externalOrderId; - - // 환불을 위한 열차 정보 (예약 삭제 시에도 환불 가능하도록) - @Column(name = "train_schedule_id") - private Long trainScheduleId; - - @Column(name = "train_departure_time") - private LocalDateTime trainDepartureTime; - - @Column(name = "train_arrival_time") - private LocalDateTime trainArrivalTime; - - // TrainOperator 제거됨 - 환불 정책은 내부 로직으로 처리 - // @Column(name = "train_operator", length = 50) - // @Enumerated(EnumType.STRING) - // private TrainOperator trainOperator; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id") - private Member member; - - // 비회원 정보 - @Column(name = "non_member_name") - private String nonMemberName; - - @Column(name = "non_member_phone") - private String nonMemberPhone; - - @Column(name = "non_member_password") - private String nonMemberPassword; - - @Enumerated(EnumType.STRING) - @Column(name = "payment_method", nullable = false) - private PaymentMethod paymentMethod; - - @Column(name = "pg_provider") - private String pgProvider; - - @Column(name = "amount_original_total", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE, nullable = false) - private BigDecimal amountOriginalTotal; - - @Builder.Default - @Column(name = "total_discount_amount_applied", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE) - private BigDecimal totalDiscountAmountApplied = BigDecimal.ZERO; - - @Builder.Default - @Column(name = "mileage_points_used", precision = PaymentPrecision.MILEAGE_PRECISION, scale = PaymentPrecision.MILEAGE_SCALE) - private BigDecimal mileagePointsUsed = BigDecimal.ZERO; - - @Builder.Default - @Column(name = "mileage_amount_deducted", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE) - private BigDecimal mileageAmountDeducted = BigDecimal.ZERO; - - @Column(name = "amount_paid", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE, nullable = false) - private BigDecimal amountPaid; - - @Enumerated(EnumType.STRING) - @Column(name = "payment_status", nullable = false) - private PaymentExecutionStatus paymentStatus; - - private String pgTransactionId; - - @Column(name = "pg_approval_no") - private String pgApprovalNo; - - // 현금영수증 정보 (Value Object) - @Embedded - private CashReceipt cashReceipt; - - @Column(name = "paid_at") - private LocalDateTime paidAt; - - @Column(name = "cancelled_at") - private LocalDateTime cancelledAt; - - @Column(name = "refunded_at") - private LocalDateTime refundedAt; - - @Builder.Default - @Column(name = "refund_amount", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE) - private BigDecimal refundAmount = BigDecimal.ZERO; - - @Builder.Default - @Column(name = "refund_fee", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE) - private BigDecimal refundFee = BigDecimal.ZERO; - - @Column(name = "refund_reason") - private String refundReason; - - private String pgRefundTransactionId; - - @Column(name = "pg_refund_approval_no") - private String pgRefundApprovalNo; - - @Builder.Default - @Column(name = "mileage_to_earn", precision = PaymentPrecision.MILEAGE_PRECISION, scale = PaymentPrecision.MILEAGE_SCALE) - private BigDecimal mileageToEarn = BigDecimal.ZERO; - - @Column(name = "idempotency_key", unique = true) - private String idempotencyKey; - - @CreationTimestamp - @Column(name = "created_at") - private LocalDateTime createdAt; - - @UpdateTimestamp - @Column(name = "updated_at") - private LocalDateTime updatedAt; - - @Version - @Column(name = "version") - private Long version; - - // Soft Delete 필드 - @Column(name = "deleted_at") - private LocalDateTime deletedAt; - - @Column(name = "deletion_reason") - private String deletionReason; - - // =========================== Public Methods =========================== - - /** - * 결제 상태 업데이트 (상태 전이 검증 포함) - * - * @param newStatus 변경할 새로운 상태 - * @throws PaymentValidationException 유효하지 않은 상태 전이 시 - */ - public void updateStatus(PaymentExecutionStatus newStatus) { - updateStatus(newStatus, null, "SYSTEM"); - } - - /** - * 결제 상태 업데이트 (상세 정보 포함) - * - * @param newStatus 변경할 새로운 상태 - * @param reason 상태 변경 사유 - * @param triggeredBy 상태 변경 주체 - * @throws PaymentValidationException 유효하지 않은 상태 전이 시 - */ - public void updateStatus(PaymentExecutionStatus newStatus, String reason, String triggeredBy) { - // 현재 상태 저장 (이벤트용) - PaymentExecutionStatus previousStatus = this.paymentStatus; - - // 상태 전이 검증 - validateStatusTransition(this.paymentStatus, newStatus); - - // 상태 변경 - this.paymentStatus = newStatus; - - // 상태별 추가 처리 - if (newStatus == PaymentExecutionStatus.SUCCESS) { - if (this.paidAt == null) { // 이미 설정되어 있지 않은 경우에만 - this.paidAt = LocalDateTime.now(); - } - } else if (newStatus == PaymentExecutionStatus.CANCELLED) { - this.cancelledAt = LocalDateTime.now(); - } else if (newStatus == PaymentExecutionStatus.FAILED) { - // 실패 시 처리 - } - - // 이벤트 발행은 Application Service 계층에서 PaymentEventPublisher를 통해 처리 - // AbstractAggregateRoot 제거로 인해 registerEvent 호출 제거 - } - - /** - * PG 정보 업데이트 - * - * @param pgTransactionId PG 거래 ID - * @param pgApprovalNo PG 승인 번호 - */ - public void updatePgInfo(String pgTransactionId, String pgApprovalNo) { - this.pgTransactionId = pgTransactionId; - this.pgApprovalNo = pgApprovalNo; - } - - /** - * 환불 가능 여부 확인 - * - * @return 환불 가능하면 true - */ - public boolean isRefundable() { - return this.paymentStatus == PaymentExecutionStatus.SUCCESS && - this.paidAt != null && - this.paidAt.isAfter(LocalDateTime.now().minusDays(30)); // 30일 이내만 환불 가능 - } - - /** - * 취소 가능 여부 확인 - * - * @return 취소 가능하면 true - */ - public boolean isCancellable() { - return PaymentStatusMapper.isInProgress(this.paymentStatus); - } - - /** - * 결제 완료 여부 확인 - * - * @return 결제 완료 상태면 true - */ - public boolean isCompleted() { - return PaymentStatusMapper.isCompleted(this.paymentStatus); - } - - /** - * 회원 결제 여부 확인 - * - * @return 회원 결제면 true - */ - public boolean isForMember() { - return this.member != null; - } - - /** - * 회원 ID 조회 (하위 호환성) - * - * @return 회원 ID, 비회원인 경우 null - */ - public Long getMemberId() { - return this.member != null ? this.member.getId() : null; - } - - /** - * 현금영수증 URL 조회 (하위 호환성) - * - * @return 현금영수증 URL, 없는 경우 null - */ - public String getReceiptUrl() { - return this.cashReceipt != null ? this.cashReceipt.getReceiptUrl() : null; - } - - /** - * 실제 결제 금액 계산 (마일리지 차감 후) - * - * @return 계산된 실제 결제 금액 - */ - public BigDecimal calculateNetAmount() { - BigDecimal originalAmount = this.amountOriginalTotal != null ? this.amountOriginalTotal : BigDecimal.ZERO; - BigDecimal discountAmount = this.totalDiscountAmountApplied != null ? this.totalDiscountAmountApplied : BigDecimal.ZERO; - BigDecimal mileageDeducted = this.mileageAmountDeducted != null ? this.mileageAmountDeducted : BigDecimal.ZERO; - - return originalAmount.subtract(discountAmount).subtract(mileageDeducted); - } - - // =========================== Private Methods =========================== - - /** - * 상태 전이 규칙 검증 - * - * @param from 현재 상태 - * @param to 변경할 상태 - * @throws PaymentValidationException 유효하지 않은 상태 전이 시 - */ - private void validateStatusTransition(PaymentExecutionStatus from, PaymentExecutionStatus to) { - switch (from) { - case PENDING: - if (!Arrays.asList(PaymentExecutionStatus.PROCESSING, PaymentExecutionStatus.CANCELLED, - PaymentExecutionStatus.FAILED).contains(to)) { - throw new PaymentValidationException( - String.format("유효하지 않은 상태 전이: %s → %s", from, to)); - } - break; - case PROCESSING: - if (!Arrays.asList(PaymentExecutionStatus.SUCCESS, PaymentExecutionStatus.FAILED, - PaymentExecutionStatus.CANCELLED).contains(to)) { - throw new PaymentValidationException( - String.format("유효하지 않은 상태 전이: %s → %s", from, to)); - } - break; - case SUCCESS: - if (!Arrays.asList(PaymentExecutionStatus.REFUNDED, PaymentExecutionStatus.CANCELLED) - .contains(to)) { - throw new PaymentValidationException( - String.format("유효하지 않은 상태 전이: %s → %s", from, to)); - } - break; - case FAILED: - case CANCELLED: - case REFUNDED: - throw new PaymentValidationException( - String.format("종료 상태에서는 변경할 수 없습니다: %s", from)); - default: - throw new PaymentValidationException( - String.format("알 수 없는 상태: %s", from)); - } - } - - /** - * 결제 취소 처리 - */ - public void cancel(String reason) { - if (!isCancellable()) { - throw new PaymentValidationException( - String.format("취소할 수 없는 상태입니다: %s", this.paymentStatus)); - } - - this.updateStatus(PaymentExecutionStatus.CANCELLED, reason, "USER"); - this.refundReason = reason; - } - - /** - * 환불 처리 - */ - public void processRefund(RefundRequest refundRequest) { - if (!isRefundable()) { - throw new PaymentValidationException( - String.format("환불할 수 없는 상태입니다: %s", this.paymentStatus)); - } - - // 환불 금액 검증 - if (refundRequest.getRefundAmount().compareTo(this.amountPaid) > 0) { - throw new PaymentValidationException("환불 금액이 결제 금액보다 큽니다"); - } - - this.updateStatus(PaymentExecutionStatus.REFUNDED, refundRequest.getReason(), "ADMIN"); - this.refundedAt = LocalDateTime.now(); - this.refundAmount = refundRequest.getRefundAmount(); - this.refundFee = refundRequest.getRefundFee(); - this.refundReason = refundRequest.getReason(); - this.pgRefundTransactionId = refundRequest.getPgTransactionId(); - this.pgRefundApprovalNo = refundRequest.getPgApprovalNo(); - } - - /** - * Soft Delete 처리 - * - * @param reason 삭제 사유 - */ - public void softDelete(String reason) { - this.deletedAt = LocalDateTime.now(); - this.deletionReason = reason; - } - - /** - * Soft Delete 여부 확인 - * - * @return 삭제되었으면 true - */ - public boolean isDeleted() { - return this.deletedAt != null; - } - - // =========================== Inner Classes =========================== - - /** - * 환불 요청 정보 DTO - */ - @Builder - @Getter - public static class RefundRequest { - private final BigDecimal refundAmount; - private final BigDecimal refundFee; - private final String reason; - private final String pgTransactionId; - private final String pgApprovalNo; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/PaymentCalculation.java b/src/main/java/com/sudo/railo/payment/domain/entity/PaymentCalculation.java deleted file mode 100644 index 400697bd..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/PaymentCalculation.java +++ /dev/null @@ -1,179 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -import com.sudo.railo.payment.domain.constant.PaymentPrecision; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import java.math.BigDecimal; -import java.time.LocalDateTime; - -@Entity -@Table(name = "PaymentCalculations") -@Getter @Builder -@NoArgsConstructor @AllArgsConstructor -public class PaymentCalculation { - - @Id - @Column(name = "calculation_id", length = 36) - private String id; - - @Column(name = "reservation_id") - private String reservationId; - - @Column(name = "external_order_id", nullable = false) - private String externalOrderId; - - @Column(name = "user_id_external", nullable = false) - private String userIdExternal; - - @Column(name = "original_amount", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE, nullable = false) - private BigDecimal originalAmount; - - @Column(name = "final_amount", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE, nullable = false) - private BigDecimal finalAmount; - - @Builder.Default - @Column(name = "mileage_to_use", precision = PaymentPrecision.MILEAGE_PRECISION, scale = PaymentPrecision.MILEAGE_SCALE) - private BigDecimal mileageToUse = BigDecimal.ZERO; - - @Builder.Default - @Column(name = "available_mileage", precision = PaymentPrecision.MILEAGE_PRECISION, scale = PaymentPrecision.MILEAGE_SCALE) - private BigDecimal availableMileage = BigDecimal.ZERO; - - @Builder.Default - @Column(name = "mileage_discount", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE) - private BigDecimal mileageDiscount = BigDecimal.ZERO; - - @Column(name = "promotion_snapshot", columnDefinition = "JSON") - private String promotionSnapshot; - - @Builder.Default - @Enumerated(EnumType.STRING) - @Column(name = "status") - private CalculationStatus status = CalculationStatus.CALCULATED; - - @Column(name = "expires_at", nullable = false) - private LocalDateTime expiresAt; - - // 열차 정보 (예약 삭제 시에도 결제 가능하도록) - @Column(name = "train_schedule_id") - private Long trainScheduleId; - - @Column(name = "train_departure_time") - private LocalDateTime trainDepartureTime; - - @Column(name = "train_arrival_time") - private LocalDateTime trainArrivalTime; - - // TrainOperator 제거됨 - // @Enumerated(EnumType.STRING) - // @Column(name = "train_operator", length = 50) - // private com.sudo.railo.train.domain.type.TrainOperator trainOperator; - - @Column(name = "route_info", length = 100) - private String routeInfo; // 예: "서울-부산" - - // 보안 강화 필드 - @Column(name = "seat_number", length = 10) - private String seatNumber; - - @Column(name = "pg_order_id", unique = true) - private String pgOrderId; // PG사에 전달할 주문번호 - - @Column(name = "created_by_ip", length = 45) - private String createdByIp; - - @Column(name = "user_agent", length = 500) - private String userAgent; - - @Column(name = "used_at") - private LocalDateTime usedAt; // 결제 완료 시점 - - @CreationTimestamp - @Column(name = "created_at") - private LocalDateTime createdAt; - - // =========================== Business Methods =========================== - - /** - * 계산 만료 처리 - */ - public void markAsExpired() { - this.status = CalculationStatus.EXPIRED; - } - - /** - * 최종 금액 업데이트 - * - * @param newFinalAmount 새로운 최종 금액 - */ - public void updateFinalAmount(BigDecimal newFinalAmount) { - if (newFinalAmount == null || newFinalAmount.compareTo(BigDecimal.ZERO) < 0) { - throw new IllegalArgumentException("최종 금액은 0 이상이어야 합니다"); - } - this.finalAmount = newFinalAmount; - } - - /** - * 마일리지 할인 적용 - * - * @param mileageToUse 사용할 마일리지 포인트 - * @param mileageDiscount 마일리지 할인 금액 - */ - public void applyMileageDiscount(BigDecimal mileageToUse, BigDecimal mileageDiscount) { - if (mileageToUse == null || mileageToUse.compareTo(BigDecimal.ZERO) < 0) { - throw new IllegalArgumentException("사용 마일리지는 0 이상이어야 합니다"); - } - if (mileageDiscount == null || mileageDiscount.compareTo(BigDecimal.ZERO) < 0) { - throw new IllegalArgumentException("마일리지 할인 금액은 0 이상이어야 합니다"); - } - - this.mileageToUse = mileageToUse; - this.mileageDiscount = mileageDiscount; - - // 최종 금액 재계산 - this.finalAmount = this.originalAmount.subtract(this.mileageDiscount); - if (this.finalAmount.compareTo(BigDecimal.ZERO) < 0) { - this.finalAmount = BigDecimal.ZERO; - } - } - - /** - * 계산이 유효한지 확인 - * - * @return 유효하면 true - */ - public boolean isValid() { - return this.status == CalculationStatus.CALCULATED && - this.expiresAt != null && - this.expiresAt.isAfter(LocalDateTime.now()); - } - - /** - * 계산이 만료되었는지 확인 - * - * @return 만료되었으면 true - */ - public boolean isExpired() { - return this.status == CalculationStatus.EXPIRED || - (this.expiresAt != null && this.expiresAt.isBefore(LocalDateTime.now())); - } - - /** - * 계산 세션을 사용됨으로 표시 - */ - public void markAsUsed() { - this.status = CalculationStatus.USED; - this.usedAt = LocalDateTime.now(); - } - - /** - * 사용자 검증 - * - * @param userId 검증할 사용자 ID - * @return 일치하면 true - */ - public boolean isOwnedBy(String userId) { - return this.userIdExternal != null && this.userIdExternal.equals(userId); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/PaymentExecutionStatus.java b/src/main/java/com/sudo/railo/payment/domain/entity/PaymentExecutionStatus.java deleted file mode 100644 index 7be2f124..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/PaymentExecutionStatus.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -/** - * 결제 실행 상태 - * - * PG사와의 결제 처리 과정에서 발생하는 기술적 상태를 나타냄 - * booking 도메인의 PaymentStatus(비즈니스 관점)와 구분하여 사용 - * - * @see com.sudo.railo.booking.domain.PaymentStatus (비즈니스 상태) - */ -public enum PaymentExecutionStatus { - PENDING, // 결제 대기 (계산 완료, PG 요청 전) - PROCESSING, // 결제 처리 중 (PG사 처리 중) - SUCCESS, // 결제 성공 (PG사 승인 완료) - FAILED, // 결제 실패 (PG사 승인 거부) - CANCELLED, // 결제 취소 (결제 전 사용자 취소) - REFUNDED // 환불 완료 (결제 후 전체 환불) -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/PaymentMethod.java b/src/main/java/com/sudo/railo/payment/domain/entity/PaymentMethod.java deleted file mode 100644 index a5e20bbf..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/PaymentMethod.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -/** - * 결제 수단 enum - * PG사별 결제 방법 정의 - */ -public enum PaymentMethod { - - // 카카오페이 - KAKAO_PAY("KAKAO_PAY", "카카오페이", "kakao"), - - // 네이버페이 - NAVER_PAY("NAVER_PAY", "네이버페이", "naver"), - - // PAYCO - PAYCO("PAYCO", "PAYCO", "payco"), - - // 신용카드 (직접 PG) - CREDIT_CARD("CREDIT_CARD", "신용카드", "card"), - - // 내 통장 결제 - BANK_ACCOUNT("BANK_ACCOUNT", "내 통장 결제", "bank"), - - // 계좌이체 - BANK_TRANSFER("BANK_TRANSFER", "계좌이체", "trans"), - - // 마일리지 (내부 포인트) - MILEAGE("MILEAGE", "마일리지", "mileage"); - - private final String code; - private final String displayName; - private final String pgType; - - PaymentMethod(String code, String displayName, String pgType) { - this.code = code; - this.displayName = displayName; - this.pgType = pgType; - } - - public String getCode() { - return code; - } - - public String getDisplayName() { - return displayName; - } - - public String getPgType() { - return pgType; - } - - /** - * PG 타입으로 결제 수단 조회 - */ - public static PaymentMethod fromPgType(String pgType) { - for (PaymentMethod method : values()) { - if (method.pgType.equals(pgType)) { - return method; - } - } - throw new IllegalArgumentException("지원하지 않는 PG 타입: " + pgType); - } - - /** - * 외부 PG사 연동이 필요한 결제 수단인지 확인 - */ - public boolean requiresExternalPg() { - return this == KAKAO_PAY || this == NAVER_PAY || this == PAYCO || this == CREDIT_CARD || this == BANK_ACCOUNT || this == BANK_TRANSFER; - } - - /** - * 내부 처리 가능한 결제 수단인지 확인 - */ - public boolean isInternalPayment() { - return this == MILEAGE; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/RefundAuditLog.java b/src/main/java/com/sudo/railo/payment/domain/entity/RefundAuditLog.java deleted file mode 100644 index 65e47068..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/RefundAuditLog.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; - -import java.time.LocalDateTime; - -/** - * 환불 감사 로그 - * - * 토스/당근 스타일로 중요한 환불 이벤트만 선택적으로 저장합니다. - * 트랜잭션과 독립적으로 저장되어 실패해도 로그는 남습니다. - */ -@Entity -@Table(name = "refund_audit_logs", indexes = { - @Index(name = "idx_payment_id", columnList = "payment_id"), - @Index(name = "idx_event_type", columnList = "event_type"), - @Index(name = "idx_created_at", columnList = "created_at") -}) -@Getter @Builder -@NoArgsConstructor @AllArgsConstructor -public class RefundAuditLog { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "payment_id", nullable = false) - private Long paymentId; - - @Column(name = "reservation_id") - private Long reservationId; - - @Column(name = "member_id") - private Long memberId; - - @Enumerated(EnumType.STRING) - @Column(name = "event_type", nullable = false, length = 50) - private AuditEventType eventType; - - @Column(name = "event_reason", length = 500) - private String eventReason; - - @Column(name = "event_detail", columnDefinition = "TEXT") - private String eventDetail; - - @Column(name = "ip_address", length = 45) - private String ipAddress; - - @Column(name = "user_agent", length = 500) - private String userAgent; - - @CreationTimestamp - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - /** - * 감사 이벤트 유형 - * 중요한 이벤트만 선택적으로 기록 - */ - public enum AuditEventType { - REFUND_DENIED_AFTER_ARRIVAL("도착 후 환불 거부"), - REFUND_DENIED_DUPLICATE("중복 환불 거부"), - REFUND_UNKNOWN_STATE("Unknown 상태 발생"), - REFUND_FAILED_PG_ERROR("PG사 오류로 환불 실패"), - REFUND_RETRY_SUCCESS("재시도 성공"), - REFUND_MANUAL_INTERVENTION("수동 개입 필요"); - - private final String description; - - AuditEventType(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } - } - - /** - * 빠른 생성을 위한 팩토리 메서드 - */ - public static RefundAuditLog createDeniedLog( - Long paymentId, - Long reservationId, - Long memberId, - AuditEventType eventType, - String reason, - String detail) { - - return RefundAuditLog.builder() - .paymentId(paymentId) - .reservationId(reservationId) - .memberId(memberId) - .eventType(eventType) - .eventReason(reason) - .eventDetail(detail) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/RefundCalculation.java b/src/main/java/com/sudo/railo/payment/domain/entity/RefundCalculation.java deleted file mode 100644 index 21984210..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/RefundCalculation.java +++ /dev/null @@ -1,189 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -import com.sudo.railo.payment.domain.constant.PaymentPrecision; -import jakarta.persistence.*; -import lombok.*; -import lombok.extern.slf4j.Slf4j; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 환불 계산 엔티티 - * 시간대별 환불 수수료 계산 결과를 저장 - */ -@Entity -@Table(name = "refund_calculations", indexes = { - @Index(name = "idx_reservation_id", columnList = "reservation_id"), - @Index(name = "idx_refund_request_time", columnList = "refund_request_time"), - @Index(name = "idx_payment_id", columnList = "payment_id") -}) -@Getter @Builder -@NoArgsConstructor @AllArgsConstructor -@Slf4j -public class RefundCalculation { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "refund_calculation_id") - private Long id; - - @Column(name = "payment_id", nullable = false) - private Long paymentId; - - @Column(name = "reservation_id", nullable = false) - private Long reservationId; - - private Long memberId; - - @Column(name = "idempotency_key", length = 64, unique = true) - private String idempotencyKey; // 멱등성 보장을 위한 키 - - @Column(name = "original_amount", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE, nullable = false) - private BigDecimal originalAmount; // 원래 결제 금액 - - @Column(name = "mileage_used", precision = PaymentPrecision.MILEAGE_PRECISION, scale = PaymentPrecision.MILEAGE_SCALE) - @Builder.Default - private BigDecimal mileageUsed = BigDecimal.ZERO; // 사용한 마일리지 - - @Column(name = "refund_fee_rate", precision = PaymentPrecision.RATE_PRECISION, scale = PaymentPrecision.RATE_SCALE, nullable = false) - private BigDecimal refundFeeRate; // 환불 수수료율 (0.300 = 30%) - - @Column(name = "refund_fee", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE, nullable = false) - private BigDecimal refundFee; // 환불 수수료 - - @Column(name = "refund_amount", precision = PaymentPrecision.AMOUNT_PRECISION, scale = PaymentPrecision.AMOUNT_SCALE, nullable = false) - private BigDecimal refundAmount; // 실제 환불 금액 - - @Column(name = "mileage_refund_amount", precision = PaymentPrecision.MILEAGE_PRECISION, scale = PaymentPrecision.MILEAGE_SCALE) - @Builder.Default - private BigDecimal mileageRefundAmount = BigDecimal.ZERO; // 마일리지 환불 금액 - - @Column(name = "train_departure_time", nullable = false) - private LocalDateTime trainDepartureTime; // 열차 출발 시간 - - @Column(name = "train_arrival_time", nullable = false) - private LocalDateTime trainArrivalTime; // 열차 도착 시간 - - @Column(name = "refund_request_time", nullable = false) - private LocalDateTime refundRequestTime; // 환불 요청 시간 - - @Enumerated(EnumType.STRING) - @Column(name = "refund_type", nullable = false) - private RefundType refundType; // 환불 유형 - - @Enumerated(EnumType.STRING) - @Column(name = "refund_status", nullable = false) - @Builder.Default - private RefundStatus refundStatus = RefundStatus.PENDING; // 환불 처리 상태 - - @Column(name = "refund_reason") - private String refundReason; // 환불 사유 - - @Column(name = "processed_at") - private LocalDateTime processedAt; // 환불 처리 완료 시간 - - @CreationTimestamp - @Column(name = "created_at") - private LocalDateTime createdAt; - - @UpdateTimestamp - @Column(name = "updated_at") - private LocalDateTime updatedAt; - - /** - * 환불 처리 완료 표시 - */ - public void markAsProcessed() { - this.refundStatus = RefundStatus.COMPLETED; - this.processedAt = LocalDateTime.now(); - } - - /** - * 환불 처리 실패 표시 - */ - public void markAsFailed(String reason) { - this.refundStatus = RefundStatus.FAILED; - this.refundReason = reason; - } - - /** - * 환불 상태 업데이트 - */ - public void updateRefundStatus(RefundStatus status) { - if (status == null) { - throw new IllegalArgumentException("환불 상태는 null일 수 없습니다"); - } - this.refundStatus = status; - } - - /** - * 환불 사유 업데이트 - */ - public void updateRefundReason(String reason) { - this.refundReason = reason; - } - - /** - * 환불 가능 여부 확인 (시간 기준) - */ - public boolean isRefundableByTime() { - if (trainArrivalTime == null) { - log.warn("trainArrivalTime이 null입니다. refundCalculationId: {}", this.id); - // trainArrivalTime이 없으면 기본적으로 환불 가능으로 처리 (임시) - return true; - } - LocalDateTime now = LocalDateTime.now(); - // 열차 도착 시간 이후는 환불 불가 - boolean refundable = now.isBefore(trainArrivalTime); - log.info("환불 가능 여부 확인 - 현재시간: {}, 도착시간: {}, 환불가능: {}", now, trainArrivalTime, refundable); - return refundable; - } - - /** - * 환불 수수료율 계산 - * 철도 환불 정책에 따른 수수료율 계산 - * - * @deprecated RefundPolicyService를 사용하세요. - * 이 메서드는 하드코딩된 정책을 포함하고 있으며, - * 운영사별 다른 정책을 적용할 수 없습니다. - * @see com.sudo.railo.payment.domain.service.refund.RefundPolicyService - * @param departureTime 열차 출발 시간 - * @param arrivalTime 열차 도착 시간 - * @param requestTime 환불 요청 시간 - * @return 환불 수수료율 (0.0 ~ 1.0) - */ - @Deprecated - public static BigDecimal calculateRefundFeeRate(LocalDateTime departureTime, LocalDateTime arrivalTime, LocalDateTime requestTime) { - // 1. 도착 후 체크를 가장 먼저! (중요!) - if (requestTime.isAfter(arrivalTime)) { - return new BigDecimal("1.0"); // 100% 위약금 (환불 불가) - } - - // 2. 출발 전 환불 - 위약금 없음 - if (requestTime.isBefore(departureTime)) { - return BigDecimal.ZERO; // 0% 위약금 - } - - // 3. 출발 후 ~ 도착 전 환불 - long minutesAfterDeparture = java.time.Duration.between(departureTime, requestTime).toMinutes(); - - if (minutesAfterDeparture <= 20) { - return new BigDecimal("0.3"); // 30% 위약금 - } else if (minutesAfterDeparture <= 60) { - return new BigDecimal("0.4"); // 40% 위약금 - } else { - return new BigDecimal("0.7"); // 70% 위약금 - } - } - - /** - * 실패 사유 조회 (하위 호환성) - * getRefundReason()의 별칭 - */ - public String getFailureReason() { - return refundReason; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/RefundStatus.java b/src/main/java/com/sudo/railo/payment/domain/entity/RefundStatus.java deleted file mode 100644 index e02cba08..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/RefundStatus.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -/** - * 환불 처리 상태 - */ -public enum RefundStatus { - PENDING("환불 대기"), - PROCESSING("환불 처리 중"), - COMPLETED("환불 완료"), - FAILED("환불 실패"), - CANCELLED("환불 취소"), - UNKNOWN("상태 불명"), // 네트워크 오류 등으로 결과를 알 수 없는 상태 - ATTEMPTED("시도됨"); // 환불 시도 기록용 - - private final String description; - - RefundStatus(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/RefundType.java b/src/main/java/com/sudo/railo/payment/domain/entity/RefundType.java deleted file mode 100644 index bba22b83..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/RefundType.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -/** - * 환불 유형 - */ -public enum RefundType { - CHANGE("변경"), - CANCEL("취소"), - FULL("전체 환불"); - - private final String description; - - RefundType(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/entity/SavedPaymentMethod.java b/src/main/java/com/sudo/railo/payment/domain/entity/SavedPaymentMethod.java deleted file mode 100644 index aea9f76b..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/entity/SavedPaymentMethod.java +++ /dev/null @@ -1,230 +0,0 @@ -package com.sudo.railo.payment.domain.entity; - -import com.sudo.railo.global.domain.BaseEntity; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -/** - * 저장된 결제수단 엔티티 - */ -@Entity -@Table(name = "saved_payment_methods") -@Getter -@NoArgsConstructor -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Builder -public class SavedPaymentMethod extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private Long id; - - @Column(name = "member_id", nullable = false) - private Long memberId; - - @Column(name = "payment_method_type", nullable = false, length = 50) - private String paymentMethodType; - - @Column(name = "alias", length = 100) - private String alias; - - @Builder.Default - @Column(name = "is_default", nullable = false) - private Boolean isDefault = false; - - @Builder.Default - @Column(name = "is_active", nullable = false) - private Boolean isActive = true; - - // 신용카드 관련 필드 - 암호화 적용 - @Column(name = "card_number_encrypted", length = 500) - private String cardNumberEncrypted; - - @Column(name = "card_number_hash", length = 100) - private String cardNumberHash; // 검색용 해시 - - @Column(name = "card_last_four_digits", length = 4) - private String cardLastFourDigits; // 마스킹 표시용 - - @Column(name = "card_holder_name_encrypted", length = 500) - private String cardHolderNameEncrypted; - - @Column(name = "card_expiry_month_encrypted", length = 100) - private String cardExpiryMonthEncrypted; - - @Column(name = "card_expiry_year_encrypted", length = 100) - private String cardExpiryYearEncrypted; - - // 계좌 관련 필드 - 암호화 적용 - @Column(name = "bank_code", length = 10) - private String bankCode; - - @Column(name = "account_number_encrypted", length = 500) - private String accountNumberEncrypted; - - @Column(name = "account_number_hash", length = 100) - private String accountNumberHash; // 검색용 해시 - - @Column(name = "account_last_four_digits", length = 4) - private String accountLastFourDigits; // 마스킹 표시용 - - @Column(name = "account_holder_name_encrypted", length = 500) - private String accountHolderNameEncrypted; - - @Column(name = "account_password_encrypted", length = 500) - private String accountPasswordEncrypted; - - // 보안 관련 메타데이터 - @Column(name = "encryption_version", length = 10) - private String encryptionVersion; // 암호화 버전 (키 로테이션 지원) - - @Column(name = "last_used_at") - private LocalDateTime lastUsedAt; - - /** - * 마스킹된 카드번호 반환 - */ - @Transient - public String getMaskedCardNumber() { - if (cardLastFourDigits == null || cardLastFourDigits.isEmpty()) { - return "****"; - } - return "**** **** **** " + cardLastFourDigits; - } - - /** - * 마스킹된 계좌번호 반환 - */ - @Transient - public String getMaskedAccountNumber() { - if (accountLastFourDigits == null || accountLastFourDigits.isEmpty()) { - return "****"; - } - return "****" + accountLastFourDigits; - } - - /** - * 결제수단이 카드인지 확인 - */ - @Transient - public boolean isCard() { - return "CARD".equalsIgnoreCase(paymentMethodType) || - "CREDIT_CARD".equalsIgnoreCase(paymentMethodType) || - "DEBIT_CARD".equalsIgnoreCase(paymentMethodType); - } - - /** - * 결제수단이 계좌인지 확인 - */ - @Transient - public boolean isAccount() { - return "ACCOUNT".equalsIgnoreCase(paymentMethodType) || - "BANK_ACCOUNT".equalsIgnoreCase(paymentMethodType); - } - - /** - * 사용 시간 업데이트 - */ - public void updateLastUsedAt() { - this.lastUsedAt = LocalDateTime.now(); - } - - /** - * 기본 결제수단으로 설정 - */ - public void setAsDefault() { - this.isDefault = true; - } - - /** - * 기본 결제수단 해제 - */ - public void unsetAsDefault() { - this.isDefault = false; - } - - /** - * 활성화 - */ - public void activate() { - this.isActive = true; - } - - /** - * 비활성화 - */ - public void deactivate() { - this.isActive = false; - } - - /** - * 카드 정보 암호화 설정 - */ - public void setEncryptedCardInfo(String encryptedNumber, String numberHash, String lastFourDigits, - String encryptedHolderName, String encryptedExpiryMonth, - String encryptedExpiryYear) { - this.cardNumberEncrypted = encryptedNumber; - this.cardNumberHash = numberHash; - this.cardLastFourDigits = lastFourDigits; - this.cardHolderNameEncrypted = encryptedHolderName; - this.cardExpiryMonthEncrypted = encryptedExpiryMonth; - this.cardExpiryYearEncrypted = encryptedExpiryYear; - } - - /** - * 계좌 정보 암호화 설정 - */ - public void setEncryptedAccountInfo(String encryptedNumber, String numberHash, String lastFourDigits, - String encryptedHolderName, String encryptedPassword) { - this.accountNumberEncrypted = encryptedNumber; - this.accountNumberHash = numberHash; - this.accountLastFourDigits = lastFourDigits; - this.accountHolderNameEncrypted = encryptedHolderName; - this.accountPasswordEncrypted = encryptedPassword; - } - - /** - * 암호화 버전 업데이트 - */ - public void updateEncryptionVersion(String version) { - this.encryptionVersion = version; - } - - /** - * 엔티티 저장 전 시간 설정 - */ - @PrePersist - public void prePersist() { - if (getCreatedAt() == null) { - // BaseEntity의 @CreatedDate가 작동하지 않을 경우를 대비 - try { - java.lang.reflect.Field createdAtField = BaseEntity.class.getDeclaredField("createdAt"); - createdAtField.setAccessible(true); - createdAtField.set(this, LocalDateTime.now()); - - java.lang.reflect.Field updatedAtField = BaseEntity.class.getDeclaredField("updatedAt"); - updatedAtField.setAccessible(true); - updatedAtField.set(this, LocalDateTime.now()); - } catch (Exception e) { - // ignore - } - } - } - - /** - * 엔티티 업데이트 전 시간 설정 - */ - @PreUpdate - public void preUpdate() { - try { - java.lang.reflect.Field updatedAtField = BaseEntity.class.getDeclaredField("updatedAt"); - updatedAtField.setAccessible(true); - updatedAtField.set(this, LocalDateTime.now()); - } catch (Exception e) { - // ignore - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/repository/DomainEventOutboxRepository.java b/src/main/java/com/sudo/railo/payment/domain/repository/DomainEventOutboxRepository.java deleted file mode 100644 index 0ce8b2dd..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/repository/DomainEventOutboxRepository.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.sudo.railo.payment.domain.repository; - -import com.sudo.railo.payment.domain.entity.DomainEventOutbox; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * 도메인 이벤트 Outbox Repository - * Outbox Pattern을 통한 안정적인 이벤트 처리를 위한 Repository - */ -public interface DomainEventOutboxRepository { - - /** - * 처리 대기 중인 이벤트 조회 (처리 순서 보장) - */ - List findPendingEventsOrderByCreatedAt(); - - /** - * 처리 대기 중인 이벤트 조회 (제한된 개수) - */ - List findPendingEventsWithLimit(int limit); - - /** - * 재시도 가능한 실패 이벤트 조회 - */ - List findRetryableFailedEvents(int maxRetryCount); - - /** - * 특정 이벤트 타입의 처리 대기 이벤트 조회 - */ - List findPendingEventsByType( - DomainEventOutbox.EventType eventType); - - /** - * 특정 애그리거트의 이벤트 조회 - */ - List findEventsByAggregate( - DomainEventOutbox.AggregateType aggregateType, - String aggregateId); - - /** - * 특정 애그리거트의 최근 이벤트 조회 - */ - Optional findLatestCompletedEventByAggregate( - DomainEventOutbox.AggregateType aggregateType, - String aggregateId); - - /** - * 처리 중인 이벤트 조회 (오래된 순) - */ - List findProcessingEventsOrderByUpdatedAt(); - - /** - * 특정 시간 이전의 완료된 이벤트 조회 (정리용) - */ - List findCompletedEventsBeforeTime( - LocalDateTime beforeTime); - - /** - * 오래된 완료 이벤트 삭제 (배치 작업용) - */ - int deleteCompletedEventsBeforeTime(LocalDateTime beforeTime); - - /** - * 타임아웃된 처리 중 이벤트를 PENDING으로 복원 (복구용) - */ - int resetTimeoutProcessingEventsToPending(LocalDateTime timeoutTime); - - /** - * 이벤트 처리 통계 조회 - */ - Object getEventStatistics(LocalDateTime fromTime); - - /** - * 이벤트 타입별 통계 조회 - */ - List getEventTypeStatistics(LocalDateTime fromTime); - - /** - * 특정 시간 범위의 이벤트 조회 (모니터링용) - */ - Page findEventsByTimeRange( - LocalDateTime startTime, - LocalDateTime endTime, - Pageable pageable); - - /** - * 실패한 이벤트들 조회 (에러 분석용) - */ - Page findFailedEvents(Pageable pageable); - - /** - * 특정 이벤트가 이미 처리되었는지 확인 - */ - boolean isEventAlreadyProcessed(String eventId); - - /** - * 처리 상태별 이벤트 개수 조회 - */ - long countByStatus(DomainEventOutbox.EventStatus status); - - /** - * 이벤트 타입별 이벤트 개수 조회 - */ - long countByEventType(DomainEventOutbox.EventType eventType); - - /** - * 애그리거트 ID로 이벤트 조회 (테스트 호환용) - * 애그리거트 타입에 관계없이 ID만으로 조회 - */ - List findByAggregateId(String aggregateId); - - /** - * ID로 이벤트 조회 - */ - Optional findById(String id); - - /** - * 이벤트 저장 - */ - DomainEventOutbox save(DomainEventOutbox event); - - /** - * 이벤트 삭제 - */ - void delete(DomainEventOutbox event); - - /** - * ID로 이벤트 삭제 - */ - void deleteById(String id); - - /** - * ID 존재 여부 확인 - */ - boolean existsById(String id); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/repository/MileageEarningScheduleRepository.java b/src/main/java/com/sudo/railo/payment/domain/repository/MileageEarningScheduleRepository.java deleted file mode 100644 index 9138b461..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/repository/MileageEarningScheduleRepository.java +++ /dev/null @@ -1,304 +0,0 @@ -package com.sudo.railo.payment.domain.repository; - -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -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.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * 마일리지 적립 스케줄 Repository - * TrainSchedule과 Payment를 연결하여 실시간 마일리지 적립을 관리 - */ -@Repository -public interface MileageEarningScheduleRepository extends JpaRepository { - - /** - * 처리 대기 중인 적립 스케줄 조회 (처리 시간 순) - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.status = 'READY' " + - "AND mes.scheduledEarningTime <= :currentTime " + - "ORDER BY mes.scheduledEarningTime ASC") - List findReadySchedulesForProcessing( - @Param("currentTime") LocalDateTime currentTime); - - /** - * 처리 준비된 스케줄 조회 (제한된 개수) - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.status = 'READY' " + - "AND mes.scheduledEarningTime <= :currentTime " + - "ORDER BY mes.scheduledEarningTime ASC " + - "LIMIT :limit") - List findReadySchedulesWithLimit( - @Param("currentTime") LocalDateTime currentTime, - @Param("limit") int limit); - - /** - * 처리 준비된 스케줄 조회 (비관적 락 사용) - * FOR UPDATE SKIP LOCKED를 사용하여 다른 트랜잭션이 잠근 행은 건너뜀 - * 동시성 문제를 해결하여 여러 스케줄러가 중복 처리하지 않도록 함 - */ - @Query(value = "SELECT * FROM mileage_earning_schedules mes " + - "WHERE mes.status = 'READY' " + - "AND mes.scheduled_earning_time <= :currentTime " + - "ORDER BY mes.scheduled_earning_time ASC " + - "LIMIT :limit " + - "FOR UPDATE SKIP LOCKED", - nativeQuery = true) - List findReadySchedulesWithLockAndLimit( - @Param("currentTime") LocalDateTime currentTime, - @Param("limit") int limit); - - /** - * 특정 열차 스케줄의 적립 스케줄 조회 - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.trainScheduleId = :trainScheduleId " + - "ORDER BY mes.createdAt DESC") - List findByTrainScheduleId(@Param("trainScheduleId") Long trainScheduleId); - - /** - * 특정 결제의 적립 스케줄 조회 - */ - Optional findByPaymentId(String paymentId); - - /** - * 회원의 적립 스케줄 조회 (페이징) - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.memberId = :memberId " + - "ORDER BY mes.createdAt DESC") - Page findByMemberIdOrderByCreatedAtDesc( - @Param("memberId") Long memberId, Pageable pageable); - - /** - * 회원의 특정 상태 적립 스케줄 조회 - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.memberId = :memberId " + - "AND mes.status = :status " + - "ORDER BY mes.scheduledEarningTime DESC") - List findByMemberIdAndStatus( - @Param("memberId") Long memberId, - @Param("status") MileageEarningSchedule.EarningStatus status); - - /** - * 회원의 모든 적립 스케줄 조회 (상태 무관) - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.memberId = :memberId " + - "ORDER BY mes.scheduledEarningTime DESC") - List findByMemberId(@Param("memberId") Long memberId); - - /** - * 기본 적립 완료, 지연 보상 대기 중인 스케줄 조회 - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.status = 'BASE_COMPLETED' " + - "AND mes.delayCompensationAmount > 0 " + - "ORDER BY mes.scheduledEarningTime ASC") - List findSchedulesAwaitingDelayCompensation(); - - /** - * 특정 기간의 적립 예정 스케줄 조회 - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.scheduledEarningTime BETWEEN :startTime AND :endTime " + - "AND mes.status IN ('SCHEDULED', 'READY') " + - "ORDER BY mes.scheduledEarningTime ASC") - List findSchedulesByEarningTimeRange( - @Param("startTime") LocalDateTime startTime, - @Param("endTime") LocalDateTime endTime); - - /** - * 실패한 적립 스케줄 조회 (재처리용) - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.status = 'FAILED' " + - "ORDER BY mes.updatedAt ASC") - List findFailedSchedulesForRetry(); - - /** - * 특정 열차 스케줄의 상태별 개수 조회 - */ - @Query("SELECT mes.status, COUNT(*) " + - "FROM MileageEarningSchedule mes " + - "WHERE mes.trainScheduleId = :trainScheduleId " + - "GROUP BY mes.status") - List countByTrainScheduleIdGroupByStatus(@Param("trainScheduleId") Long trainScheduleId); - - /** - * 회원의 총 적립 예정 마일리지 조회 - */ - @Query("SELECT COALESCE(SUM(mes.totalMileageAmount), 0) " + - "FROM MileageEarningSchedule mes " + - "WHERE mes.memberId = :memberId " + - "AND mes.status IN ('SCHEDULED', 'READY', 'BASE_PROCESSING', 'BASE_COMPLETED', 'COMPENSATION_PROCESSING')") - BigDecimal calculatePendingMileageByMemberId(@Param("memberId") Long memberId); - - /** - * 특정 기간의 적립 완료된 마일리지 통계 - */ - @Query("SELECT new map(" + - "COUNT(*) as totalSchedules, " + - "SUM(mes.baseMileageAmount) as totalBaseMileage, " + - "SUM(mes.delayCompensationAmount) as totalDelayCompensation, " + - "SUM(mes.totalMileageAmount) as totalMileage, " + - "COUNT(CASE WHEN mes.delayMinutes > 0 THEN 1 END) as delayedTrainCount, " + - "AVG(mes.delayMinutes) as averageDelayMinutes) " + - "FROM MileageEarningSchedule mes " + - "WHERE mes.status = 'FULLY_COMPLETED' " + - "AND mes.processedAt BETWEEN :startTime AND :endTime") - Object getMileageEarningStatistics( - @Param("startTime") LocalDateTime startTime, - @Param("endTime") LocalDateTime endTime); - - /** - * 지연 보상 통계 조회 - */ - @Query("SELECT new map(" + - "COUNT(CASE WHEN mes.delayMinutes >= 20 AND mes.delayMinutes < 40 THEN 1 END) as delay20to40Count, " + - "COUNT(CASE WHEN mes.delayMinutes >= 40 AND mes.delayMinutes < 60 THEN 1 END) as delay40to60Count, " + - "COUNT(CASE WHEN mes.delayMinutes >= 60 THEN 1 END) as delayOver60Count, " + - "SUM(CASE WHEN mes.delayMinutes >= 20 THEN mes.delayCompensationAmount ELSE 0 END) as totalCompensation) " + - "FROM MileageEarningSchedule mes " + - "WHERE mes.processedAt BETWEEN :startTime AND :endTime") - Object getDelayCompensationStatistics( - @Param("startTime") LocalDateTime startTime, - @Param("endTime") LocalDateTime endTime); - - /** - * 특정 상태의 스케줄 개수 조회 - */ - long countByStatus(MileageEarningSchedule.EarningStatus status); - - /** - * 특정 회원의 완료된 적립 스케줄 조회 - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.memberId = :memberId " + - "AND mes.status = 'FULLY_COMPLETED' " + - "ORDER BY mes.processedAt DESC") - Page findCompletedSchedulesByMemberId( - @Param("memberId") Long memberId, Pageable pageable); - - /** - * 오래된 완료 스케줄 삭제 (정리용) - */ - @Modifying - @Query("DELETE FROM MileageEarningSchedule mes " + - "WHERE mes.status = 'FULLY_COMPLETED' " + - "AND mes.processedAt < :beforeTime") - int deleteCompletedSchedulesBeforeTime(@Param("beforeTime") LocalDateTime beforeTime); - - /** - * 특정 결제들의 적립 스케줄 조회 (배치 처리용) - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.paymentId IN :paymentIds") - List findByPaymentIds(@Param("paymentIds") List paymentIds); - - /** - * 스케줄 상태를 READY로 일괄 업데이트 - */ - @Modifying - @Query("UPDATE MileageEarningSchedule mes " + - "SET mes.status = 'READY' " + - "WHERE mes.trainScheduleId = :trainScheduleId " + - "AND mes.status = 'SCHEDULED'") - int markSchedulesReadyByTrainSchedule(@Param("trainScheduleId") Long trainScheduleId); - - /** - * 처리 시간이 지났지만 아직 SCHEDULED 상태인 스케줄 조회 - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.status = 'SCHEDULED' " + - "AND mes.scheduledEarningTime <= :currentTime " + - "ORDER BY mes.scheduledEarningTime ASC") - List findOverdueScheduledEarnings(@Param("currentTime") LocalDateTime currentTime); - - /** - * 특정 회원의 월별 마일리지 적립 통계 - */ - @Query("SELECT YEAR(mes.processedAt), MONTH(mes.processedAt), " + - "SUM(mes.baseMileageAmount), SUM(mes.delayCompensationAmount), SUM(mes.totalMileageAmount) " + - "FROM MileageEarningSchedule mes " + - "WHERE mes.memberId = :memberId " + - "AND mes.status = 'FULLY_COMPLETED' " + - "AND mes.processedAt >= :fromDate " + - "GROUP BY YEAR(mes.processedAt), MONTH(mes.processedAt) " + - "ORDER BY YEAR(mes.processedAt), MONTH(mes.processedAt)") - List getMonthlyMileageStatisticsByMemberId( - @Param("memberId") Long memberId, - @Param("fromDate") LocalDateTime fromDate); - - /** - * 원자적 상태 변경 - * 예상 상태일 때만 새로운 상태로 변경하여 동시성 문제 방지 - * - * @return 업데이트된 행 수 (0이면 이미 다른 프로세스가 처리) - */ - @Modifying - @Query("UPDATE MileageEarningSchedule mes " + - "SET mes.status = :newStatus " + - "WHERE mes.id = :scheduleId " + - "AND mes.status = :expectedStatus") - int updateStatusAtomically(@Param("scheduleId") Long scheduleId, - @Param("expectedStatus") MileageEarningSchedule.EarningStatus expectedStatus, - @Param("newStatus") MileageEarningSchedule.EarningStatus newStatus); - - /** - * 스케줄 처리 완료 시 트랜잭션 정보와 함께 원자적 업데이트 - */ - @Modifying - @Query("UPDATE MileageEarningSchedule mes " + - "SET mes.status = :newStatus, " + - " mes.baseTransactionId = :transactionId, " + - " mes.processedAt = CASE WHEN :isFullyCompleted = true THEN CURRENT_TIMESTAMP ELSE mes.processedAt END " + - "WHERE mes.id = :scheduleId " + - "AND mes.status = :expectedStatus") - int updateWithTransactionAtomically(@Param("scheduleId") Long scheduleId, - @Param("expectedStatus") MileageEarningSchedule.EarningStatus expectedStatus, - @Param("newStatus") MileageEarningSchedule.EarningStatus newStatus, - @Param("transactionId") Long transactionId, - @Param("isFullyCompleted") boolean isFullyCompleted); - - /** - * 도착 시간이 지난 SCHEDULED 상태의 스케줄 조회 - */ - default List findScheduledBeforeTime(LocalDateTime currentTime) { - return findOverdueScheduledEarnings(currentTime); - } - - /** - * READY 상태의 모든 스케줄 조회 - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.status = 'READY' " + - "ORDER BY mes.scheduledEarningTime ASC") - List findReadySchedules(); - - /** - * 기본 적립 완료되고 지연 보상이 있는 스케줄 조회 - */ - @Query("SELECT mes FROM MileageEarningSchedule mes " + - "WHERE mes.status = 'BASE_COMPLETED' " + - "AND mes.delayCompensationAmount > 0 " + - "ORDER BY mes.scheduledEarningTime ASC") - List findBaseCompletedWithCompensation(); - - /** - * 특정 상태의 스케줄 조회 - */ - List findByStatus(MileageEarningSchedule.EarningStatus status); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/repository/MileageTransactionRepository.java b/src/main/java/com/sudo/railo/payment/domain/repository/MileageTransactionRepository.java deleted file mode 100644 index 1aec70a8..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/repository/MileageTransactionRepository.java +++ /dev/null @@ -1,415 +0,0 @@ -package com.sudo.railo.payment.domain.repository; - -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * 마일리지 거래 내역 Repository - */ -public interface MileageTransactionRepository extends JpaRepository { - - /** - * 회원의 마일리지 거래 내역 조회 (페이징) - */ - Page findByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable); - - /** - * 회원의 특정 기간 마일리지 거래 내역 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.createdAt BETWEEN :startDate AND :endDate " + - "ORDER BY mt.createdAt DESC") - Page findByMemberIdAndDateRange( - @Param("memberId") Long memberId, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate, - Pageable pageable); - - /** - * 회원의 모든 마일리지 거래 내역 조회 (테스트 호환용) - */ - List findByMemberId(Long memberId); - - /** - * 특정 결제와 연관된 마일리지 거래 내역 조회 - */ - List findByPaymentIdOrderByCreatedAtDesc(String paymentId); - - /** - * 특정 결제와 연관된 마일리지 거래 내역 조회 (테스트 호환용) - */ - List findByPaymentId(String paymentId); - - /** - * 여러 결제 ID에 대한 마일리지 거래 내역 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.paymentId IN :paymentIds " + - "ORDER BY mt.createdAt DESC") - List findByPaymentIds(@Param("paymentIds") List paymentIds); - - /** - * 회원의 현재 마일리지 잔액 계산 - */ - @Query("SELECT COALESCE(SUM(mt.pointsAmount), 0) " + - "FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.status = 'COMPLETED'") - BigDecimal calculateCurrentBalance(@Param("memberId") Long memberId); - - /** - * 회원의 활성 마일리지 잔액 계산 (만료되지 않은 것만) - */ - @Query("SELECT COALESCE(SUM(mt.pointsAmount), 0) " + - "FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.status = 'COMPLETED' " + - "AND (mt.expiresAt IS NULL OR mt.expiresAt > :currentTime)") - BigDecimal calculateActiveBalance(@Param("memberId") Long memberId, - @Param("currentTime") LocalDateTime currentTime); - - /** - * 회원의 최근 거래 내역 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.status = 'COMPLETED' " + - "ORDER BY mt.createdAt DESC " + - "LIMIT :limit") - List findRecentTransactionsByMemberId(@Param("memberId") Long memberId, - @Param("limit") Integer limit); - - - - /** - * 만료 예정 마일리지 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED' " + - "AND mt.expiresAt BETWEEN :startTime AND :endTime") - List findExpiringMileage(@Param("startTime") LocalDateTime startTime, - @Param("endTime") LocalDateTime endTime); - - /** - * 회원의 전체 거래 내역 조회 (페이징) - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "ORDER BY mt.createdAt DESC") - List findByMemberIdOrderByCreatedAtDesc(@Param("memberId") Long memberId); - - /** - * 기간별 마일리지 거래 통계 - */ - @Query("SELECT new map(" + - "SUM(CASE WHEN mt.type = 'EARN' THEN mt.pointsAmount ELSE 0 END) as totalEarned, " + - "SUM(CASE WHEN mt.type = 'USE' THEN ABS(mt.pointsAmount) ELSE 0 END) as totalUsed, " + - "COUNT(*) as transactionCount) " + - "FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.status = 'COMPLETED' " + - "AND mt.createdAt BETWEEN :startDate AND :endDate") - Object getMileageStatistics(@Param("memberId") Long memberId, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); - - /** - * 사용 가능한 마일리지 조회 (FIFO 순서, 만료일 순) - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED' " + - "AND (mt.expiresAt IS NULL OR mt.expiresAt > :currentTime) " + - "ORDER BY mt.expiresAt ASC, mt.createdAt ASC") - List findAvailableMileageForUsage(@Param("memberId") Long memberId, - @Param("currentTime") LocalDateTime currentTime); - - /** - * 특정 결제에 대한 마일리지 사용 내역 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.paymentId = :paymentId " + - "AND mt.type = 'USE' " + - "AND mt.status = 'COMPLETED'") - List findMileageUsageByPaymentId(@Param("paymentId") String paymentId); - - /** - * 특정 결제에 대한 마일리지 적립 내역 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.paymentId = :paymentId " + - "AND mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED'") - List findMileageEarningByPaymentId(@Param("paymentId") String paymentId); - - // 🆕 새로운 마일리지 시스템을 위한 메서드들 - - /** - * 특정 열차 스케줄과 관련된 마일리지 거래 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.trainScheduleId = :trainScheduleId " + - "ORDER BY mt.createdAt DESC") - List findByTrainScheduleId(@Param("trainScheduleId") Long trainScheduleId); - - /** - * 특정 적립 스케줄과 관련된 마일리지 거래 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.earningScheduleId = :earningScheduleId " + - "ORDER BY mt.createdAt ASC") - List findByEarningScheduleId(@Param("earningScheduleId") Long earningScheduleId); - - /** - * 특정 적립 스케줄의 기본 마일리지 거래 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.earningScheduleId = :earningScheduleId " + - "AND mt.earningType = 'BASE_EARN' " + - "AND mt.status = 'COMPLETED'") - Optional findBaseEarningByScheduleId(@Param("earningScheduleId") Long earningScheduleId); - - /** - * 특정 적립 스케줄의 지연 보상 거래 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.earningScheduleId = :earningScheduleId " + - "AND mt.earningType = 'DELAY_COMPENSATION' " + - "AND mt.status = 'COMPLETED'") - Optional findDelayCompensationByScheduleId(@Param("earningScheduleId") Long earningScheduleId); - - /** - * 회원의 적립 타입별 마일리지 거래 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'EARN' " + - "AND mt.earningType = :earningType " + - "AND mt.status = 'COMPLETED' " + - "ORDER BY mt.createdAt DESC") - List findByMemberIdAndEarningType( - @Param("memberId") Long memberId, - @Param("earningType") MileageTransaction.EarningType earningType); - - /** - * 지연 보상 마일리지 거래 조회 (통계용) - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.earningType = 'DELAY_COMPENSATION' " + - "AND mt.status = 'COMPLETED' " + - "AND mt.createdAt BETWEEN :startTime AND :endTime " + - "ORDER BY mt.createdAt DESC") - List findDelayCompensationTransactions( - @Param("startTime") LocalDateTime startTime, - @Param("endTime") LocalDateTime endTime); - - /** - * 회원의 지연 보상 총액 계산 - */ - @Query("SELECT COALESCE(SUM(mt.pointsAmount), 0) " + - "FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.earningType = 'DELAY_COMPENSATION' " + - "AND mt.status = 'COMPLETED'") - BigDecimal calculateTotalDelayCompensationByMemberId(@Param("memberId") Long memberId); - - /** - * 특정 기간의 적립 타입별 통계 - */ - @Query("SELECT mt.earningType, " + - "COUNT(*) as transactionCount, " + - "SUM(mt.pointsAmount) as totalAmount, " + - "AVG(mt.delayMinutes) as averageDelayMinutes " + - "FROM MileageTransaction mt " + - "WHERE mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED' " + - "AND mt.createdAt BETWEEN :startTime AND :endTime " + - "GROUP BY mt.earningType") - List getEarningTypeStatistics( - @Param("startTime") LocalDateTime startTime, - @Param("endTime") LocalDateTime endTime); - - /** - * 특정 회원의 열차별 마일리지 적립 내역 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.trainScheduleId IS NOT NULL " + - "AND mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED' " + - "ORDER BY mt.createdAt DESC") - Page findTrainRelatedEarningsByMemberId( - @Param("memberId") Long memberId, Pageable pageable); - - /** - * 지연 시간대별 보상 마일리지 통계 - */ - @Query("SELECT " + - "CASE " + - " WHEN mt.delayMinutes >= 20 AND mt.delayMinutes < 40 THEN '20-40min' " + - " WHEN mt.delayMinutes >= 40 AND mt.delayMinutes < 60 THEN '40-60min' " + - " WHEN mt.delayMinutes >= 60 THEN '60min+' " + - " ELSE 'no_delay' " + - "END as delayRange, " + - "COUNT(*) as transactionCount, " + - "SUM(mt.pointsAmount) as totalCompensation " + - "FROM MileageTransaction mt " + - "WHERE mt.earningType = 'DELAY_COMPENSATION' " + - "AND mt.status = 'COMPLETED' " + - "AND mt.createdAt BETWEEN :startTime AND :endTime " + - "GROUP BY delayRange") - List getDelayCompensationStatisticsByDelayTime( - @Param("startTime") LocalDateTime startTime, - @Param("endTime") LocalDateTime endTime); - - /** - * 특정 결제의 모든 관련 마일리지 거래 조회 (사용 + 적립) - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.paymentId = :paymentId " + - "ORDER BY " + - "CASE WHEN mt.type = 'USE' THEN 1 " + - " WHEN mt.earningType = 'BASE_EARN' THEN 2 " + - " WHEN mt.earningType = 'DELAY_COMPENSATION' THEN 3 " + - " ELSE 4 END, " + - "mt.createdAt ASC") - List findAllMileageTransactionsByPaymentId(@Param("paymentId") String paymentId); - - /** - * 회원의 마일리지 적립 내역 조회 (기본 + 지연보상) - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'EARN' " + - "AND mt.earningType IN ('BASE_EARN', 'DELAY_COMPENSATION') " + - "AND mt.status = 'COMPLETED' " + - "ORDER BY mt.createdAt DESC") - Page findTrainEarningsByMemberId( - @Param("memberId") Long memberId, Pageable pageable); - - /** - * 특정 열차 스케줄의 총 지급된 마일리지 계산 - */ - @Query("SELECT COALESCE(SUM(mt.pointsAmount), 0) " + - "FROM MileageTransaction mt " + - "WHERE mt.trainScheduleId = :trainScheduleId " + - "AND mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED'") - BigDecimal calculateTotalMileageByTrainSchedule(@Param("trainScheduleId") Long trainScheduleId); - - /** - * 미처리된 마일리지 거래 조회 (재처리용) - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.status = 'PENDING' " + - "AND mt.createdAt < :beforeTime " + - "ORDER BY mt.createdAt ASC") - List findPendingTransactionsBeforeTime(@Param("beforeTime") LocalDateTime beforeTime); - - /** - * 회원의 특정 기간 마일리지 적립 총액 계산 - */ - @Query("SELECT COALESCE(SUM(mt.pointsAmount), 0) " + - "FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED' " + - "AND mt.createdAt BETWEEN :startDate AND :endDate") - BigDecimal calculateTotalEarnedInPeriod(@Param("memberId") Long memberId, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); - - /** - * 회원의 특정 기간 마일리지 사용 총액 계산 - */ - @Query("SELECT COALESCE(SUM(ABS(mt.pointsAmount)), 0) " + - "FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'USE' " + - "AND mt.status = 'COMPLETED' " + - "AND mt.createdAt BETWEEN :startDate AND :endDate") - BigDecimal calculateTotalUsedInPeriod(@Param("memberId") Long memberId, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); - - /** - * 회원의 특정 기간 만료된 마일리지 총액 계산 - */ - @Query("SELECT COALESCE(SUM(mt.pointsAmount), 0) " + - "FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'EXPIRE' " + - "AND mt.status = 'COMPLETED' " + - "AND mt.createdAt BETWEEN :startDate AND :endDate") - BigDecimal calculateTotalExpiredInPeriod(@Param("memberId") Long memberId, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); - - /** - * 회원의 특정 기간 타입별 적립 내역 - */ - @Query("SELECT mt.earningType, COUNT(*), SUM(mt.pointsAmount) " + - "FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED' " + - "AND mt.createdAt BETWEEN :startDate AND :endDate " + - "GROUP BY mt.earningType") - List getEarningByTypeInPeriod(@Param("memberId") Long memberId, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); - - /** - * 회원의 특정 기차별 마일리지 적립 이력 조회 - * Native Query를 사용하여 train_schedule 테이블과 조인 - */ - @Query(value = "SELECT mt.* FROM mileage_transactions mt " + - "JOIN train_schedule ts ON mt.train_schedule_id = ts.id " + - "WHERE mt.member_id = :memberId " + - "AND ts.train_no = :trainId " + - "AND mt.transaction_type = 'EARN' " + - "AND mt.status = 'COMPLETED' " + - "AND (:startDate IS NULL OR mt.created_at >= :startDate) " + - "AND (:endDate IS NULL OR mt.created_at <= :endDate) " + - "ORDER BY mt.created_at DESC", - nativeQuery = true) - List findEarningHistoryByTrainId(@Param("memberId") Long memberId, - @Param("trainId") String trainId, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); - - /** - * 회원의 특정 기간 마일리지 적립 이력 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED' " + - "AND mt.createdAt BETWEEN :startDate AND :endDate " + - "ORDER BY mt.createdAt DESC") - List findEarningHistoryByPeriod(@Param("memberId") Long memberId, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); - - /** - * 회원의 모든 마일리지 적립 이력 조회 - */ - @Query("SELECT mt FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED' " + - "ORDER BY mt.createdAt DESC") - List findAllEarningHistory(@Param("memberId") Long memberId); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/repository/PaymentCalculationRepository.java b/src/main/java/com/sudo/railo/payment/domain/repository/PaymentCalculationRepository.java deleted file mode 100644 index 5e9ab450..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/repository/PaymentCalculationRepository.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.sudo.railo.payment.domain.repository; - -import com.sudo.railo.payment.domain.entity.CalculationStatus; -import com.sudo.railo.payment.domain.entity.PaymentCalculation; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * PaymentCalculation 도메인 리포지토리 인터페이스 - * 결제 계산 세션 관리를 위한 데이터 접근 메서드 정의 - */ -public interface PaymentCalculationRepository { - - /** - * 계산 정보 저장 - */ - PaymentCalculation save(PaymentCalculation calculation); - - /** - * 계산 ID로 조회 - */ - Optional findById(String calculationId); - - /** - * 외부 주문 ID로 조회 - */ - Optional findByExternalOrderId(String externalOrderId); - - /** - * 사용자 ID로 활성 계산 조회 - */ - List findByUserIdExternalAndStatus(String userId, CalculationStatus status); - - /** - * 만료된 계산 세션 조회 - */ - List findByExpiresAtBeforeAndStatus(LocalDateTime expireTime, CalculationStatus status); - - /** - * 계산 상태 일괄 업데이트 - */ - void updateStatusByIds(List calculationIds, CalculationStatus newStatus); - - /** - * 만료된 계산 삭제 - */ - void deleteByExpiresAtBeforeAndStatus(LocalDateTime expireTime, CalculationStatus status); - - /** - * PG 주문번호로 조회 - */ - Optional findByPgOrderId(String pgOrderId); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/repository/PaymentRepository.java b/src/main/java/com/sudo/railo/payment/domain/repository/PaymentRepository.java deleted file mode 100644 index 13a29a74..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/repository/PaymentRepository.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.sudo.railo.payment.domain.repository; - -import com.sudo.railo.payment.domain.entity.Payment; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * Payment 도메인 리포지토리 인터페이스 - * 도메인 관점에서 필요한 데이터 접근 메서드를 정의 - */ -public interface PaymentRepository { - - /** - * 결제 정보 저장 - */ - Payment save(Payment payment); - - /** - * ID로 결제 정보 조회 - */ - Optional findById(Long paymentId); - - /** - * 예약 ID로 결제 정보 조회 - */ - Optional findByReservationId(Long reservationId); - - /** - * 외부 주문 ID로 결제 정보 조회 - */ - Optional findByExternalOrderId(String externalOrderId); - - /** - * 회원 ID로 결제 목록 조회 - */ - List findByMemberId(Long memberId); - - /** - * 회원 ID로 결제 내역 페이징 조회 (최신순) - */ - Page findByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable); - - /** - * 회원 ID + 기간별 결제 내역 페이징 조회 - */ - Page findByMemberIdAndCreatedAtBetweenOrderByCreatedAtDesc( - Long memberId, - LocalDateTime startDate, - LocalDateTime endDate, - Pageable pageable - ); - - /** - * 비회원 정보로 결제 조회 - */ - Optional findByNonMemberNameAndNonMemberPhone(String name, String phone); - - /** - * 멱등성 키 존재 여부 확인 - */ - boolean existsByIdempotencyKey(String idempotencyKey); - - /** - * 멱등성 키로 결제 정보 조회 - */ - Optional findByIdempotencyKey(String idempotencyKey); - - /** - * 모든 결제 정보 조회 (테스트용) - */ - List findAll(); - - /** - * PG 승인번호 존재 여부 확인 - */ - boolean existsByPgApprovalNo(String pgApprovalNo); - - /** - * PG 승인번호로 결제 조회 - */ - Optional findByPgApprovalNo(String pgApprovalNo); - - /** - * 예약 ID로 삭제되지 않은 결제 정보 조회 - */ - Optional findByReservationIdAndNotDeleted(Long reservationId); - - /** - * 비회원 정보로 삭제되지 않은 결제 목록 페이징 조회 - */ - Page findByNonMemberInfo(String name, String phone, Pageable pageable); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/repository/RefundCalculationRepository.java b/src/main/java/com/sudo/railo/payment/domain/repository/RefundCalculationRepository.java deleted file mode 100644 index 7826b9bb..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/repository/RefundCalculationRepository.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.sudo.railo.payment.domain.repository; - -import com.sudo.railo.payment.domain.entity.RefundCalculation; -import com.sudo.railo.payment.domain.entity.RefundStatus; -import com.sudo.railo.payment.domain.entity.RefundType; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * 환불 계산 Repository 인터페이스 (Domain 계층) - */ -public interface RefundCalculationRepository { - - /** - * 환불 계산 저장 - */ - RefundCalculation save(RefundCalculation refundCalculation); - - /** - * ID로 환불 계산 조회 - */ - Optional findById(Long refundCalculationId); - - /** - * 결제 ID로 환불 계산 조회 - */ - Optional findByPaymentId(Long paymentId); - - /** - * 여러 결제 ID로 환불 계산 목록 조회 - */ - List findByPaymentIds(List paymentIds); - - /** - * 예약 ID로 환불 계산 조회 - */ - Optional findByReservationId(Long reservationId); - - /** - * 회원 ID로 환불 계산 목록 조회 - */ - List findByMemberId(Long memberId); - - /** - * 환불 상태별 조회 - */ - List findByRefundStatus(RefundStatus refundStatus); - - /** - * 환불 유형별 조회 - */ - List findByRefundType(RefundType refundType); - - /** - * 기간별 환불 계산 조회 - */ - List findByRefundRequestTimeBetween(LocalDateTime startTime, LocalDateTime endTime); - - /** - * 회원별 기간별 환불 계산 조회 - */ - List findByMemberIdAndRefundRequestTimeBetween( - Long memberId, LocalDateTime startTime, LocalDateTime endTime); - - /** - * 처리 대기 중인 환불 계산 조회 - */ - List findPendingRefunds(); - - /** - * 환불 계산 삭제 - */ - void delete(RefundCalculation refundCalculation); - - /** - * ID로 환불 계산 삭제 - */ - void deleteById(Long refundCalculationId); - - /** - * 환불 계산 존재 여부 확인 - */ - boolean existsById(Long refundCalculationId); - - /** - * 결제 ID로 환불 계산 존재 여부 확인 - */ - boolean existsByPaymentId(Long paymentId); - - /** - * 예약 ID로 환불 계산 존재 여부 확인 - */ - boolean existsByReservationId(Long reservationId); - - /** - * 멱등성 키로 환불 계산 조회 - */ - Optional findByIdempotencyKey(String idempotencyKey); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/repository/SavedPaymentMethodRepository.java b/src/main/java/com/sudo/railo/payment/domain/repository/SavedPaymentMethodRepository.java deleted file mode 100644 index 7a4d5422..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/repository/SavedPaymentMethodRepository.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.sudo.railo.payment.domain.repository; - -import com.sudo.railo.payment.domain.entity.SavedPaymentMethod; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.List; -import java.util.Optional; - -/** - * 저장된 결제수단 Repository 인터페이스 - */ -public interface SavedPaymentMethodRepository { - - /** - * 저장 - */ - SavedPaymentMethod save(SavedPaymentMethod savedPaymentMethod); - - /** - * ID로 조회 - */ - Optional findById(Long id); - - /** - * 회원 ID와 활성 상태로 조회 - */ - List findByMemberIdAndIsActive(Long memberId, Boolean isActive); - - /** - * 회원 ID로 모든 결제수단 조회 - */ - List findByMemberId(Long memberId); - - /** - * 회원의 기본 결제수단 조회 - */ - Optional findByMemberIdAndIsDefaultTrue(Long memberId); - - /** - * 해시값으로 중복 체크 - */ - @Query("SELECT CASE WHEN COUNT(s) > 0 THEN true ELSE false END FROM SavedPaymentMethod s " + - "WHERE s.memberId = :memberId AND (s.cardNumberHash = :hash OR s.accountNumberHash = :hash) " + - "AND s.isActive = true") - boolean existsByMemberIdAndHash(@Param("memberId") Long memberId, @Param("hash") String hash); - - /** - * 회원의 모든 결제수단을 기본값 해제 - */ - @Modifying - @Query("UPDATE SavedPaymentMethod s SET s.isDefault = false WHERE s.memberId = :memberId") - void updateAllToNotDefault(@Param("memberId") Long memberId); - - /** - * 삭제 (물리적 삭제) - */ - void delete(SavedPaymentMethod savedPaymentMethod); - - /** - * ID로 삭제 - */ - void deleteById(Long id); - - /** - * 특정 결제 타입의 활성 결제수단 개수 - */ - @Query("SELECT COUNT(s) FROM SavedPaymentMethod s " + - "WHERE s.memberId = :memberId AND s.paymentMethodType = :type AND s.isActive = true") - long countByMemberIdAndTypeAndActive(@Param("memberId") Long memberId, @Param("type") String type); - - /** - * 페이징된 전체 결제수단 조회 - */ - Page findAll(Pageable pageable); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/MemberTypeService.java b/src/main/java/com/sudo/railo/payment/domain/service/MemberTypeService.java deleted file mode 100644 index 68c755e5..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/MemberTypeService.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.sudo.railo.payment.domain.service; - -import com.sudo.railo.payment.application.dto.request.PaymentExecuteRequest; -import com.sudo.railo.payment.domain.entity.MemberType; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -/** - * 회원 타입 판별 전문 도메인 서비스 - * - 회원/비회원 구분 로직 - * - 의존성 없는 순수 도메인 로직 - * - 결제 플로우 분기점 역할 - */ -@Service -public class MemberTypeService { - - /** - * 결제 요청에서 회원 타입을 판별 - * - * @param request 결제 실행 요청 - * @return 회원 타입 (MEMBER 또는 NON_MEMBER) - * @throws IllegalArgumentException 회원 정보나 비회원 정보가 모두 없는 경우 - */ - public MemberType determineMemberType(PaymentExecuteRequest request) { - // 1. 회원 ID 우선 확인 - if (request.getMemberId() != null && request.getMemberId() > 0) { - return MemberType.MEMBER; - } - - // 2. 비회원 정보 완전성 확인 - if (hasNonMemberInfo(request)) { - return MemberType.NON_MEMBER; - } - - // 3. 둘 다 없으면 예외 (Fail-Fast) - throw new IllegalArgumentException("회원 ID 또는 비회원 정보가 필요합니다"); - } - - /** - * 회원 여부 확인 - * - * @param request 결제 실행 요청 - * @return 회원이면 true - */ - public boolean isMember(PaymentExecuteRequest request) { - return determineMemberType(request) == MemberType.MEMBER; - } - - /** - * 비회원 여부 확인 - * - * @param request 결제 실행 요청 - * @return 비회원이면 true - */ - public boolean isNonMember(PaymentExecuteRequest request) { - return determineMemberType(request) == MemberType.NON_MEMBER; - } - - /** - * 비회원 정보 완전성 검증 - * - * @param request 결제 실행 요청 - * @return 비회원 정보가 완전하면 true - */ - private boolean hasNonMemberInfo(PaymentExecuteRequest request) { - return StringUtils.hasText(request.getNonMemberName()) - && StringUtils.hasText(request.getNonMemberPhone()) - && StringUtils.hasText(request.getNonMemberPassword()); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/MileageEarningDomainService.java b/src/main/java/com/sudo/railo/payment/domain/service/MileageEarningDomainService.java deleted file mode 100644 index 757e0ede..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/MileageEarningDomainService.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.sudo.railo.payment.domain.service; - -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import com.sudo.railo.payment.domain.util.DelayCompensationCalculator; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import org.springframework.stereotype.Service; - -/** - * 마일리지 적립 도메인 서비스 - * - * 순수한 도메인 로직만을 포함하며, 프레임워크나 인프라스트럭처에 - * 의존하지 않는 비즈니스 규칙을 구현합니다. - */ -@Service -public class MileageEarningDomainService { - - // 마일리지 적립 비율 (결제 금액의 1%) - private static final BigDecimal EARNING_RATE = new BigDecimal("0.01"); - - /** - * 정상 운행 기준 마일리지 적립 스케줄을 생성합니다. - * - * @param trainScheduleId 열차 스케줄 ID - * @param paymentId 결제 ID - * @param memberId 회원 ID - * @param paymentAmount 결제 금액 - * @param expectedArrivalTime 예상 도착 시간 - * @param routeInfo 노선 정보 - * @return 생성된 마일리지 적립 스케줄 - */ - public MileageEarningSchedule createNormalEarningSchedule( - Long trainScheduleId, - String paymentId, - Long memberId, - BigDecimal paymentAmount, - LocalDateTime expectedArrivalTime, - String routeInfo) { - - return MileageEarningSchedule.createNormalEarningSchedule( - trainScheduleId, - paymentId, - memberId, - paymentAmount, - expectedArrivalTime, - routeInfo - ); - } - - /** - * 기본 마일리지 금액을 계산합니다. - * - * @param paymentAmount 결제 금액 - * @return 기본 마일리지 금액 - */ - public BigDecimal calculateBaseMileage(BigDecimal paymentAmount) { - return paymentAmount.multiply(EARNING_RATE).setScale(0, BigDecimal.ROUND_DOWN); - } - - /** - * 지연 보상 마일리지를 계산합니다. - * - * @param originalAmount 원본 결제 금액 (운임) - * @param delayMinutes 지연 시간(분) - * @return 지연 보상 마일리지 금액 - */ - public BigDecimal calculateDelayCompensation(BigDecimal originalAmount, int delayMinutes) { - BigDecimal compensationRate = DelayCompensationCalculator.calculateCompensationRate(delayMinutes); - return originalAmount.multiply(compensationRate).setScale(0, BigDecimal.ROUND_DOWN); - } - - /** - * 스케줄이 처리 가능한 상태인지 검증합니다. - * - * @param schedule 마일리지 적립 스케줄 - * @return 처리 가능 여부 - */ - public boolean isProcessable(MileageEarningSchedule schedule) { - return schedule.getStatus() == MileageEarningSchedule.EarningStatus.READY - && schedule.getScheduledEarningTime().isBefore(LocalDateTime.now()); - } - - /** - * 스케줄 상태 전이가 유효한지 검증합니다. - * - * @param currentStatus 현재 상태 - * @param newStatus 변경하려는 상태 - * @return 전이 가능 여부 - */ - public boolean isValidStatusTransition( - MileageEarningSchedule.EarningStatus currentStatus, - MileageEarningSchedule.EarningStatus newStatus) { - - return switch (currentStatus) { - case SCHEDULED -> newStatus == MileageEarningSchedule.EarningStatus.READY - || newStatus == MileageEarningSchedule.EarningStatus.CANCELLED; - - case READY -> newStatus == MileageEarningSchedule.EarningStatus.BASE_PROCESSING - || newStatus == MileageEarningSchedule.EarningStatus.CANCELLED; - - case BASE_PROCESSING -> newStatus == MileageEarningSchedule.EarningStatus.BASE_COMPLETED - || newStatus == MileageEarningSchedule.EarningStatus.FULLY_COMPLETED - || newStatus == MileageEarningSchedule.EarningStatus.FAILED; - - case BASE_COMPLETED -> newStatus == MileageEarningSchedule.EarningStatus.COMPENSATION_PROCESSING; - - case COMPENSATION_PROCESSING -> newStatus == MileageEarningSchedule.EarningStatus.FULLY_COMPLETED - || newStatus == MileageEarningSchedule.EarningStatus.FAILED; - - case FULLY_COMPLETED, CANCELLED, FAILED -> false; // 종료 상태에서는 변경 불가 - }; - } - - /** - * 지연 보상이 필요한지 확인합니다. - * - * @param delayMinutes 지연 시간(분) - * @return 보상 필요 여부 - */ - public boolean requiresDelayCompensation(int delayMinutes) { - return delayMinutes >= 20; // 20분 이상 지연 시 보상 - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/MileageExecutionService.java b/src/main/java/com/sudo/railo/payment/domain/service/MileageExecutionService.java deleted file mode 100644 index 7a605cfa..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/MileageExecutionService.java +++ /dev/null @@ -1,292 +0,0 @@ -package com.sudo.railo.payment.domain.service; - -import com.sudo.railo.payment.application.dto.PaymentResult.MileageExecutionResult; -import com.sudo.railo.payment.application.port.out.SaveMemberInfoPort; -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.repository.MileageTransactionRepository; -import com.sudo.railo.payment.exception.PaymentValidationException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 마일리지 적립/사용 실행 도메인 서비스 - * 실제 마일리지 거래를 처리하고 잔액을 관리 - */ -@Service -@RequiredArgsConstructor -@Transactional -@Slf4j -public class MileageExecutionService { - - private final MileageTransactionRepository mileageTransactionRepository; - private final MileageService mileageService; - private final SaveMemberInfoPort saveMemberInfoPort; - - /** - * 마일리지 적립 실행 - * 결제 완료 후 호출되어 실제 포인트를 적립 - */ - public MileageTransaction executeEarning(Payment payment) { - if (payment.getMemberId() == null) { - throw new PaymentValidationException("비회원은 마일리지 적립이 불가능합니다"); - } - - if (payment.getMileageToEarn() == null || - payment.getMileageToEarn().compareTo(BigDecimal.ZERO) <= 0) { - log.debug("적립할 마일리지가 없습니다 - 결제ID: {}", payment.getId()); - return null; - } - - // 1. 현재 잔액 조회 - BigDecimal currentBalance = getCurrentBalance(payment.getMemberId()); - - // 2. 적립 거래 생성 - MileageTransaction transaction = MileageTransaction.createEarnTransaction( - payment.getMemberId(), - payment.getId().toString(), - payment.getMileageToEarn(), - currentBalance, - String.format("기차표 구매 적립 (결제금액: %s원)", payment.getAmountPaid()) - ); - - // 3. 거래 완료 처리 - // TODO: 열차 도착 발생 시 completed로 변경 필요 - transaction.complete(); - - // 4. 저장 - MileageTransaction savedTransaction = mileageTransactionRepository.save(transaction); - - // 5. 회원 마일리지 잔액 동기화 - saveMemberInfoPort.addMileage(payment.getMemberId(), payment.getMileageToEarn().longValue()); - - log.info("마일리지 적립 완료 - 회원ID: {}, 적립포인트: {}, 적립후잔액: {}", - payment.getMemberId(), payment.getMileageToEarn(), transaction.getBalanceAfter()); - - return savedTransaction; - } - - /** - * 마일리지 사용 실행 - * 결제 시 호출되어 실제 포인트를 차감 - * @return MileageExecutionResult DTO - */ - public MileageExecutionResult executeUsage(Payment payment) { - MileageTransaction transaction = executeUsageTransaction(payment); - - if (transaction == null) { - return MileageExecutionResult.builder() - .success(true) - .usedPoints(BigDecimal.ZERO) - .remainingBalance(getCurrentBalance(payment.getMemberId())) - .transactionId(null) - .build(); - } - - return MileageExecutionResult.builder() - .success(true) - .usedPoints(transaction.getPointsAmount().abs()) // 사용은 음수로 저장되므로 절대값 - .remainingBalance(transaction.getBalanceAfter()) - .transactionId(transaction.getId().toString()) - .build(); - } - - /** - * 마일리지 사용 실행 (내부 트랜잭션 처리) - * 결제 시 호출되어 실제 포인트를 차감 - */ - private MileageTransaction executeUsageTransaction(Payment payment) { - if (payment.getMemberId() == null) { - throw new PaymentValidationException("비회원은 마일리지 사용이 불가능합니다"); - } - - if (payment.getMileagePointsUsed() == null || - payment.getMileagePointsUsed().compareTo(BigDecimal.ZERO) <= 0) { - log.debug("사용할 마일리지가 없습니다 - 결제ID: {}", payment.getId()); - return null; - } - - // 1. 현재 잔액 조회 - BigDecimal currentBalance = getCurrentBalance(payment.getMemberId()); - - // 2. 잔액 충분성 검증 - if (currentBalance.compareTo(payment.getMileagePointsUsed()) < 0) { - throw new PaymentValidationException( - String.format("마일리지 잔액이 부족합니다. 현재잔액: %s, 사용요청: %s", - currentBalance, payment.getMileagePointsUsed())); - } - - // 3. 사용 거래 생성 - MileageTransaction transaction = MileageTransaction.createUseTransaction( - payment.getMemberId(), - payment.getId().toString(), - payment.getMileagePointsUsed(), - currentBalance, - String.format("기차표 구매 사용 (차감금액: %s원)", payment.getMileageAmountDeducted()) - ); - - // 4. 거래 완료 처리 - transaction.complete(); - - // 5. 저장 - MileageTransaction savedTransaction = mileageTransactionRepository.save(transaction); - - // 6. 회원 마일리지 잔액 동기화 - saveMemberInfoPort.useMileage(payment.getMemberId(), payment.getMileagePointsUsed().longValue()); - - log.info("마일리지 사용 완료 - 회원ID: {}, 사용포인트: {}, 사용후잔액: {}", - payment.getMemberId(), payment.getMileagePointsUsed(), transaction.getBalanceAfter()); - - return savedTransaction; - } - - /** - * 마일리지 사용 취소 (환불 시) - * - * @deprecated Use {@link #restoreMileageUsage(String, Long, BigDecimal, String)} instead - */ - @Deprecated - public MileageTransaction cancelUsage(String paymentId, Long memberId, BigDecimal pointsToRestore) { - return restoreMileageUsage(paymentId, memberId, pointsToRestore, - String.format("결제 취소로 인한 마일리지 복구 (%s포인트)", pointsToRestore)); - } - - /** - * 마일리지 적립 취소 (환불 시) - */ - public MileageTransaction cancelEarning(String paymentId, Long memberId, BigDecimal pointsToCancel) { - // 1. 현재 잔액 조회 - BigDecimal currentBalance = getCurrentBalance(memberId); - - // 2. 잔액 충분성 검증 - if (currentBalance.compareTo(pointsToCancel) < 0) { - throw new PaymentValidationException( - String.format("적립 취소할 마일리지가 부족합니다. 현재잔액: %s, 취소요청: %s", - currentBalance, pointsToCancel)); - } - - // 3. 취소 거래 생성 - MileageTransaction transaction = MileageTransaction.builder() - .memberId(memberId) - .paymentId(paymentId) - .type(MileageTransaction.TransactionType.ADJUST) - .pointsAmount(pointsToCancel.negate()) // 음수로 차감 - .balanceBefore(currentBalance) - .balanceAfter(currentBalance.subtract(pointsToCancel)) - .description(String.format("결제 취소로 인한 적립 마일리지 회수 (%s포인트)", pointsToCancel)) - .status(MileageTransaction.TransactionStatus.COMPLETED) - .processedAt(LocalDateTime.now()) - .build(); - - // 4. 저장 - MileageTransaction savedTransaction = mileageTransactionRepository.save(transaction); - - log.debug("마일리지 적립 취소 완료 - 회원ID: {}, 회수포인트: {}, 회수후잔액: {}", - memberId, pointsToCancel, transaction.getBalanceAfter()); - - return savedTransaction; - } - - /** - * 마일리지 사용 복구 (결제 취소 시) - 기존 메서드 호환성 유지 - * - * @param paymentId 결제 ID - * @param memberId 회원 ID - * @param pointsToRestore 복구할 포인트 - * @return 복구 거래 내역 - */ - public MileageTransaction restoreUsage(String paymentId, Long memberId, BigDecimal pointsToRestore) { - return restoreMileageUsage(paymentId, memberId, pointsToRestore, - String.format("결제 취소로 인한 마일리지 사용 복구 (%s포인트)", pointsToRestore)); - } - - /** - * 마일리지 사용 복구 (결제 취소 시) - 통합된 메서드 - * - * @param paymentId 결제 ID - * @param memberId 회원 ID - * @param pointsToRestore 복구할 포인트 - * @param description 거래 설명 - * @return 복구 거래 내역 - */ - public MileageTransaction restoreMileageUsage(String paymentId, Long memberId, - BigDecimal pointsToRestore, String description) { - // 1. 현재 잔액 조회 - BigDecimal currentBalance = getCurrentBalance(memberId); - - // 2. 복구 거래 생성 - MileageTransaction transaction = MileageTransaction.builder() - .memberId(memberId) - .paymentId(paymentId) - .type(MileageTransaction.TransactionType.REFUND) - .pointsAmount(pointsToRestore) // 양수로 복구 - .balanceBefore(currentBalance) - .balanceAfter(currentBalance.add(pointsToRestore)) - .description(description) - .status(MileageTransaction.TransactionStatus.COMPLETED) - .processedAt(LocalDateTime.now()) - .build(); - - // 3. 저장 - MileageTransaction savedTransaction = mileageTransactionRepository.save(transaction); - - log.debug("마일리지 복구 완료 - 회원ID: {}, 복구포인트: {}, 복구후잔액: {}, 설명: {}", - memberId, pointsToRestore, transaction.getBalanceAfter(), description); - - return savedTransaction; - } - - /** - * 회원의 현재 마일리지 잔액 조회 - */ - @Transactional(readOnly = true) - public BigDecimal getCurrentBalance(Long memberId) { - return mileageTransactionRepository.calculateCurrentBalance(memberId); - } - - /** - * 회원의 활성 마일리지 잔액 조회 (만료되지 않은 것만) - */ - @Transactional(readOnly = true) - public BigDecimal getActiveBalance(Long memberId) { - return mileageTransactionRepository.calculateActiveBalance(memberId, LocalDateTime.now()); - } - - /** - * 마일리지 만료 처리 (스케줄러에서 호출) - */ - public void processExpiredMileage() { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime oneDayAgo = now.minusDays(1); - - // 어제부터 오늘까지 만료된 마일리지 조회 - var expiredTransactions = mileageTransactionRepository.findExpiringMileage(oneDayAgo, now); - - for (MileageTransaction expiredTransaction : expiredTransactions) { - // 만료 처리 거래 생성 - BigDecimal currentBalance = getCurrentBalance(expiredTransaction.getMemberId()); - - MileageTransaction expireTransaction = MileageTransaction.builder() - .memberId(expiredTransaction.getMemberId()) - .type(MileageTransaction.TransactionType.EXPIRE) - .pointsAmount(expiredTransaction.getPointsAmount().negate()) // 음수로 차감 - .balanceBefore(currentBalance) - .balanceAfter(currentBalance.subtract(expiredTransaction.getPointsAmount())) - .description(String.format("마일리지 만료 (원본적립일: %s)", - expiredTransaction.getCreatedAt().toLocalDate())) - .status(MileageTransaction.TransactionStatus.COMPLETED) - .processedAt(LocalDateTime.now()) - .build(); - - mileageTransactionRepository.save(expireTransaction); - - log.debug("마일리지 만료 처리 - 회원ID: {}, 만료포인트: {}", - expiredTransaction.getMemberId(), expiredTransaction.getPointsAmount()); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/MileageService.java b/src/main/java/com/sudo/railo/payment/domain/service/MileageService.java deleted file mode 100644 index 2486a6ff..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/MileageService.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.sudo.railo.payment.domain.service; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.math.BigDecimal; -import java.math.RoundingMode; - -/** - * 마일리지 도메인 서비스 - * 1포인트 = 1원, 최대 100%, 최소 1,000포인트, 1포인트 단위 - */ -@Service -@Slf4j -public class MileageService { - - private static final BigDecimal MAX_USAGE_RATE = new BigDecimal("1.0"); - private static final BigDecimal MIN_USAGE_AMOUNT = new BigDecimal("1000"); - private static final BigDecimal USAGE_UNIT = new BigDecimal("1"); - private static final BigDecimal EARNING_RATE = new BigDecimal("0.01"); - private static final BigDecimal MILEAGE_TO_WON_RATE = BigDecimal.ONE; - - public boolean validateMileageUsage(BigDecimal requestedMileage, BigDecimal availableMileage, BigDecimal paymentAmount) { - if (requestedMileage.compareTo(BigDecimal.ZERO) <= 0) { - return true; - } - - if (requestedMileage.compareTo(availableMileage) > 0) { - log.debug("보유 마일리지 부족 - 요청: {}, 보유: {}", requestedMileage, availableMileage); - return false; - } - - if (requestedMileage.compareTo(MIN_USAGE_AMOUNT) < 0) { - return false; - } - - if (requestedMileage.remainder(USAGE_UNIT).compareTo(BigDecimal.ZERO) != 0) { - return false; - } - - BigDecimal maxUsableAmount = calculateMaxUsableAmount(paymentAmount); - if (requestedMileage.compareTo(maxUsableAmount) > 0) { - return false; - } - - return true; - } - - public BigDecimal calculateMaxUsableAmount(BigDecimal paymentAmount) { - if (paymentAmount == null || paymentAmount.compareTo(BigDecimal.ZERO) <= 0) { - return BigDecimal.ZERO; - } - - BigDecimal maxUsable = paymentAmount.multiply(MAX_USAGE_RATE) - .setScale(0, RoundingMode.DOWN); - - BigDecimal remainder = maxUsable.remainder(USAGE_UNIT); - return maxUsable.subtract(remainder); - } - - public BigDecimal convertMileageToWon(BigDecimal mileageAmount) { - if (mileageAmount == null || mileageAmount.compareTo(BigDecimal.ZERO) < 0) { - return BigDecimal.ZERO; - } - return mileageAmount.multiply(MILEAGE_TO_WON_RATE); - } - - public BigDecimal convertWonToMileage(BigDecimal wonAmount) { - if (wonAmount == null || wonAmount.compareTo(BigDecimal.ZERO) < 0) { - return BigDecimal.ZERO; - } - return wonAmount.divide(MILEAGE_TO_WON_RATE, 0, RoundingMode.DOWN); - } - - public BigDecimal calculateEarningAmount(BigDecimal paymentAmount) { - if (paymentAmount == null || paymentAmount.compareTo(BigDecimal.ZERO) <= 0) { - return BigDecimal.ZERO; - } - return paymentAmount.multiply(EARNING_RATE).setScale(0, RoundingMode.DOWN); - } - - public BigDecimal calculateFinalAmount(BigDecimal originalAmount, BigDecimal usedMileage) { - if (originalAmount == null || originalAmount.compareTo(BigDecimal.ZERO) <= 0) { - return BigDecimal.ZERO; - } - - if (usedMileage == null || usedMileage.compareTo(BigDecimal.ZERO) < 0) { - usedMileage = BigDecimal.ZERO; - } - - BigDecimal mileageDiscount = convertMileageToWon(usedMileage); - BigDecimal finalAmount = originalAmount.subtract(mileageDiscount); - - return finalAmount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : finalAmount; - } - - public BigDecimal calculateRecommendedUsage(BigDecimal availableMileage, BigDecimal paymentAmount) { - if (availableMileage == null || availableMileage.compareTo(MIN_USAGE_AMOUNT) < 0) { - return BigDecimal.ZERO; - } - - BigDecimal maxUsable = calculateMaxUsableAmount(paymentAmount); - BigDecimal recommended = availableMileage.min(maxUsable); - - BigDecimal remainder = recommended.remainder(USAGE_UNIT); - recommended = recommended.subtract(remainder); - - return recommended.compareTo(MIN_USAGE_AMOUNT) < 0 ? BigDecimal.ZERO : recommended; - } - - public BigDecimal calculateUsageRate(BigDecimal usedMileage, BigDecimal paymentAmount) { - if (paymentAmount == null || paymentAmount.compareTo(BigDecimal.ZERO) <= 0 || - usedMileage == null || usedMileage.compareTo(BigDecimal.ZERO) <= 0) { - return BigDecimal.ZERO; - } - - BigDecimal mileageWon = convertMileageToWon(usedMileage); - return mileageWon.divide(paymentAmount, 4, RoundingMode.HALF_UP); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/NonMemberService.java b/src/main/java/com/sudo/railo/payment/domain/service/NonMemberService.java deleted file mode 100644 index 71bab078..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/NonMemberService.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.sudo.railo.payment.domain.service; - -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.application.dto.request.PaymentExecuteRequest; -import com.sudo.railo.payment.exception.PaymentValidationException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import java.util.regex.Pattern; - -/** - * 비회원 정보 처리 도메인 서비스 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class NonMemberService { - - private final PasswordEncoder passwordEncoder; - - // 전화번호 정규식 (010-1234-5678 또는 01012345678) - private static final Pattern PHONE_PATTERN = Pattern.compile("^01[016789](-)?\\d{3,4}(-)?\\d{4}$"); - - - /** - * 비회원 정보 검증 (결제 내역 조회용) - */ - public boolean validateNonMemberInfo(String name, String phone, String password, Payment payment) { - if (!StringUtils.hasText(name) || !StringUtils.hasText(phone) || !StringUtils.hasText(password)) { - return false; - } - - // 이름 검증 - if (!name.trim().equals(payment.getNonMemberName())) { - return false; - } - - // 전화번호 검증 (정규화하여 비교) - String normalizedInputPhone = normalizePhoneNumber(phone); - if (!normalizedInputPhone.equals(payment.getNonMemberPhone())) { - return false; - } - - // 비밀번호 검증 - return passwordEncoder.matches(password, payment.getNonMemberPassword()); - } - - /** - * 전화번호 유효성 검증 - */ - private void validatePhoneNumber(String phone) { - String cleanPhone = phone.replaceAll("[^0-9]", ""); - if (!PHONE_PATTERN.matcher(phone).matches() && !cleanPhone.matches("^01[016789]\\d{7,8}$")) { - throw new PaymentValidationException("올바른 전화번호 형식이 아닙니다"); - } - } - - /** - * 전화번호 정규화 (숫자만 남기고 010xxxxxxxx 형태로) - */ - private String normalizePhoneNumber(String phone) { - return phone.replaceAll("[^0-9]", ""); - } - - /** - * 전화번호 마스킹 (로그용) - */ - private String maskPhoneNumber(String phone) { - String normalized = normalizePhoneNumber(phone); - if (normalized.length() >= 7) { - return normalized.substring(0, 3) + "****" + normalized.substring(normalized.length() - 4); - } - return "****"; - } - - /** - * 비회원 인증 정보 검증 (전체 조회용) - * 데이터베이스에서 첫 번째 일치하는 결제를 찾아 비밀번호 검증 - */ - public boolean validateNonMemberCredentials(String name, String phone, String password) { - // 기본 검증 - if (!StringUtils.hasText(name) || !StringUtils.hasText(phone) || !StringUtils.hasText(password)) { - return false; - } - - // 전화번호 유효성 검증 - try { - validatePhoneNumber(phone); - } catch (PaymentValidationException e) { - log.debug("비회원 인증 실패 - 전화번호 형식 오류: {}", e.getMessage()); - return false; - } - - // 실제 검증은 서비스 레이어에서 첫 번째 결제를 찾아서 처리하도록 함 - // 여기서는 입력값 검증만 수행 - return true; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/PaymentValidationService.java b/src/main/java/com/sudo/railo/payment/domain/service/PaymentValidationService.java deleted file mode 100644 index 8c77eb14..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/PaymentValidationService.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.sudo.railo.payment.domain.service; - -import com.sudo.railo.payment.application.dto.request.PaymentCalculationRequest; -import com.sudo.railo.payment.application.dto.request.PaymentExecuteRequest; -import com.sudo.railo.payment.exception.PaymentValidationException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.List; - -@Service -@RequiredArgsConstructor -@Slf4j -public class PaymentValidationService { - - public void validateCalculationRequest(PaymentCalculationRequest request) { - List errors = new ArrayList<>(); - - // 기본 필드 검증 - if (request.getOriginalAmount().compareTo(BigDecimal.ZERO) <= 0) { - errors.add("결제 금액은 0보다 커야 합니다"); - } - - if (request.getOriginalAmount().compareTo(BigDecimal.valueOf(10000000)) > 0) { - errors.add("결제 금액은 1천만원을 초과할 수 없습니다"); - } - - // 프로모션 검증 - if (request.getRequestedPromotions() != null) { - validatePromotions(request.getRequestedPromotions(), request.getOriginalAmount(), errors); - } - - if (!errors.isEmpty()) { - throw new PaymentValidationException("결제 계산 요청 검증 실패: " + String.join(", ", errors)); - } - } - - public void validateExecuteRequest(PaymentExecuteRequest request) { - List errors = new ArrayList<>(); - - // 계산 세션 ID 검증 - if (request.getId() == null || request.getId().trim().isEmpty()) { - errors.add("계산 세션 ID는 필수입니다"); - } - - // 중복 방지 키 검증 - if (request.getIdempotencyKey() == null || request.getIdempotencyKey().trim().isEmpty()) { - errors.add("중복 방지 키는 필수입니다"); - } - - // 결제 수단 검증 - if (request.getPaymentMethod() != null) { - validatePaymentMethod(request.getPaymentMethod(), errors); - } - - if (!errors.isEmpty()) { - throw new PaymentValidationException("결제 실행 요청 검증 실패: " + String.join(", ", errors)); - } - } - - private void validatePromotions(List promotions, - BigDecimal originalAmount, List errors) { - - for (PaymentCalculationRequest.PromotionRequest promotion : promotions) { - if ("MILEAGE".equals(promotion.getType())) { - if (promotion.getPointsToUse() == null || - promotion.getPointsToUse().compareTo(BigDecimal.ZERO) <= 0) { - errors.add("마일리지 사용 포인트는 0보다 커야 합니다"); - } - - if (promotion.getPointsToUse() != null && - promotion.getPointsToUse().compareTo(originalAmount) > 0) { - errors.add("마일리지 사용 포인트는 결제 금액을 초과할 수 없습니다"); - } - } - } - } - - private void validatePaymentMethod(PaymentExecuteRequest.PaymentMethodInfo paymentMethod, - List errors) { - - if (paymentMethod.getType() == null || paymentMethod.getType().trim().isEmpty()) { - errors.add("결제 수단 타입은 필수입니다"); - } - - if (paymentMethod.getPgProvider() == null || paymentMethod.getPgProvider().trim().isEmpty()) { - errors.add("PG 제공자는 필수입니다"); - } - - if (paymentMethod.getPgToken() == null || paymentMethod.getPgToken().trim().isEmpty()) { - errors.add("PG 토큰은 필수입니다"); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/RefundCalculationService.java b/src/main/java/com/sudo/railo/payment/domain/service/RefundCalculationService.java deleted file mode 100644 index d69c72ea..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/RefundCalculationService.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.sudo.railo.payment.domain.service; - -import com.sudo.railo.payment.domain.entity.RefundCalculation; -import com.sudo.railo.payment.domain.entity.RefundStatus; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Optional; - -/** - * 환불 계산 관련 도메인 서비스 - * RefundCalculation 엔티티의 비즈니스 로직을 분리 - */ -@Slf4j -@Service -public class RefundCalculationService { - - /** - * 환불 가능 여부 확인 (시간 기준) - * - * @param calculation 환불 계산 정보 - * @return 환불 가능 여부 - */ - public boolean isRefundableByTime(RefundCalculation calculation) { - if (calculation == null) { - log.warn("RefundCalculation이 null입니다."); - return false; - } - - return isRefundableByTime( - calculation.getTrainArrivalTime(), - calculation.getRefundStatus() - ); - } - - /** - * 환불 가능 여부 확인 (시간 기준) - * - * @param trainArrivalTime 열차 도착 시간 - * @param refundStatus 환불 상태 - * @return 환불 가능 여부 - */ - public boolean isRefundableByTime(LocalDateTime trainArrivalTime, RefundStatus refundStatus) { - // 이미 환불 완료된 경우 - if (refundStatus == RefundStatus.COMPLETED) { - log.info("이미 환불 완료된 상태입니다."); - return false; - } - - // 도착 시간이 없는 경우 (데이터 이상) - if (trainArrivalTime == null) { - log.warn("열차 도착 시간이 null입니다. 환불 불가로 처리합니다."); - return false; - } - - LocalDateTime now = LocalDateTime.now(); - boolean isRefundable = now.isBefore(trainArrivalTime); - - log.debug("환불 가능 여부 확인 - 현재시간: {}, 도착시간: {}, 환불가능: {}", - now, trainArrivalTime, isRefundable); - - return isRefundable; - } - - /** - * 환불 가능 시간 확인 (Optional 반환) - * - * @param trainArrivalTime 열차 도착 시간 - * @param refundStatus 환불 상태 - * @return 환불 가능 여부 (Optional) - */ - public Optional checkRefundability(LocalDateTime trainArrivalTime, RefundStatus refundStatus) { - if (trainArrivalTime == null) { - return Optional.empty(); - } - - return Optional.of(isRefundableByTime(trainArrivalTime, refundStatus)); - } - - /** - * 환불 수수료율 유효성 검증 - * - * @param refundFeeRate 환불 수수료율 - * @return 유효성 여부 - */ - public boolean isValidRefundFeeRate(BigDecimal refundFeeRate) { - if (refundFeeRate == null) { - return false; - } - - // 0% ~ 100% 사이인지 확인 - return refundFeeRate.compareTo(BigDecimal.ZERO) >= 0 - && refundFeeRate.compareTo(BigDecimal.ONE) <= 0; - } - - /** - * 환불 금액 계산 - * - * @param originalAmount 원본 금액 - * @param refundFeeRate 환불 수수료율 - * @return 환불 금액 - */ - public BigDecimal calculateRefundAmount(BigDecimal originalAmount, BigDecimal refundFeeRate) { - if (originalAmount == null || refundFeeRate == null) { - throw new IllegalArgumentException("금액과 수수료율은 필수입니다."); - } - - if (!isValidRefundFeeRate(refundFeeRate)) { - throw new IllegalArgumentException("유효하지 않은 환불 수수료율입니다: " + refundFeeRate); - } - - BigDecimal refundFee = originalAmount.multiply(refundFeeRate); - return originalAmount.subtract(refundFee); - } - - /** - * 환불 수수료 계산 - * - * @param originalAmount 원본 금액 - * @param refundFeeRate 환불 수수료율 - * @return 환불 수수료 - */ - public BigDecimal calculateRefundFee(BigDecimal originalAmount, BigDecimal refundFeeRate) { - if (originalAmount == null || refundFeeRate == null) { - throw new IllegalArgumentException("금액과 수수료율은 필수입니다."); - } - - if (!isValidRefundFeeRate(refundFeeRate)) { - throw new IllegalArgumentException("유효하지 않은 환불 수수료율입니다: " + refundFeeRate); - } - - return originalAmount.multiply(refundFeeRate); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/refund/DefaultRefundPolicy.java b/src/main/java/com/sudo/railo/payment/domain/service/refund/DefaultRefundPolicy.java deleted file mode 100644 index c874be76..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/refund/DefaultRefundPolicy.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.sudo.railo.payment.domain.service.refund; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 기본 환불 정책 구현 - * 운영사가 명시되지 않았거나 매칭되는 정책이 없을 때 사용 - * 기존 하드코딩된 로직과 동일한 정책 적용 - */ -@Slf4j -@Component -public class DefaultRefundPolicy implements RefundPolicyService { - - private final KorailRefundPolicy korailPolicy; - - public DefaultRefundPolicy() { - // 기본 정책은 KORAIL 정책을 따름 - this.korailPolicy = new KorailRefundPolicy(); - } - - @Override - public BigDecimal calculateRefundFeeRate(LocalDateTime departureTime, - LocalDateTime arrivalTime, - LocalDateTime requestTime) { - log.warn("기본 환불 정책 사용 - KORAIL 정책으로 대체"); - return korailPolicy.calculateRefundFeeRate(departureTime, arrivalTime, requestTime); - } - - @Override - public boolean supports(String operator) { - // 기본 정책은 모든 경우를 지원 (fallback) - return true; - } - - @Override - public String getPolicyName() { - return "기본 환불 정책 (KORAIL 정책 준용)"; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/refund/KorailRefundPolicy.java b/src/main/java/com/sudo/railo/payment/domain/service/refund/KorailRefundPolicy.java deleted file mode 100644 index cca7fc3b..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/refund/KorailRefundPolicy.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.sudo.railo.payment.domain.service.refund; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.time.Duration; -import java.time.LocalDateTime; - -/** - * 한국철도공사(KORAIL) 환불 정책 구현 - * KTX, KTX-산천, KTX-청룡, KTX-이음 등에 적용 - */ -@Slf4j -@Component -public class KorailRefundPolicy implements RefundPolicyService { - - // 환불 수수료율 상수 - private static final BigDecimal NO_FEE = BigDecimal.ZERO; - private static final BigDecimal FEE_30_PERCENT = new BigDecimal("0.3"); - private static final BigDecimal FEE_40_PERCENT = new BigDecimal("0.4"); - private static final BigDecimal FEE_70_PERCENT = new BigDecimal("0.7"); - private static final BigDecimal FULL_FEE = BigDecimal.ONE; - - // 시간 기준 상수 (분) - private static final long MINUTES_20 = 20; - private static final long MINUTES_60 = 60; - - @Override - public BigDecimal calculateRefundFeeRate(LocalDateTime departureTime, - LocalDateTime arrivalTime, - LocalDateTime requestTime) { - log.debug("KORAIL 환불 수수료 계산 - 출발: {}, 도착: {}, 요청: {}", - departureTime, arrivalTime, requestTime); - - // 1. 도착 후 환불 요청 - 환불 불가 (100% 수수료) - if (requestTime.isAfter(arrivalTime)) { - log.info("도착 후 환불 요청 - 환불 불가"); - return FULL_FEE; - } - - // 2. 출발 전 환불 - 수수료 없음 - if (requestTime.isBefore(departureTime)) { - log.info("출발 전 환불 요청 - 수수료 없음"); - return NO_FEE; - } - - // 3. 출발 후 ~ 도착 전 환불 - 경과 시간에 따른 수수료 - long minutesAfterDeparture = Duration.between(departureTime, requestTime).toMinutes(); - - BigDecimal feeRate; - if (minutesAfterDeparture <= MINUTES_20) { - feeRate = FEE_30_PERCENT; - log.info("출발 후 {}분 이내 환불 - 30% 수수료", minutesAfterDeparture); - } else if (minutesAfterDeparture <= MINUTES_60) { - feeRate = FEE_40_PERCENT; - log.info("출발 후 {}분 이내 환불 - 40% 수수료", minutesAfterDeparture); - } else { - feeRate = FEE_70_PERCENT; - log.info("출발 후 {}분 초과 환불 - 70% 수수료", minutesAfterDeparture); - } - - return feeRate; - } - - @Override - public boolean supports(String operator) { - return "KORAIL".equalsIgnoreCase(operator); - } - - @Override - public String getPolicyName() { - return "KORAIL 표준 환불 정책"; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/refund/RefundPolicyFactory.java b/src/main/java/com/sudo/railo/payment/domain/service/refund/RefundPolicyFactory.java deleted file mode 100644 index 6ebd5f43..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/refund/RefundPolicyFactory.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.sudo.railo.payment.domain.service.refund; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.List; - -/** - * 환불 정책 팩토리 - * 운영사에 따라 적절한 환불 정책을 선택하여 반환 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class RefundPolicyFactory { - - private final List policies; - private final DefaultRefundPolicy defaultPolicy; - - - /** - * 운영사 이름으로 환불 정책 조회 - * - * @param operatorName 운영사 이름 - * @return 환불 정책 서비스 - */ - public RefundPolicyService getPolicy(String operatorName) { - log.debug("환불 정책 조회 - 운영사: {}", operatorName); - - if (operatorName == null) { - log.warn("운영사 정보가 없습니다. 기본 정책을 사용합니다."); - return defaultPolicy; - } - - return policies.stream() - .filter(policy -> policy.supports(operatorName)) - .findFirst() - .orElseGet(() -> { - log.warn("운영사 {}에 대한 환불 정책을 찾을 수 없습니다. 기본 정책을 사용합니다.", operatorName); - return defaultPolicy; - }); - } - - /** - * 기본 환불 정책 조회 - * - * @return 기본 환불 정책 서비스 - */ - public RefundPolicyService getDefaultPolicy() { - return defaultPolicy; - } - - /** - * 등록된 모든 정책 목록 조회 - * - * @return 환불 정책 목록 - */ - public List getAllPolicies() { - return policies; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/refund/RefundPolicyService.java b/src/main/java/com/sudo/railo/payment/domain/service/refund/RefundPolicyService.java deleted file mode 100644 index 183894eb..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/refund/RefundPolicyService.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.sudo.railo.payment.domain.service.refund; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 환불 정책 서비스 인터페이스 - * 운영사별로 다른 환불 수수료 정책을 적용하기 위한 인터페이스 - */ -public interface RefundPolicyService { - - /** - * 환불 수수료율 계산 - * - * @param departureTime 열차 출발 시간 - * @param arrivalTime 열차 도착 시간 - * @param requestTime 환불 요청 시간 - * @return 환불 수수료율 (0.0 ~ 1.0) - */ - BigDecimal calculateRefundFeeRate(LocalDateTime departureTime, - LocalDateTime arrivalTime, - LocalDateTime requestTime); - - /** - * 환불 가능 여부 확인 - * - * @param arrivalTime 열차 도착 시간 - * @param requestTime 환불 요청 시간 - * @return 환불 가능하면 true - */ - default boolean isRefundable(LocalDateTime arrivalTime, LocalDateTime requestTime) { - return requestTime.isBefore(arrivalTime); - } - - /** - * 이 정책이 지원하는 운영사인지 확인 - * - * @param operator 운영사 - * @return 지원하면 true - */ - boolean supports(String operator); - - /** - * 정책 이름 조회 - * - * @return 정책 이름 - */ - String getPolicyName(); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/service/refund/SRTRefundPolicy.java b/src/main/java/com/sudo/railo/payment/domain/service/refund/SRTRefundPolicy.java deleted file mode 100644 index fd9cbb75..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/service/refund/SRTRefundPolicy.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.sudo.railo.payment.domain.service.refund; - -import java.math.BigDecimal; -import java.time.Duration; -import java.time.LocalDateTime; - -import org.springframework.stereotype.Component; - -import lombok.extern.slf4j.Slf4j; - -/** - * SRT 환불 정책 구현 - * SRT 열차에 적용되는 환불 정책 - * - * SRT도 KORAIL과 동일한 환불 정책을 적용: - * - 열차 출발 전까지: 위약금 없음 - * - 열차 출발 후 20분까지: 위약금 30% 부과 - * - 열차 출발 후 60분까지: 위약금 40% 부과 - * - 열차 도착 시간까지: 위약금 70% 부과 - * - 열차 도착 시간 이후: 반환 불가 - */ -@Slf4j -@Component -public class SRTRefundPolicy implements RefundPolicyService { - - // 환불 수수료율 상수 (KORAIL과 동일) - private static final BigDecimal NO_FEE = BigDecimal.ZERO; - private static final BigDecimal FEE_30_PERCENT = new BigDecimal("0.3"); - private static final BigDecimal FEE_40_PERCENT = new BigDecimal("0.4"); - private static final BigDecimal FEE_70_PERCENT = new BigDecimal("0.7"); - private static final BigDecimal FULL_FEE = BigDecimal.ONE; - - // 시간 기준 상수 (분) - private static final long MINUTES_20 = 20; - private static final long MINUTES_60 = 60; - - @Override - public BigDecimal calculateRefundFeeRate(LocalDateTime departureTime, - LocalDateTime arrivalTime, - LocalDateTime requestTime) { - log.debug("SRT 환불 수수료 계산 - 출발: {}, 도착: {}, 요청: {}", - departureTime, arrivalTime, requestTime); - - // 1. 도착 후 환불 요청 - 환불 불가 (100% 수수료) - if (requestTime.isAfter(arrivalTime)) { - log.info("도착 후 환불 요청 - 환불 불가"); - return FULL_FEE; - } - - // 2. 출발 전 환불 - 수수료 없음 - if (requestTime.isBefore(departureTime)) { - log.info("출발 전 환불 요청 - 수수료 없음"); - return NO_FEE; - } - - // 3. 출발 후 ~ 도착 전 환불 - 경과 시간에 따른 수수료 - long minutesAfterDeparture = Duration.between(departureTime, requestTime).toMinutes(); - - BigDecimal feeRate; - if (minutesAfterDeparture <= MINUTES_20) { - feeRate = FEE_30_PERCENT; - log.info("출발 후 {}분 이내 환불 - 30% 수수료", minutesAfterDeparture); - } else if (minutesAfterDeparture <= MINUTES_60) { - feeRate = FEE_40_PERCENT; - log.info("출발 후 {}분 이내 환불 - 40% 수수료", minutesAfterDeparture); - } else { - feeRate = FEE_70_PERCENT; - log.info("출발 후 {}분 초과 환불 - 70% 수수료", minutesAfterDeparture); - } - - return feeRate; - } - - @Override - public boolean supports(String operator) { - return "SRT".equalsIgnoreCase(operator); - } - - @Override - public String getPolicyName() { - return "SRT 환불 정책"; - } -} diff --git a/src/main/java/com/sudo/railo/payment/domain/status/PaymentStatus.java b/src/main/java/com/sudo/railo/payment/domain/status/PaymentStatus.java new file mode 100644 index 00000000..bebab78b --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/domain/status/PaymentStatus.java @@ -0,0 +1,39 @@ +package com.sudo.railo.payment.domain.status; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PaymentStatus { + + PENDING("결제대기", "결제 요청이 생성된 상태"), + PAID("결제완료", "결제가 성공적으로 완료된 상태"), + CANCELLED("결제취소", "결제가 취소된 상태"), + REFUNDED("환불완료", "결제가 환불된 상태"), + FAILED("결제실패", "결제 처리가 실패한 상태"); + + private final String displayName; + private final String description; + + /** + * 결제 가능한 상태인지 확인 + */ + public boolean isPayable() { + return this == PENDING; + } + + /** + * 취소 가능한 상태인지 확인 + */ + public boolean isCancellable() { + return this == PAID; + } + + /** + * 환불 가능한 상태인지 확인 + */ + public boolean isRefundable() { + return this == CANCELLED; + } +} diff --git a/src/main/java/com/sudo/railo/payment/domain/type/PaymentMethod.java b/src/main/java/com/sudo/railo/payment/domain/type/PaymentMethod.java new file mode 100644 index 00000000..2934f4cd --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/domain/type/PaymentMethod.java @@ -0,0 +1,14 @@ +package com.sudo.railo.payment.domain.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PaymentMethod { + CARD("카드", "카드 결제"), + TRANSFER("계좌이체", "계좌이체 결제"); + + private final String displayName; + private final String description; +} diff --git a/src/main/java/com/sudo/railo/payment/domain/util/DelayCompensationCalculator.java b/src/main/java/com/sudo/railo/payment/domain/util/DelayCompensationCalculator.java deleted file mode 100644 index cb99bff6..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/util/DelayCompensationCalculator.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.sudo.railo.payment.domain.util; - -import java.math.BigDecimal; - -/** - * 지연 보상 비율 계산 유틸리티 - * 열차 지연 시간에 따른 마일리지 보상 비율을 계산 - */ -public final class DelayCompensationCalculator { - - // 지연 시간 기준 (분) - private static final int DELAY_THRESHOLD_20_MINUTES = 20; - private static final int DELAY_THRESHOLD_40_MINUTES = 40; - private static final int DELAY_THRESHOLD_60_MINUTES = 60; - - // 보상 비율 - private static final BigDecimal COMPENSATION_RATE_12_5_PERCENT = new BigDecimal("0.125"); - private static final BigDecimal COMPENSATION_RATE_25_PERCENT = new BigDecimal("0.25"); - private static final BigDecimal COMPENSATION_RATE_50_PERCENT = new BigDecimal("0.50"); - - private DelayCompensationCalculator() { - // 유틸리티 클래스는 인스턴스화 방지 - } - - /** - * 지연 시간에 따른 보상 비율 계산 (BigDecimal 반환) - * - * @param delayMinutes 지연 시간(분) - * @return 보상 비율 (0.0 ~ 0.5) - */ - public static BigDecimal calculateCompensationRate(int delayMinutes) { - if (delayMinutes >= DELAY_THRESHOLD_60_MINUTES) { - return COMPENSATION_RATE_50_PERCENT; // 50% 보상 - } else if (delayMinutes >= DELAY_THRESHOLD_40_MINUTES) { - return COMPENSATION_RATE_25_PERCENT; // 25% 보상 - } else if (delayMinutes >= DELAY_THRESHOLD_20_MINUTES) { - return COMPENSATION_RATE_12_5_PERCENT; // 12.5% 보상 - } else { - return BigDecimal.ZERO; // 보상 없음 - } - } - - /** - * 지연 시간에 따른 보상 비율 계산 (double 반환) - * Train 도메인 호환용 - * - * @param delayMinutes 지연 시간(분) - * @return 보상 비율 (0.0 ~ 0.5) - */ - public static double calculateCompensationRateAsDouble(int delayMinutes) { - return calculateCompensationRate(delayMinutes).doubleValue(); - } - - /** - * 지연 보상 대상 여부 확인 - * - * @param delayMinutes 지연 시간(분) - * @return 20분 이상 지연 시 true - */ - public static boolean isEligibleForCompensation(int delayMinutes) { - return delayMinutes >= DELAY_THRESHOLD_20_MINUTES; - } - - /** - * 지연 보상 금액 계산 - * - * @param originalAmount 원래 결제 금액 - * @param delayMinutes 지연 시간(분) - * @return 보상 금액 (소수점 이하 버림) - */ - public static BigDecimal calculateCompensationAmount(BigDecimal originalAmount, int delayMinutes) { - BigDecimal compensationRate = calculateCompensationRate(delayMinutes); - - if (compensationRate.compareTo(BigDecimal.ZERO) == 0) { - return BigDecimal.ZERO; - } - - return originalAmount - .multiply(compensationRate) - .setScale(0, BigDecimal.ROUND_DOWN); - } - - /** - * 지연 단계별 설명 반환 - * - * @param delayMinutes 지연 시간(분) - * @return 지연 단계 설명 - */ - public static String getDelayCompensationDescription(int delayMinutes) { - if (delayMinutes >= DELAY_THRESHOLD_60_MINUTES) { - return "60분 이상 지연 (50% 보상)"; - } else if (delayMinutes >= DELAY_THRESHOLD_40_MINUTES) { - return "40분 이상 지연 (25% 보상)"; - } else if (delayMinutes >= DELAY_THRESHOLD_20_MINUTES) { - return "20분 이상 지연 (12.5% 보상)"; - } else { - return "지연 보상 없음"; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/util/PaymentAmountUtils.java b/src/main/java/com/sudo/railo/payment/domain/util/PaymentAmountUtils.java deleted file mode 100644 index 1a30f81e..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/util/PaymentAmountUtils.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.sudo.railo.payment.domain.util; - -import com.sudo.railo.payment.domain.constant.PaymentPrecision; - -import java.math.BigDecimal; -import java.math.RoundingMode; - -/** - * 결제 금액 계산 유틸리티 - * - * BigDecimal 연산 시 정밀도와 반올림 규칙을 통일하여 적용 - * 모든 금액 계산은 이 유틸리티를 통해 수행 - */ -public final class PaymentAmountUtils { - - /** - * 기본 반올림 모드 - 반올림(HALF_UP) - */ - private static final RoundingMode DEFAULT_ROUNDING = RoundingMode.HALF_UP; - - /** - * 마일리지 반올림 모드 - 버림(DOWN) - */ - private static final RoundingMode MILEAGE_ROUNDING = RoundingMode.DOWN; - - private PaymentAmountUtils() { - // 유틸리티 클래스이므로 인스턴스화 방지 - } - - /** - * 금액 정규화 - 원화 - * - * @param amount 원본 금액 - * @return 정밀도가 조정된 금액 - */ - public static BigDecimal normalizeAmount(BigDecimal amount) { - if (amount == null) { - return BigDecimal.ZERO; - } - return amount.setScale(PaymentPrecision.AMOUNT_SCALE, DEFAULT_ROUNDING); - } - - /** - * 마일리지 정규화 - * - * @param mileage 원본 마일리지 - * @return 정밀도가 조정된 마일리지 (소수점 버림) - */ - public static BigDecimal normalizeMileage(BigDecimal mileage) { - if (mileage == null) { - return BigDecimal.ZERO; - } - return mileage.setScale(PaymentPrecision.MILEAGE_SCALE, MILEAGE_ROUNDING); - } - - /** - * 비율 정규화 - * - * @param rate 원본 비율 - * @return 정밀도가 조정된 비율 - */ - public static BigDecimal normalizeRate(BigDecimal rate) { - if (rate == null) { - return BigDecimal.ZERO; - } - return rate.setScale(PaymentPrecision.RATE_SCALE, DEFAULT_ROUNDING); - } - - /** - * 할인 금액 계산 - * - * @param originalAmount 원금액 - * @param discountRate 할인율 (0.1 = 10%) - * @return 할인 금액 - */ - public static BigDecimal calculateDiscountAmount(BigDecimal originalAmount, BigDecimal discountRate) { - if (originalAmount == null || discountRate == null) { - return BigDecimal.ZERO; - } - - BigDecimal discount = originalAmount.multiply(discountRate); - return normalizeAmount(discount); - } - - /** - * 마일리지 적립 금액 계산 - * - * @param paidAmount 실제 결제 금액 - * @param earningRate 적립률 (0.01 = 1%) - * @return 적립 마일리지 - */ - public static BigDecimal calculateMileageEarning(BigDecimal paidAmount, BigDecimal earningRate) { - if (paidAmount == null || earningRate == null) { - return BigDecimal.ZERO; - } - - BigDecimal earning = paidAmount.multiply(earningRate); - return normalizeMileage(earning); // 마일리지는 소수점 버림 - } - - /** - * 최종 결제 금액 계산 - * - * @param originalAmount 원금액 - * @param discountAmount 할인 금액 - * @param mileageDeduction 마일리지 차감 금액 - * @return 최종 결제 금액 - */ - public static BigDecimal calculateFinalAmount(BigDecimal originalAmount, - BigDecimal discountAmount, - BigDecimal mileageDeduction) { - BigDecimal original = normalizeAmount(originalAmount); - BigDecimal discount = normalizeAmount(discountAmount); - BigDecimal mileage = normalizeAmount(mileageDeduction); - - BigDecimal finalAmount = original.subtract(discount).subtract(mileage); - - // 음수 방지 - if (finalAmount.compareTo(BigDecimal.ZERO) < 0) { - return BigDecimal.ZERO; - } - - return finalAmount; - } - - /** - * 금액 유효성 검증 - * - * @param amount 검증할 금액 - * @return 유효한 금액인지 여부 - */ - public static boolean isValidAmount(BigDecimal amount) { - return amount != null && amount.compareTo(BigDecimal.ZERO) >= 0; - } - - /** - * 양수 금액 검증 - * - * @param amount 검증할 금액 - * @return 양수인지 여부 - */ - public static boolean isPositiveAmount(BigDecimal amount) { - return amount != null && amount.compareTo(BigDecimal.ZERO) > 0; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/domain/util/PaymentStatusMapper.java b/src/main/java/com/sudo/railo/payment/domain/util/PaymentStatusMapper.java deleted file mode 100644 index bf7a3d80..00000000 --- a/src/main/java/com/sudo/railo/payment/domain/util/PaymentStatusMapper.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.sudo.railo.payment.domain.util; - -import com.sudo.railo.payment.domain.entity.PaymentExecutionStatus; -import com.sudo.railo.booking.domain.PaymentStatus; - -/** - * 결제 상태 매핑 유틸리티 - * - * Payment 도메인의 PaymentExecutionStatus와 Booking 도메인의 PaymentStatus 간의 매핑을 담당 - * - * 매핑 규칙: - * - PENDING/PROCESSING → RESERVED (예약 상태) - * - SUCCESS → PAID (결제 완료) - * - CANCELLED → CANCELLED (취소) - * - REFUNDED → REFUNDED (환불) - * - FAILED → CANCELLED (실패는 취소로 처리) - */ -public class PaymentStatusMapper { - - private PaymentStatusMapper() { - // 유틸리티 클래스 - 인스턴스 생성 방지 - } - - /** - * PaymentExecutionStatus를 Booking PaymentStatus로 변환 - * - * @param executionStatus 결제 실행 상태 - * @return 예약 시스템 결제 상태 - * @throws IllegalArgumentException 지원하지 않는 상태인 경우 - */ - public static PaymentStatus toBookingPaymentStatus(PaymentExecutionStatus executionStatus) { - if (executionStatus == null) { - throw new IllegalArgumentException("PaymentExecutionStatus는 null일 수 없습니다"); - } - - switch (executionStatus) { - case PENDING: - case PROCESSING: - return PaymentStatus.RESERVED; - case SUCCESS: - return PaymentStatus.PAID; - case CANCELLED: - return PaymentStatus.CANCELLED; - case REFUNDED: - return PaymentStatus.REFUNDED; - case FAILED: - return PaymentStatus.CANCELLED; // 실패는 취소로 처리 - default: - throw new IllegalArgumentException("지원하지 않는 PaymentExecutionStatus: " + executionStatus); - } - } - - /** - * Booking PaymentStatus를 PaymentExecutionStatus로 변환 - * - * @param bookingStatus 예약 시스템 결제 상태 - * @return 결제 실행 상태 - * @throws IllegalArgumentException 지원하지 않는 상태인 경우 - */ - public static PaymentExecutionStatus toPaymentExecutionStatus(PaymentStatus bookingStatus) { - if (bookingStatus == null) { - throw new IllegalArgumentException("PaymentStatus는 null일 수 없습니다"); - } - - switch (bookingStatus) { - case RESERVED: - return PaymentExecutionStatus.PENDING; - case PAID: - return PaymentExecutionStatus.SUCCESS; - case CANCELLED: - return PaymentExecutionStatus.CANCELLED; - case REFUNDED: - return PaymentExecutionStatus.REFUNDED; - default: - throw new IllegalArgumentException("지원하지 않는 PaymentStatus: " + bookingStatus); - } - } - - /** - * 결제 실행 상태가 완료된 상태인지 확인 - * - * @param executionStatus 결제 실행 상태 - * @return 완료된 상태(SUCCESS, CANCELLED, REFUNDED)인 경우 true - */ - public static boolean isCompleted(PaymentExecutionStatus executionStatus) { - return executionStatus == PaymentExecutionStatus.SUCCESS - || executionStatus == PaymentExecutionStatus.CANCELLED - || executionStatus == PaymentExecutionStatus.REFUNDED - || executionStatus == PaymentExecutionStatus.FAILED; - } - - /** - * 결제 실행 상태가 진행 중인지 확인 - * - * @param executionStatus 결제 실행 상태 - * @return 진행 중인 상태(PENDING, PROCESSING)인 경우 true - */ - public static boolean isInProgress(PaymentExecutionStatus executionStatus) { - return executionStatus == PaymentExecutionStatus.PENDING - || executionStatus == PaymentExecutionStatus.PROCESSING; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/exception/DuplicatePgAuthException.java b/src/main/java/com/sudo/railo/payment/exception/DuplicatePgAuthException.java deleted file mode 100644 index 581e00ec..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/DuplicatePgAuthException.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.sudo.railo.payment.exception; - -/** - * PG 승인번호 중복 사용 예외 - */ -public class DuplicatePgAuthException extends RuntimeException { - - public DuplicatePgAuthException(String message) { - super(message); - } - - public DuplicatePgAuthException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/exception/InsufficientMileageException.java b/src/main/java/com/sudo/railo/payment/exception/InsufficientMileageException.java deleted file mode 100644 index 5e30c89a..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/InsufficientMileageException.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.sudo.railo.payment.exception; - -import java.math.BigDecimal; - -/** - * 마일리지 부족 예외 - * 사용 가능한 마일리지보다 많은 마일리지를 사용하려 할 때 발생 - */ -public class InsufficientMileageException extends PaymentValidationException { - - private final BigDecimal requestedAmount; - private final BigDecimal availableAmount; - - public InsufficientMileageException(BigDecimal requestedAmount, BigDecimal availableAmount) { - super(String.format("마일리지가 부족합니다. 요청: %s, 사용가능: %s", - requestedAmount, availableAmount)); - this.requestedAmount = requestedAmount; - this.availableAmount = availableAmount; - } - - public BigDecimal getRequestedAmount() { - return requestedAmount; - } - - public BigDecimal getAvailableAmount() { - return availableAmount; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/exception/PaymentAmountLimitExceededException.java b/src/main/java/com/sudo/railo/payment/exception/PaymentAmountLimitExceededException.java deleted file mode 100644 index 346c2e94..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/PaymentAmountLimitExceededException.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.sudo.railo.payment.exception; - -import java.math.BigDecimal; - -/** - * 결제 금액 한도 초과 예외 - * 최대/최소 결제 금액을 벗어날 때 발생 - */ -public class PaymentAmountLimitExceededException extends PaymentValidationException { - - private final BigDecimal requestedAmount; - private final BigDecimal limitAmount; - private final LimitType limitType; - - public enum LimitType { - MAX_AMOUNT("최대 결제 금액"), - MIN_AMOUNT("최소 결제 금액"), - MAX_NON_MEMBER_AMOUNT("비회원 최대 결제 금액"); - - private final String description; - - LimitType(String description) { - this.description = description; - } - - public String getDescription() { - return description; - } - } - - public PaymentAmountLimitExceededException(BigDecimal requestedAmount, BigDecimal limitAmount, LimitType limitType) { - super(String.format("%s 초과. 요청 금액: %s, 제한 금액: %s", - limitType.getDescription(), requestedAmount, limitAmount)); - this.requestedAmount = requestedAmount; - this.limitAmount = limitAmount; - this.limitType = limitType; - } - - public BigDecimal getRequestedAmount() { - return requestedAmount; - } - - public BigDecimal getLimitAmount() { - return limitAmount; - } - - public LimitType getLimitType() { - return limitType; - } -} diff --git a/src/main/java/com/sudo/railo/payment/exception/PaymentContextException.java b/src/main/java/com/sudo/railo/payment/exception/PaymentContextException.java deleted file mode 100644 index 70eba5e0..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/PaymentContextException.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.sudo.railo.payment.exception; - -/** - * 결제 컨텍스트 관련 예외 - * - * PaymentContext 생성 및 검증 과정에서 발생하는 예외 - */ -public class PaymentContextException extends PaymentException { - - public PaymentContextException(String message) { - super(message); - } - - public PaymentContextException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/exception/PaymentCryptoException.java b/src/main/java/com/sudo/railo/payment/exception/PaymentCryptoException.java deleted file mode 100644 index fdb753d4..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/PaymentCryptoException.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.sudo.railo.payment.exception; - -/** - * 결제 정보 암호화/복호화 관련 예외 - */ -public class PaymentCryptoException extends PaymentException { - - public PaymentCryptoException(String message) { - super(message); - } - - public PaymentCryptoException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/exception/PaymentError.java b/src/main/java/com/sudo/railo/payment/exception/PaymentError.java new file mode 100644 index 00000000..873c13ab --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/exception/PaymentError.java @@ -0,0 +1,40 @@ +package com.sudo.railo.payment.exception; + +import org.springframework.http.HttpStatus; + +import com.sudo.railo.global.exception.error.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum PaymentError implements ErrorCode { + + // 예약 관련 에러 + RESERVATION_NOT_FOUND("예약을 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "P_001"), + RESERVATION_ACCESS_DENIED("예약에 대한 접근 권한이 없습니다.", HttpStatus.FORBIDDEN, "P_002"), + RESERVATION_NOT_PAYABLE("결제할 수 없는 예약 상태입니다.", HttpStatus.BAD_REQUEST, "P_003"), + + // 결제 관련 에러 + PAYMENT_NOT_FOUND("결제 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "P_004"), + PAYMENT_ACCESS_DENIED("결제에 대한 접근 권한이 없습니다.", HttpStatus.FORBIDDEN, "P_005"), + PAYMENT_ALREADY_COMPLETED("이미 결제가 완료된 예약입니다.", HttpStatus.BAD_REQUEST, "P_006"), + PAYMENT_NOT_CANCELLABLE("취소할 수 없는 결제 상태입니다.", HttpStatus.BAD_REQUEST, "P_007"), + PAYMENT_NOT_APPROVABLE("승인할 수 없는 결제 상태입니다.", HttpStatus.BAD_REQUEST, "P_008"), + PAYMENT_PROCESS_FAILED("결제 처리 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR, "P_009"), + + // 금액 관련 에러 + INVALID_PAYMENT_AMOUNT("유효하지 않은 결제 금액입니다.", HttpStatus.BAD_REQUEST, "P_010"), + PAYMENT_AMOUNT_MISMATCH("결제 금액이 일치하지 않습니다.", HttpStatus.BAD_REQUEST, "P_011"), + + // 결제 수단 관련 에러 + INVALID_PAYMENT_METHOD("지원하지 않는 결제 수단입니다.", HttpStatus.BAD_REQUEST, "P_012"), + + // 시스템 에러 + PAYMENT_SYSTEM_ERROR("결제 시스템 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR, "P_999"); + + private final String message; + private final HttpStatus status; + private final String code; +} diff --git a/src/main/java/com/sudo/railo/payment/exception/PaymentException.java b/src/main/java/com/sudo/railo/payment/exception/PaymentException.java deleted file mode 100644 index 22db19fd..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/PaymentException.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.sudo.railo.payment.exception; - -/** - * 결제 관련 예외 - */ -public class PaymentException extends RuntimeException { - - public PaymentException(String message) { - super(message); - } - - public PaymentException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/exception/PaymentExecutionException.java b/src/main/java/com/sudo/railo/payment/exception/PaymentExecutionException.java deleted file mode 100644 index e178bbca..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/PaymentExecutionException.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.sudo.railo.payment.exception; - -/** - * 결제 실행 관련 예외 - * - * 결제 실행 과정에서 발생하는 예외 (PG 연동, 마일리지 차감 등) - */ -public class PaymentExecutionException extends PaymentException { - - public PaymentExecutionException(String message) { - super(message); - } - - public PaymentExecutionException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/exception/PaymentNotFoundException.java b/src/main/java/com/sudo/railo/payment/exception/PaymentNotFoundException.java deleted file mode 100644 index daaa2d68..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/PaymentNotFoundException.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.sudo.railo.payment.exception; - -/** - * 결제 정보를 찾을 수 없을 때 발생하는 예외 - */ -public class PaymentNotFoundException extends PaymentException { - - public PaymentNotFoundException(String message) { - super(message); - } - - public PaymentNotFoundException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/exception/PaymentSessionExpiredException.java b/src/main/java/com/sudo/railo/payment/exception/PaymentSessionExpiredException.java deleted file mode 100644 index 4f645541..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/PaymentSessionExpiredException.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.sudo.railo.payment.exception; - -import java.time.LocalDateTime; - -/** - * 결제 세션 만료 예외 - * 계산 세션이 만료되어 결제할 수 없을 때 발생 - */ -public class PaymentSessionExpiredException extends PaymentValidationException { - - private final String sessionId; - private final LocalDateTime expiredAt; - - public PaymentSessionExpiredException(String sessionId, LocalDateTime expiredAt) { - super(String.format("결제 세션이 만료되었습니다. 세션ID: %s, 만료시간: %s", - sessionId, expiredAt)); - this.sessionId = sessionId; - this.expiredAt = expiredAt; - } - - public String getSessionId() { - return sessionId; - } - - public LocalDateTime getExpiredAt() { - return expiredAt; - } -} diff --git a/src/main/java/com/sudo/railo/payment/exception/PaymentValidationException.java b/src/main/java/com/sudo/railo/payment/exception/PaymentValidationException.java deleted file mode 100644 index 8e52ac1c..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/PaymentValidationException.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.sudo.railo.payment.exception; - -/** - * 결제 검증 실패 예외 - * - * 결제 과정에서 발생하는 검증 관련 예외를 나타냄 - * 예: 금액 불일치, 상태 오류, 세션 만료 등 - */ -public class PaymentValidationException extends PaymentException { - - public PaymentValidationException(String message) { - super(message); - } - - public PaymentValidationException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/exception/RefundDeniedException.java b/src/main/java/com/sudo/railo/payment/exception/RefundDeniedException.java deleted file mode 100644 index b29d44ef..00000000 --- a/src/main/java/com/sudo/railo/payment/exception/RefundDeniedException.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.sudo.railo.payment.exception; - -/** - * 환불이 거부될 때 발생하는 예외 - * 사용자에게 명확한 거부 사유를 전달하기 위한 전용 예외 - */ -public class RefundDeniedException extends PaymentException { - - public RefundDeniedException(String message) { - super(message); - } - - public RefundDeniedException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/PaymentRepository.java b/src/main/java/com/sudo/railo/payment/infrastructure/PaymentRepository.java new file mode 100644 index 00000000..55c471fe --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/infrastructure/PaymentRepository.java @@ -0,0 +1,31 @@ +package com.sudo.railo.payment.infrastructure; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.sudo.railo.payment.domain.Payment; +import com.sudo.railo.payment.domain.status.PaymentStatus; + +@Repository +public interface PaymentRepository extends JpaRepository, PaymentRepositoryCustom { + + /** + * 결제 키로 결제 정보 조회 + */ + Optional findByPaymentKey(String paymentKey); + + /** + * 회원의 모든 결제 목록 조회 (최신순) + */ + List findByMemberIdOrderByPaidAtDesc(Long memberId); + + /** + * 예약 ID와 결제 상태로 결제 존재 여부 확인 + */ + boolean existsByReservationIdAndPaymentStatus(Long reservationId, PaymentStatus paymentStatus); + + +} diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/PaymentRepositoryCustom.java b/src/main/java/com/sudo/railo/payment/infrastructure/PaymentRepositoryCustom.java new file mode 100644 index 00000000..4bef7be2 --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/infrastructure/PaymentRepositoryCustom.java @@ -0,0 +1,16 @@ +package com.sudo.railo.payment.infrastructure; + +import java.util.List; + +import com.sudo.railo.payment.application.dto.projection.PaymentProjection; + +public interface PaymentRepositoryCustom { + + /** + * 회원의 결제 히스토리를 프로젝션으로 조회 (가장 효율적) + * + * @param memberId 회원 ID + * @return PaymentHistoryResponse 리스트 (필요한 필드만 조회) + */ + List findPaymentHistoryByMemberId(Long memberId); +} diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/PaymentRepositoryCustomImpl.java b/src/main/java/com/sudo/railo/payment/infrastructure/PaymentRepositoryCustomImpl.java new file mode 100644 index 00000000..e81be2d3 --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/infrastructure/PaymentRepositoryCustomImpl.java @@ -0,0 +1,42 @@ +package com.sudo.railo.payment.infrastructure; + +import static com.sudo.railo.booking.domain.QReservation.*; +import static com.sudo.railo.member.domain.QMember.*; +import static com.sudo.railo.payment.domain.QPayment.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.sudo.railo.payment.application.dto.projection.PaymentProjection; +import com.sudo.railo.payment.application.dto.projection.QPaymentProjection; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class PaymentRepositoryCustomImpl implements PaymentRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findPaymentHistoryByMemberId(Long memberId) { + return queryFactory + .select(new QPaymentProjection( + payment.id, + payment.paymentKey, + reservation.reservationCode, + payment.amount, + payment.paymentMethod, + payment.paymentStatus, + payment.paidAt, + payment.cancelledAt, + payment.refundedAt)) + .from(payment) + .join(payment.member, member) + .join(payment.reservation, reservation) + .where(payment.member.id.eq(memberId)) + .orderBy(payment.paidAt.desc()).fetch(); + } +} diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/event/MileageEarningEventAdapter.java b/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/event/MileageEarningEventAdapter.java deleted file mode 100644 index ff1f07b8..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/event/MileageEarningEventAdapter.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.sudo.railo.payment.infrastructure.adapter.out.event; - -import com.sudo.railo.payment.application.port.out.MileageEarningEventPort; -import com.sudo.railo.payment.application.service.DomainEventOutboxService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -/** - * 마일리지 적립 이벤트 어댑터 - * - * 헥사고날 아키텍처의 어댑터로, 마일리지 적립 관련 - * 도메인 이벤트 발행을 담당합니다. - */ -@Component -@RequiredArgsConstructor -public class MileageEarningEventAdapter implements MileageEarningEventPort { - - private final DomainEventOutboxService domainEventOutboxService; - - @Override - public void publishMileageEarningReadyEvent( - Long scheduleId, - Long memberId, - String aggregateId) { - - domainEventOutboxService.publishMileageEarningReadyEvent( - scheduleId, memberId, aggregateId - ); - } - - @Override - public void publishMileageEarnedEvent( - Long transactionId, - Long memberId, - String pointsAmount, - String earningType) { - - domainEventOutboxService.publishMileageEarnedEvent( - transactionId, memberId, pointsAmount, earningType - ); - } - - @Override - public void publishDelayCompensationEarnedEvent( - Long transactionId, - Long memberId, - String compensationAmount, - int delayMinutes) { - - domainEventOutboxService.publishDelayCompensationEarnedEvent( - transactionId, memberId, compensationAmount, delayMinutes - ); - } - - @Override - public void publishMileageEarningFailedEvent( - Long scheduleId, - Long memberId, - String reason) { - - // DomainEventOutboxService에 실패 이벤트 메서드가 없으므로 - // 일반적인 이벤트로 발행하거나 새로운 메서드 추가 필요 - // TODO: 실패 이벤트 발행 메서드 구현 - domainEventOutboxService.publishMileageEarningReadyEvent( - scheduleId, memberId, "FAILED:" + reason - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/member/MemberInfoAdapter.java b/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/member/MemberInfoAdapter.java deleted file mode 100644 index 4554178c..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/member/MemberInfoAdapter.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.sudo.railo.payment.infrastructure.adapter.out.member; - -import com.sudo.railo.member.application.MemberService; -import com.sudo.railo.payment.application.port.out.LoadMemberInfoPort; -import com.sudo.railo.payment.application.port.out.LoadMemberPort; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; - -/** - * 회원 정보 어댑터 - * - * 헥사고날 아키텍처의 어댑터로, Member 도메인과의 - * 통신을 담당합니다. - */ -@Component -@RequiredArgsConstructor -public class MemberInfoAdapter implements LoadMemberInfoPort { - - private final MemberService memberService; - private final LoadMemberPort loadMemberPort; - - @Override - public BigDecimal getMileageBalance(Long memberId) { - return memberService.getMileageBalance(memberId); - } - - @Override - public String getMemberType(Long memberId) { - // LoadMemberPort를 통해 회원 타입 조회 - return loadMemberPort.getMemberType(memberId); - } - - @Override - public boolean existsById(Long memberId) { - return loadMemberPort.existsById(memberId); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/member/SaveMemberInfoAdapter.java b/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/member/SaveMemberInfoAdapter.java deleted file mode 100644 index f98619bf..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/member/SaveMemberInfoAdapter.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.sudo.railo.payment.infrastructure.adapter.out.member; - -import com.sudo.railo.payment.application.port.out.SaveMemberInfoPort; -import com.sudo.railo.member.infra.MemberRepository; -import com.sudo.railo.member.domain.Member; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * 회원 정보 저장 어댑터 - * - * SaveMemberInfoPort의 구현체로, 실제 Member 도메인과의 연동을 담당합니다. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class SaveMemberInfoAdapter implements SaveMemberInfoPort { - - private final MemberRepository memberRepository; - - @Override - @Transactional - public void addMileage(Long memberId, Long amount) { - log.info("마일리지 추가 시작 - memberId: {}, amount: {}", memberId, amount); - - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다: " + memberId)); - - member.addMileage(amount); - memberRepository.save(member); - - log.info("마일리지 추가 완료 - memberId: {}, amount: {}, 현재 총 마일리지: {}", - memberId, amount, member.getTotalMileage()); - } - - @Override - @Transactional - public void useMileage(Long memberId, Long amount) { - log.info("마일리지 사용 시작 - memberId: {}, amount: {}", memberId, amount); - - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다: " + memberId)); - - member.useMileage(amount); - memberRepository.save(member); - - log.info("마일리지 사용 완료 - memberId: {}, amount: {}, 현재 총 마일리지: {}", - memberId, amount, member.getTotalMileage()); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/MemberPersistenceAdapter.java b/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/MemberPersistenceAdapter.java deleted file mode 100644 index bf9f065b..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/MemberPersistenceAdapter.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.sudo.railo.payment.infrastructure.adapter.out.persistence; - -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.member.infra.MemberRepository; -import com.sudo.railo.payment.application.port.out.LoadMemberPort; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -/** - * 회원 영속성 어댑터 - * - * 결제 도메인에서 필요한 회원 정보 조회를 담당 - * 회원 도메인의 Repository를 활용하여 필요한 정보만 제공 - */ -@Component -@RequiredArgsConstructor -public class MemberPersistenceAdapter implements LoadMemberPort { - - private final MemberRepository memberRepository; - - @Override - public String getMemberType(Long memberId) { - return memberRepository.findById(memberId) - .map(member -> { - if (member.getMemberDetail() != null && member.getMemberDetail().getMembership() != null) { - // Membership enum을 MemberType 형식으로 변환 - return switch (member.getMemberDetail().getMembership()) { - case VIP, VVIP -> "VIP"; - case BUSINESS -> "BUSINESS"; - default -> "GENERAL"; - }; - } - return "GENERAL"; - }) - .orElse("GENERAL"); - } - - @Override - public boolean existsById(Long memberId) { - return memberRepository.existsById(memberId); - } - - @Override - public Optional findById(Long memberId) { - return memberRepository.findById(memberId); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/MileageEarningSchedulePersistenceAdapter.java b/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/MileageEarningSchedulePersistenceAdapter.java deleted file mode 100644 index 14642ad9..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/MileageEarningSchedulePersistenceAdapter.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.sudo.railo.payment.infrastructure.adapter.out.persistence; - -import com.sudo.railo.payment.application.port.out.LoadMileageEarningSchedulePort; -import com.sudo.railo.payment.application.port.out.SaveMileageEarningSchedulePort; -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import com.sudo.railo.payment.domain.repository.MileageEarningScheduleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * 마일리지 적립 스케줄 영속성 어댑터 - * - * 헥사고날 아키텍처의 어댑터로, 출력 포트를 구현하여 - * 실제 데이터베이스 접근을 담당합니다. - * 동시성 제어를 위한 비관적 락과 원자적 업데이트를 지원합니다. - */ -@Component -@RequiredArgsConstructor -public class MileageEarningSchedulePersistenceAdapter - implements LoadMileageEarningSchedulePort, SaveMileageEarningSchedulePort { - - private final MileageEarningScheduleRepository repository; - - // LoadMileageEarningSchedulePort 구현 - - @Override - public Optional findById(Long scheduleId) { - return repository.findById(scheduleId); - } - - @Override - public List findReadySchedulesWithLock(LocalDateTime currentTime, int limit) { - // FOR UPDATE SKIP LOCKED를 사용하여 동시성 문제 방지 - return repository.findReadySchedulesWithLockAndLimit(currentTime, limit); - } - - @Override - public List findByTrainScheduleId(Long trainScheduleId) { - return repository.findByTrainScheduleId(trainScheduleId); - } - - @Override - public Optional findByPaymentId(String paymentId) { - return repository.findByPaymentId(paymentId); - } - - @Override - public List findByMemberId(Long memberId) { - return repository.findByMemberId(memberId); - } - - @Override - public List findByMemberIdAndStatus( - Long memberId, MileageEarningSchedule.EarningStatus status) { - return repository.findByMemberIdAndStatus(memberId, status); - } - - @Override - public BigDecimal calculatePendingMileageByMemberId(Long memberId) { - return repository.calculatePendingMileageByMemberId(memberId); - } - - @Override - public Object getMileageEarningStatistics(LocalDateTime startTime, LocalDateTime endTime) { - return repository.getMileageEarningStatistics(startTime, endTime); - } - - @Override - public Object getDelayCompensationStatistics(LocalDateTime startTime, LocalDateTime endTime) { - return repository.getDelayCompensationStatistics(startTime, endTime); - } - - // SaveMileageEarningSchedulePort 구현 - - @Override - public MileageEarningSchedule save(MileageEarningSchedule schedule) { - return repository.save(schedule); - } - - @Override - public int updateStatusAtomically( - Long scheduleId, - MileageEarningSchedule.EarningStatus expectedStatus, - MileageEarningSchedule.EarningStatus newStatus) { - // 원자적 상태 변경으로 동시성 문제 방지 - return repository.updateStatusAtomically(scheduleId, expectedStatus, newStatus); - } - - @Override - public int updateWithTransactionAtomically( - Long scheduleId, - MileageEarningSchedule.EarningStatus expectedStatus, - MileageEarningSchedule.EarningStatus newStatus, - Long transactionId, - boolean isFullyCompleted) { - return repository.updateWithTransactionAtomically( - scheduleId, expectedStatus, newStatus, transactionId, isFullyCompleted - ); - } - - @Override - public List saveAll(List schedules) { - return repository.saveAll(schedules); - } - - @Override - public int deleteCompletedSchedulesBeforeTime(LocalDateTime beforeTime) { - return repository.deleteCompletedSchedulesBeforeTime(beforeTime); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/MileageTransactionPersistenceAdapter.java b/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/MileageTransactionPersistenceAdapter.java deleted file mode 100644 index 003009ed..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/MileageTransactionPersistenceAdapter.java +++ /dev/null @@ -1,230 +0,0 @@ -package com.sudo.railo.payment.infrastructure.adapter.out.persistence; - -import com.sudo.railo.payment.application.port.out.LoadMileageTransactionPort; -import com.sudo.railo.payment.application.port.out.SaveMileageTransactionPort; -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.infrastructure.persistence.JpaMileageTransactionRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * 마일리지 거래 영속성 어댑터 - * - * 애플리케이션 계층의 포트를 구현하여 실제 데이터베이스 접근을 담당 - * 헥사고날 아키텍처의 아웃바운드 어댑터 - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class MileageTransactionPersistenceAdapter implements LoadMileageTransactionPort, SaveMileageTransactionPort { - - private final JpaMileageTransactionRepository mileageTransactionRepository; - - @Override - public Optional findById(Long id) { - return mileageTransactionRepository.findById(id); - } - - @Override - public Optional findTopByMemberIdOrderByCreatedAtDesc(Long memberId) { - // Repository에는 이 메서드가 없으므로 대체 구현 - List transactions = mileageTransactionRepository.findByMemberIdOrderByCreatedAtDesc(memberId); - return transactions.isEmpty() ? Optional.empty() : Optional.of(transactions.get(0)); - } - - @Override - public List findByMemberIdAndType(Long memberId, MileageTransaction.TransactionType type) { - // Repository에는 이 메서드가 없으므로 대체 구현 - return mileageTransactionRepository.findByMemberId(memberId).stream() - .filter(t -> t.getType().equals(type)) - .toList(); - } - - @Override - public Page findByMemberId(Long memberId, Pageable pageable) { - return mileageTransactionRepository.findByMemberIdOrderByCreatedAtDesc(memberId, pageable); - } - - @Override - public BigDecimal calculateBalanceByMemberId(Long memberId) { - return mileageTransactionRepository.calculateCurrentBalance(memberId); - } - - @Override - public List findExpiredTransactionsByMemberIdAndDate(Long memberId, LocalDateTime date) { - // Repository에는 이 메서드가 없으므로 대체 구현 - return mileageTransactionRepository.findByMemberId(memberId).stream() - .filter(t -> t.getExpiresAt() != null && t.getExpiresAt().isBefore(date)) - .toList(); - } - - @Override - public BigDecimal sumEarnedPointsByMemberIdAndDateRange(Long memberId, LocalDateTime startDate, LocalDateTime endDate) { - return mileageTransactionRepository.calculateTotalEarnedInPeriod(memberId, startDate, endDate); - } - - @Override - public BigDecimal sumUsedPointsByMemberIdAndDateRange(Long memberId, LocalDateTime startDate, LocalDateTime endDate) { - return mileageTransactionRepository.calculateTotalUsedInPeriod(memberId, startDate, endDate); - } - - @Override - public Long countTransactionsByMemberIdAndType(Long memberId, MileageTransaction.TransactionType type) { - // Repository에는 이 메서드가 없으므로 대체 구현 - return (long) mileageTransactionRepository.findByMemberId(memberId).stream() - .filter(t -> t.getType().equals(type)) - .count(); - } - - @Override - public MileageTransaction save(MileageTransaction transaction) { - log.info("마일리지 거래 저장 시작 - 회원ID: {}, 타입: {}, 적립타입: {}, 금액: {}P, 상태: {}", - transaction.getMemberId(), - transaction.getType(), - transaction.getEarningType(), - transaction.getPointsAmount(), - transaction.getStatus()); - - MileageTransaction saved = mileageTransactionRepository.save(transaction); - - log.info("마일리지 거래 저장 완료 - 거래ID: {}, 스케줄ID: {}, 결제ID: {}", - saved.getId(), - saved.getEarningScheduleId(), - saved.getPaymentId()); - - return saved; - } - - @Override - public List findByPaymentId(String paymentId) { - return mileageTransactionRepository.findByPaymentId(paymentId); - } - - @Override - public List findByPaymentIds(List paymentIds) { - return mileageTransactionRepository.findByPaymentIds(paymentIds); - } - - @Override - public List findMileageUsageByPaymentId(String paymentId) { - return mileageTransactionRepository.findMileageUsageByPaymentId(paymentId); - } - - @Override - public List findByTrainScheduleId(Long trainScheduleId) { - return mileageTransactionRepository.findByTrainScheduleId(trainScheduleId); - } - - @Override - public List findByEarningScheduleId(Long earningScheduleId) { - return mileageTransactionRepository.findByEarningScheduleId(earningScheduleId); - } - - @Override - public Optional findBaseEarningByScheduleId(Long earningScheduleId) { - return mileageTransactionRepository.findBaseEarningByScheduleId(earningScheduleId); - } - - @Override - public Optional findDelayCompensationByScheduleId(Long earningScheduleId) { - return mileageTransactionRepository.findDelayCompensationByScheduleId(earningScheduleId); - } - - @Override - public List findByMemberIdAndEarningType(Long memberId, MileageTransaction.EarningType earningType) { - return mileageTransactionRepository.findByMemberIdAndEarningType(memberId, earningType); - } - - @Override - public BigDecimal calculateTotalDelayCompensationByMemberId(Long memberId) { - return mileageTransactionRepository.calculateTotalDelayCompensationByMemberId(memberId); - } - - @Override - public List findDelayCompensationTransactions(LocalDateTime startTime, LocalDateTime endTime) { - return mileageTransactionRepository.findDelayCompensationTransactions(startTime, endTime); - } - - @Override - public List getEarningTypeStatistics(LocalDateTime startTime, LocalDateTime endTime) { - return mileageTransactionRepository.getEarningTypeStatistics(startTime, endTime); - } - - @Override - public List getDelayCompensationStatisticsByDelayTime(LocalDateTime startTime, LocalDateTime endTime) { - return mileageTransactionRepository.getDelayCompensationStatisticsByDelayTime(startTime, endTime); - } - - @Override - public Page findTrainRelatedEarningsByMemberId(Long memberId, Pageable pageable) { - return mileageTransactionRepository.findTrainRelatedEarningsByMemberId(memberId, pageable); - } - - @Override - public List findAllEarningHistory(Long memberId) { - return mileageTransactionRepository.findAllEarningHistory(memberId); - } - - @Override - public BigDecimal calculateTotalMileageByTrainSchedule(Long trainScheduleId) { - return mileageTransactionRepository.calculateTotalMileageByTrainSchedule(trainScheduleId); - } - - @Override - public List findAllMileageTransactionsByPaymentId(String paymentId) { - return mileageTransactionRepository.findAllMileageTransactionsByPaymentId(paymentId); - } - - @Override - public List findPendingTransactionsBeforeTime(LocalDateTime beforeTime) { - return mileageTransactionRepository.findPendingTransactionsBeforeTime(beforeTime); - } - - @Override - public BigDecimal calculateTotalEarnedInPeriod(Long memberId, LocalDateTime startDate, LocalDateTime endDate) { - return mileageTransactionRepository.calculateTotalEarnedInPeriod(memberId, startDate, endDate); - } - - @Override - public BigDecimal calculateTotalUsedInPeriod(Long memberId, LocalDateTime startDate, LocalDateTime endDate) { - return mileageTransactionRepository.calculateTotalUsedInPeriod(memberId, startDate, endDate); - } - - @Override - public List findByMemberIdOrderByCreatedAtDesc(Long memberId) { - return mileageTransactionRepository.findByMemberIdOrderByCreatedAtDesc(memberId); - } - - @Override - public Page findByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable) { - return mileageTransactionRepository.findByMemberIdOrderByCreatedAtDesc(memberId, pageable); - } - - @Override - public List findEarningHistoryByTrainId(Long memberId, String trainId, LocalDateTime startDate, LocalDateTime endDate) { - return mileageTransactionRepository.findEarningHistoryByTrainId(memberId, trainId, startDate, endDate); - } - - @Override - public List findEarningHistoryByPeriod(Long memberId, LocalDateTime startDate, LocalDateTime endDate) { - return mileageTransactionRepository.findEarningHistoryByPeriod(memberId, startDate, endDate); - } - - @Override - public List findByMemberId(Long memberId) { - return mileageTransactionRepository.findByMemberId(memberId); - } - - @Override - public List findByPaymentIdOrderByCreatedAtDesc(String paymentId) { - return mileageTransactionRepository.findByPaymentIdOrderByCreatedAtDesc(paymentId); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/PaymentPersistenceAdapter.java b/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/PaymentPersistenceAdapter.java deleted file mode 100644 index c8e5001c..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/PaymentPersistenceAdapter.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.sudo.railo.payment.infrastructure.adapter.out.persistence; - -import com.sudo.railo.payment.application.port.out.LoadPaymentPort; -import com.sudo.railo.payment.application.port.out.SavePaymentPort; -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.repository.PaymentRepository; -import com.sudo.railo.payment.infrastructure.persistence.JpaPaymentRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.Optional; - -/** - * 결제 영속성 어댑터 - * - * 애플리케이션 계층의 포트를 구현하여 실제 데이터베이스 접근을 담당 - * 헥사고날 아키텍처의 아웃바운드 어댑터 - */ -@Component -@RequiredArgsConstructor -public class PaymentPersistenceAdapter implements LoadPaymentPort, SavePaymentPort { - - private final JpaPaymentRepository paymentRepository; - - @Override - public Optional findById(Long paymentId) { - return ((PaymentRepository) paymentRepository).findById(paymentId); - } - - @Override - public boolean existsByIdempotencyKey(String idempotencyKey) { - return paymentRepository.existsByIdempotencyKey(idempotencyKey); - } - - @Override - public Optional findByExternalOrderId(String externalOrderId) { - return paymentRepository.findByExternalOrderId(externalOrderId); - } - - @Override - public Optional findByReservationId(Long reservationId) { - return paymentRepository.findByReservationId(reservationId); - } - - @Override - public Payment save(Payment payment) { - return ((PaymentRepository) paymentRepository).save(payment); - } - - @Override - public Page findByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable) { - return paymentRepository.findByMemberIdOrderByCreatedAtDesc(memberId, pageable); - } - - @Override - public Page findByMemberIdAndCreatedAtBetweenOrderByCreatedAtDesc( - Long memberId, LocalDateTime startDate, LocalDateTime endDate, Pageable pageable) { - return paymentRepository.findByMemberIdAndCreatedAtBetweenOrderByCreatedAtDesc( - memberId, startDate, endDate, pageable); - } - - @Override - public Page findByNonMemberInfo(String name, String phoneNumber, Pageable pageable) { - return paymentRepository.findByNonMemberNameAndNonMemberPhoneOrderByCreatedAtDesc( - name, phoneNumber, pageable); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/RefundCalculationPersistenceAdapter.java b/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/RefundCalculationPersistenceAdapter.java deleted file mode 100644 index 4da693ed..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/persistence/RefundCalculationPersistenceAdapter.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.sudo.railo.payment.infrastructure.adapter.out.persistence; - -import com.sudo.railo.payment.application.port.out.LoadRefundCalculationPort; -import com.sudo.railo.payment.application.port.out.SaveRefundCalculationPort; -import com.sudo.railo.payment.domain.entity.RefundCalculation; -import com.sudo.railo.payment.domain.repository.RefundCalculationRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; - -/** - * 환불 계산 영속성 어댑터 - * - * 애플리케이션 계층의 포트를 구현하여 실제 데이터베이스 접근을 담당 - * 헥사고날 아키텍처의 아웃바운드 어댑터 - */ -@Component -@RequiredArgsConstructor -public class RefundCalculationPersistenceAdapter implements LoadRefundCalculationPort, SaveRefundCalculationPort { - - private final RefundCalculationRepository refundCalculationRepository; - - @Override - public Optional findByPaymentId(Long paymentId) { - return refundCalculationRepository.findByPaymentId(paymentId); - } - - @Override - public List findByPaymentIds(List paymentIds) { - return refundCalculationRepository.findByPaymentIds(paymentIds); - } - - @Override - public List findByMemberId(Long memberId) { - return refundCalculationRepository.findByMemberId(memberId); - } - - @Override - public RefundCalculation save(RefundCalculation refundCalculation) { - return refundCalculationRepository.save(refundCalculation); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/train/TrainScheduleAdapter.java b/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/train/TrainScheduleAdapter.java deleted file mode 100644 index 2344daba..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/adapter/out/train/TrainScheduleAdapter.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.sudo.railo.payment.infrastructure.adapter.out.train; - -import com.sudo.railo.payment.application.port.out.LoadTrainSchedulePort; -import com.sudo.railo.train.application.TrainScheduleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; - -/** - * 열차 스케줄 정보 어댑터 - * - * 헥사고날 아키텍처의 어댑터로, Train 도메인과의 - * 통신을 담당합니다. - */ -@Component -@RequiredArgsConstructor -public class TrainScheduleAdapter implements LoadTrainSchedulePort { - - private final TrainScheduleService trainScheduleService; - - @Override - public String getRouteInfo(Long trainScheduleId) { - return trainScheduleService.getRouteInfo(trainScheduleId); - } - - @Override - public LocalDateTime getActualArrivalTime(Long trainScheduleId) { - // TrainScheduleService에서 실제 도착 시간을 조회하는 메서드가 필요 - // 현재는 예정 도착 시간을 반환하도록 임시 구현 - // TODO: TrainScheduleService에 getActualArrivalTime 메서드 추가 필요 - return getScheduledArrivalTime(trainScheduleId); - } - - @Override - public LocalDateTime getScheduledArrivalTime(Long trainScheduleId) { - // TrainScheduleService에서 예정 도착 시간을 조회하는 메서드가 필요 - // TODO: TrainScheduleService에 getScheduledArrivalTime 메서드 추가 필요 - return LocalDateTime.now().plusHours(3); // 임시 구현 - } - - @Override - public int getDelayMinutes(Long trainScheduleId) { - // TrainScheduleService에서 지연 시간을 조회하는 메서드가 필요 - // TODO: TrainScheduleService에 getDelayMinutes 메서드 추가 필요 - return 0; // 임시 구현 - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/client/MockPgApiClient.java b/src/main/java/com/sudo/railo/payment/infrastructure/client/MockPgApiClient.java deleted file mode 100644 index f01912c6..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/client/MockPgApiClient.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.sudo.railo.payment.infrastructure.client; - -import com.sudo.railo.payment.domain.entity.PaymentCalculation; -import com.sudo.railo.payment.domain.repository.PaymentCalculationRepository; -import com.sudo.railo.payment.exception.PaymentValidationException; -import com.sudo.railo.payment.infrastructure.client.dto.PgVerificationResult; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * Mock PG API 클라이언트 - * 개발/테스트 환경에서 사용 - * - * TODO: 운영 환경에서는 실제 PG사 API 연동 구현 필요 - * - 실제 API 인증 토큰 관리 - * - API 호출 및 서명 검증 - * - 에러 처리 및 재시도 로직 - * - 타임아웃 처리 - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class MockPgApiClient implements PgApiClient { - - private final PaymentCalculationRepository calculationRepository; - - @Override - public PgVerificationResult verifyPayment(String authNumber, String orderId) { - log.warn("⚠️ Mock PG API 사용 중 - 운영 환경에서는 실제 PG 연동 필요"); - log.info("Mock PG 검증 요청: authNumber={}, orderId={}", authNumber, orderId); - - // Mock 승인번호 검증 (형식만 체크) - if (!authNumber.startsWith("MOCK-")) { - throw new PaymentValidationException("Mock 환경에서는 MOCK- 접두사 필요"); - } - - // 실제처럼 동작하기 위해 계산 세션에서 금액 조회 - PaymentCalculation calculation = calculationRepository.findByPgOrderId(orderId) - .orElseThrow(() -> new PaymentValidationException("유효하지 않은 주문번호")); - - // Mock 응답 생성 (항상 승인) - // TODO: 실제 PG 연동 시 아래 로직을 실제 API 호출로 변경 - PgVerificationResult result = PgVerificationResult.builder() - .success(true) - .amount(calculation.getFinalAmount()) // 계산된 금액 그대로 반환 - .authNumber(authNumber) - .approvedAt(LocalDateTime.now()) - .cardNumber("****-****-****-1234") // Mock 카드번호 - .cardType("신용카드") - .build(); - - log.info("Mock PG 검증 성공: amount={}, authNumber={}", - result.getAmount(), result.getAuthNumber()); - - return result; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/client/PgApiClient.java b/src/main/java/com/sudo/railo/payment/infrastructure/client/PgApiClient.java deleted file mode 100644 index 0eb9e4e8..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/client/PgApiClient.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.sudo.railo.payment.infrastructure.client; - -import com.sudo.railo.payment.infrastructure.client.dto.PgVerificationResult; - -/** - * PG사 API 클라이언트 인터페이스 - */ -public interface PgApiClient { - - /** - * PG 승인번호 검증 - * - * @param authNumber PG 승인번호 - * @param orderId 주문번호 - * @return 검증 결과 - */ - PgVerificationResult verifyPayment(String authNumber, String orderId); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/client/dto/PgVerificationResult.java b/src/main/java/com/sudo/railo/payment/infrastructure/client/dto/PgVerificationResult.java deleted file mode 100644 index 9fcf9be9..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/client/dto/PgVerificationResult.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.sudo.railo.payment.infrastructure.client.dto; - -import lombok.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * PG 검증 결과 DTO - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@ToString -public class PgVerificationResult { - - private boolean success; - private BigDecimal amount; - private String authNumber; - private LocalDateTime approvedAt; - private String message; - private String cardNumber; // 마스킹된 카드번호 - private String cardType; - - // 성공 결과 생성 - public static PgVerificationResult success(BigDecimal amount, String authNumber) { - return PgVerificationResult.builder() - .success(true) - .amount(amount) - .authNumber(authNumber) - .approvedAt(LocalDateTime.now()) - .build(); - } - - // 실패 결과 생성 - public static PgVerificationResult fail(String message) { - return PgVerificationResult.builder() - .success(false) - .message(message) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/config/PgConfig.java b/src/main/java/com/sudo/railo/payment/infrastructure/config/PgConfig.java deleted file mode 100644 index 50870f55..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/config/PgConfig.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.sudo.railo.payment.infrastructure.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; -import org.springframework.http.client.SimpleClientHttpRequestFactory; - -/** - * PG 연동을 위한 설정 - */ -@Configuration -public class PgConfig { - - /** - * PG API 호출용 RestTemplate - */ - @Bean - public RestTemplate restTemplate() { - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - factory.setConnectTimeout(10000); // 10초 - factory.setReadTimeout(30000); // 30초 - - return new RestTemplate(factory); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/PgPaymentGateway.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/PgPaymentGateway.java deleted file mode 100644 index 5023a507..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/PgPaymentGateway.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; - -/** - * PG(Payment Gateway) 연동 인터페이스 - * - * Strategy 패턴으로 각 PG사별 구현체를 제공 - * 현재는 Mock 구현으로 실제 PG API 호출 없이 시뮬레이션 - */ -public interface PgPaymentGateway { - - /** - * 지원하는 결제 수단인지 확인 - * - * @param paymentMethod 결제 수단 - * @return 지원 여부 - */ - boolean supports(PaymentMethod paymentMethod); - - /** - * 결제 요청 - * - * @param request 결제 요청 정보 - * @return 결제 응답 - */ - PgPaymentResponse requestPayment(PgPaymentRequest request); - - /** - * 결제 승인 - * - * @param pgTransactionId PG 트랜잭션 ID - * @param merchantOrderId 가맹점 주문 ID - * @return 결제 승인 응답 - */ - PgPaymentResponse approvePayment(String pgTransactionId, String merchantOrderId); - - /** - * 결제 취소/환불 - * - * @param request 취소 요청 정보 - * @return 취소 응답 - */ - PgPaymentCancelResponse cancelPayment(PgPaymentCancelRequest request); - - /** - * 결제 상태 조회 - * - * @param pgTransactionId PG 트랜잭션 ID - * @return 결제 상태 응답 - */ - PgPaymentResponse getPaymentStatus(String pgTransactionId); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/PgPaymentService.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/PgPaymentService.java deleted file mode 100644 index 78cea8e8..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/PgPaymentService.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.List; - -/** - * PG 연동 통합 서비스 - * Strategy 패턴으로 각 PG사별 Gateway를 관리 - */ -@Slf4j -@Service("infrastructurePgPaymentService") -@RequiredArgsConstructor -public class PgPaymentService { - - private final List pgGateways; - - /** - * 결제 수단에 맞는 PG Gateway 조회 - */ - private PgPaymentGateway getGateway(PaymentMethod paymentMethod) { - return pgGateways.stream() - .filter(gateway -> gateway.supports(paymentMethod)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 결제 수단: " + paymentMethod)); - } - - /** - * 결제 요청 - */ - public PgPaymentResponse requestPayment(PaymentMethod paymentMethod, PgPaymentRequest request) { - log.debug("PG 결제 요청: method={}, orderId={}", paymentMethod, request.getMerchantOrderId()); - - PgPaymentGateway gateway = getGateway(paymentMethod); - return gateway.requestPayment(request); - } - - /** - * 결제 승인 - */ - public PgPaymentResponse approvePayment(PaymentMethod paymentMethod, String pgTransactionId, String merchantOrderId) { - log.debug("PG 결제 승인: method={}, tid={}, orderId={}", paymentMethod, pgTransactionId, merchantOrderId); - - PgPaymentGateway gateway = getGateway(paymentMethod); - return gateway.approvePayment(pgTransactionId, merchantOrderId); - } - - /** - * 결제 취소 - */ - public PgPaymentCancelResponse cancelPayment(PaymentMethod paymentMethod, PgPaymentCancelRequest request) { - log.debug("PG 결제 취소: method={}, tid={}", paymentMethod, request.getPgTransactionId()); - - PgPaymentGateway gateway = getGateway(paymentMethod); - return gateway.cancelPayment(request); - } - - /** - * 결제 상태 조회 - */ - public PgPaymentResponse getPaymentStatus(PaymentMethod paymentMethod, String pgTransactionId) { - log.debug("PG 결제 상태 조회: method={}, tid={}", paymentMethod, pgTransactionId); - - PgPaymentGateway gateway = getGateway(paymentMethod); - return gateway.getPaymentStatus(pgTransactionId); - } - - /** - * 외부 PG 연동이 필요한 결제 수단인지 확인 - */ - public boolean requiresExternalPg(PaymentMethod paymentMethod) { - return paymentMethod.requiresExternalPg(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentCancelRequest.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentCancelRequest.java deleted file mode 100644 index 37503a4f..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentCancelRequest.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; -import java.util.Map; - -/** - * PG 결제 취소/환불 요청 DTO - * - * Mock PG 시스템에서 사용하는 결제 취소 및 환불 요청 정보 - * 실제 PG 연동 시 각 PG사별 요구사항에 맞게 수정 필요 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PgPaymentCancelRequest { - - /** - * 원본 PG 트랜잭션 ID - */ - private String pgTransactionId; - - /** - * 가맹점 주문 ID - */ - private String merchantOrderId; - - /** - * 취소/환불 금액 - */ - private BigDecimal cancelAmount; - - /** - * 취소/환불 사유 - */ - private String cancelReason; - - /** - * 취소 타입 (FULL: 전체 취소) - */ - private String cancelType; - - /** - * 환불 계좌 정보 (계좌 환불 시) - */ - private String refundBankCode; - private String refundAccountNumber; - private String refundAccountHolder; - - /** - * 요청자 정보 - */ - private String requesterName; - private String requesterPhone; - - /** - * 추가 정보 (각 PG사별 특수 요구사항) - */ - private Map additionalInfo; - - /** - * 요청자 ID 또는 식별자 - */ - private String requestedBy; - - /** - * 전체 취소 요청 생성 헬퍼 메서드 - */ - public static PgPaymentCancelRequest fullCancel(String pgTransactionId, String merchantOrderId, - BigDecimal amount, String reason) { - return PgPaymentCancelRequest.builder() - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .cancelAmount(amount) - .cancelReason(reason) - .cancelType("FULL") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentCancelResponse.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentCancelResponse.java deleted file mode 100644 index c9c04b34..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentCancelResponse.java +++ /dev/null @@ -1,171 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Map; - -/** - * PG 결제 취소/환불 응답 DTO - * - * Mock PG 시스템에서 반환하는 결제 취소 및 환불 결과 정보 - * 실제 PG 연동 시 각 PG사별 응답 형식에 맞게 수정 필요 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PgPaymentCancelResponse { - - /** - * 취소/환불 성공 여부 - */ - private boolean success; - - /** - * 원본 PG 트랜잭션 ID - */ - private String pgTransactionId; - - /** - * 취소 트랜잭션 ID - */ - private String cancelTransactionId; - - /** - * 취소 승인 번호 - */ - private String cancelApprovalNo; - - /** - * 가맹점 주문 ID - */ - private String merchantOrderId; - - /** - * 취소/환불 금액 - */ - private BigDecimal cancelAmount; - - /** - * 잔여 금액 (전체 취소 시 0) - */ - private BigDecimal remainingAmount; - - /** - * 취소 상태 (CANCELLED, REFUNDED, FAILED) - */ - private String cancelStatus; - - /** - * 취소 타입 (FULL: 전체 취소) - */ - private String cancelType; - - /** - * 취소/환불 일시 - */ - private LocalDateTime cancelDateTime; - - /** - * 환불 예정일 (계좌 환불 시) - */ - private LocalDateTime refundScheduledDate; - - /** - * 응답 메시지 - */ - private String message; - - /** - * 오류 코드 (실패 시) - */ - private String errorCode; - - /** - * 오류 메시지 (실패 시) - */ - private String errorMessage; - - /** - * 취소 사유 - */ - private String cancelReason; - - /** - * 환불 수수료 - */ - private BigDecimal refundFee; - - /** - * 실제 환불 금액 (환불금액 - 수수료) - */ - private BigDecimal actualRefundAmount; - - /** - * 추가 응답 정보 (각 PG사별 특수 정보) - */ - private Map additionalInfo; - - /** - * 취소 승인 번호 (cancelApprovalNo의 alias) - */ - private String cancelApprovalNumber; - - /** - * 취소 처리 일시 (cancelDateTime의 alias) - */ - private LocalDateTime canceledAt; - - /** - * getCancelApprovalNumber 메서드 - 기존 cancelApprovalNo 반환 - */ - public String getCancelApprovalNumber() { - return this.cancelApprovalNo != null ? this.cancelApprovalNo : this.cancelApprovalNumber; - } - - /** - * 성공 응답 생성 헬퍼 메서드 (전체 취소) - */ - public static PgPaymentCancelResponse success(String pgTransactionId, String cancelTransactionId, - String cancelApprovalNo, String merchantOrderId, - BigDecimal cancelAmount, String cancelReason) { - return PgPaymentCancelResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .cancelTransactionId(cancelTransactionId) - .cancelApprovalNo(cancelApprovalNo) - .merchantOrderId(merchantOrderId) - .cancelAmount(cancelAmount) - .remainingAmount(BigDecimal.ZERO) - .cancelStatus("CANCELLED") - .cancelType("FULL") - .cancelDateTime(LocalDateTime.now()) - .cancelReason(cancelReason) - .refundFee(BigDecimal.ZERO) - .actualRefundAmount(cancelAmount) - .message("취소가 성공적으로 처리되었습니다") - .build(); - } - - /** - * 실패 응답 생성 헬퍼 메서드 - */ - public static PgPaymentCancelResponse failure(String pgTransactionId, String merchantOrderId, - String errorCode, String errorMessage) { - return PgPaymentCancelResponse.builder() - .success(false) - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .cancelStatus("FAILED") - .cancelDateTime(LocalDateTime.now()) - .errorCode(errorCode) - .errorMessage(errorMessage) - .message("취소 처리에 실패했습니다") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentRequest.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentRequest.java deleted file mode 100644 index 1d03338e..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentRequest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.dto; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; -import java.util.Map; - -/** - * PG 결제 요청 DTO - * - * Mock PG 시스템에서 사용하는 결제 요청 정보 - * 실제 PG 연동 시 각 PG사별 요구사항에 맞게 수정 필요 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PgPaymentRequest { - - /** - * 가맹점 주문 ID (Payment ID 사용) - */ - private String merchantOrderId; - - /** - * 결제 금액 - */ - private BigDecimal amount; - - /** - * 결제 수단 타입 (CREDIT_CARD, BANK_TRANSFER, MOBILE) - */ - private String paymentMethodType; - - /** - * PG 제공자 (NICE_PAY, TOSS_PAYMENTS, KG_INICIS) - */ - private String pgProvider; - - /** - * PG 토큰 (프론트엔드에서 받은 결제 토큰) - */ - private String pgToken; - - /** - * 주문명 - */ - private String orderName; - - /** - * 구매자 정보 - */ - private String buyerName; - private String buyerEmail; - private String buyerPhone; - - /** - * 현금영수증 정보 - */ - private boolean cashReceiptRequested; - private String cashReceiptType; - private String cashReceiptNumber; - - /** - * 추가 정보 (각 PG사별 특수 요구사항) - */ - private Map additionalInfo; - - /** - * 성공 리다이렉트 URL - */ - private String successUrl; - - /** - * 실패 리다이렉트 URL - */ - private String failUrl; - - /** - * 취소 리다이렉트 URL - */ - private String cancelUrl; - - /** - * 상품명 - */ - private String productName; - - /** - * 결제 방법 (PaymentMethod enum) - */ - private PaymentMethod paymentMethod; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentResponse.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentResponse.java deleted file mode 100644 index f19a0df1..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/dto/PgPaymentResponse.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Map; - -/** - * PG 결제 응답 DTO - * - * Mock PG 시스템에서 반환하는 결제 결과 정보 - * 실제 PG 연동 시 각 PG사별 응답 형식에 맞게 수정 필요 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PgPaymentResponse { - - /** - * 결제 성공 여부 - */ - private boolean success; - - /** - * PG 트랜잭션 ID - */ - private String pgTransactionId; - - /** - * PG 승인 번호 - */ - private String pgApprovalNo; - - /** - * 가맹점 주문 ID - */ - private String merchantOrderId; - - /** - * 결제 금액 - */ - private BigDecimal amount; - - /** - * 결제 상태 (SUCCESS, FAILED, PENDING, CANCELLED) - */ - private String paymentStatus; - - /** - * 결제 수단 정보 - */ - private String paymentMethodType; - private String cardCompany; - private String cardNumber; // 마스킹된 카드번호 - private String installmentPlan; - - /** - * 결제 일시 - */ - private LocalDateTime paymentDateTime; - - /** - * 응답 메시지 - */ - private String message; - - /** - * 오류 코드 (실패 시) - */ - private String errorCode; - - /** - * 오류 메시지 (실패 시) - */ - private String errorMessage; - - /** - * 현금영수증 정보 - */ - private String cashReceiptUrl; - private String cashReceiptApprovalNo; - - /** - * 추가 응답 정보 (각 PG사별 특수 정보) - */ - private Map additionalInfo; - - /** - * 결제 상태 코드 (READY, SUCCESS, FAILED 등) - */ - private String status; - - /** - * 결제 진행 URL (결제창 리다이렉트 URL) - */ - private String paymentUrl; - - /** - * 승인 번호 (pgApprovalNo의 alias) - */ - private String approvalNumber; - - /** - * 승인 일시 - */ - private LocalDateTime approvedAt; - - /** - * PG사 원본 응답 데이터 - */ - private String rawResponse; - - /** - * 성공 응답 생성 헬퍼 메서드 - */ - public static PgPaymentResponse success(String pgTransactionId, String pgApprovalNo, - String merchantOrderId, BigDecimal amount) { - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .pgApprovalNo(pgApprovalNo) - .merchantOrderId(merchantOrderId) - .amount(amount) - .paymentStatus("SUCCESS") - .paymentDateTime(LocalDateTime.now()) - .message("결제가 성공적으로 처리되었습니다") - .build(); - } - - /** - * 실패 응답 생성 헬퍼 메서드 - */ - public static PgPaymentResponse failure(String merchantOrderId, String errorCode, String errorMessage) { - return PgPaymentResponse.builder() - .success(false) - .merchantOrderId(merchantOrderId) - .paymentStatus("FAILED") - .paymentDateTime(LocalDateTime.now()) - .errorCode(errorCode) - .errorMessage(errorMessage) - .message("결제 처리에 실패했습니다") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/KakaoPayGateway.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/KakaoPayGateway.java deleted file mode 100644 index 5bc1e939..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/KakaoPayGateway.java +++ /dev/null @@ -1,232 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.kakaopay; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.PgPaymentGateway; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; -import com.sudo.railo.payment.infrastructure.external.pg.kakaopay.dto.*; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -import java.time.LocalDateTime; - -/** - * 카카오페이 API 연동 Gateway - */ -@Slf4j -@Component -@RequiredArgsConstructor -@Profile("never") // 실제 PG 연동은 사용하지 않음 -public class KakaoPayGateway implements PgPaymentGateway { - - private final RestTemplate restTemplate; - - @Value("${payment.kakaopay.admin-key}") - private String adminKey; - - @Value("${payment.kakaopay.cid}") - private String cid; - - @Value("${payment.kakaopay.ready-url}") - private String readyUrl; - - @Value("${payment.kakaopay.approve-url}") - private String approveUrl; - - @Value("${payment.kakaopay.cancel-url}") - private String cancelUrl; - - @Value("${payment.kakaopay.order-url}") - private String orderUrl; - - @Override - public boolean supports(PaymentMethod paymentMethod) { - return PaymentMethod.KAKAO_PAY.equals(paymentMethod); - } - - @Override - public PgPaymentResponse requestPayment(PgPaymentRequest request) { - log.debug("[카카오페이] 결제 요청: orderId={}, amount={}", request.getMerchantOrderId(), request.getAmount()); - - try { - // 카카오페이 결제 준비 요청 - KakaoPayReadyRequest kakaoRequest = KakaoPayReadyRequest.builder() - .cid(cid) - .partnerOrderId(request.getMerchantOrderId()) - .partnerUserId(request.getBuyerEmail()) - .itemName(request.getProductName()) - .quantity(1) - .totalAmount(request.getAmount().intValue()) - .taxFreeAmount(0) - .approvalUrl("http://localhost:3001/payment/kakao/success") // 프론트엔드 URL - .cancelUrl("http://localhost:3001/payment/kakao/cancel") - .failUrl("http://localhost:3001/payment/kakao/fail") - .build(); - - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", adminKey); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - // MultiValueMap으로 변환 (카카오페이는 form-data 방식) - MultiValueMap params = convertToFormData(kakaoRequest); - - HttpEntity> entity = new HttpEntity<>(params, headers); - - ResponseEntity response = restTemplate.postForEntity( - readyUrl, entity, KakaoPayReadyResponse.class); - - if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { - KakaoPayReadyResponse kakaoResponse = response.getBody(); - - log.debug("[카카오페이] 결제 준비 성공: tid={}, paymentUrl={}", - kakaoResponse.getTid(), kakaoResponse.getNextRedirectPcUrl()); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(kakaoResponse.getTid()) - .amount(request.getAmount()) - .status("READY") - .paymentUrl(kakaoResponse.getNextRedirectPcUrl()) // PC용 URL - .build(); - } else { - log.error("[카카오페이] 결제 준비 실패: status={}", response.getStatusCode()); - return createErrorResponse("카카오페이 결제 준비 실패"); - } - - } catch (Exception e) { - log.error("[카카오페이] 결제 요청 중 오류 발생", e); - return createErrorResponse("카카오페이 연동 오류: " + e.getMessage()); - } - } - - @Override - public PgPaymentResponse approvePayment(String pgTransactionId, String merchantOrderId) { - log.debug("[카카오페이] 결제 승인: tid={}, orderId={}", pgTransactionId, merchantOrderId); - - // 카카오페이는 프론트엔드에서 pg_token을 받아서 처리해야 하지만 - // 기본 인터페이스에 맞춰 간단한 승인 처리 - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .status("SUCCESS") - .approvalNumber("REAL_" + System.currentTimeMillis()) - .approvedAt(LocalDateTime.now()) - .build(); - } - - @Override - public PgPaymentCancelResponse cancelPayment(PgPaymentCancelRequest request) { - log.debug("[카카오페이] 결제 취소: tid={}, amount={}", request.getPgTransactionId(), request.getCancelAmount()); - - try { - KakaoPayCancelRequest kakaoRequest = KakaoPayCancelRequest.builder() - .cid(cid) - .tid(request.getPgTransactionId()) - .cancelAmount(request.getCancelAmount().intValue()) - .cancelTaxFreeAmount(0) - .build(); - - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", adminKey); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - MultiValueMap params = convertToFormData(kakaoRequest); - HttpEntity> entity = new HttpEntity<>(params, headers); - - ResponseEntity response = restTemplate.postForEntity( - cancelUrl, entity, KakaoPayCancelResponse.class); - - if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { - KakaoPayCancelResponse kakaoResponse = response.getBody(); - - log.debug("[카카오페이] 결제 취소 성공: tid={}", kakaoResponse.getTid()); - - return PgPaymentCancelResponse.builder() - .success(true) - .pgTransactionId(kakaoResponse.getTid()) - .cancelAmount(request.getCancelAmount()) - .cancelApprovalNumber(kakaoResponse.getAid()) - .canceledAt(LocalDateTime.now()) - .build(); - } else { - log.error("[카카오페이] 결제 취소 실패: status={}", response.getStatusCode()); - return PgPaymentCancelResponse.builder() - .success(false) - .errorCode("CANCEL_FAILED") - .errorMessage("카카오페이 결제 취소 실패") - .build(); - } - - } catch (Exception e) { - log.error("[카카오페이] 결제 취소 중 오류 발생", e); - return PgPaymentCancelResponse.builder() - .success(false) - .errorCode("CANCEL_ERROR") - .errorMessage("카카오페이 취소 오류: " + e.getMessage()) - .build(); - } - } - - @Override - public PgPaymentResponse getPaymentStatus(String pgTransactionId) { - log.debug("[카카오페이] 결제 상태 조회: tid={}", pgTransactionId); - - // 카카오페이는 별도 상태 조회 API가 없어서 간단한 응답 반환 - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .status("INQUIRY_SUCCESS") - .build(); - } - - // === Private Methods === - - private PgPaymentResponse createErrorResponse(String errorMessage) { - return PgPaymentResponse.builder() - .success(false) - .errorMessage(errorMessage) - .status("ERROR") - .build(); - } - - // 카카오페이 API 요청을 위한 form-data 변환 - private MultiValueMap convertToFormData(KakaoPayReadyRequest request) { - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add("cid", request.getCid()); - params.add("partner_order_id", request.getPartnerOrderId()); - params.add("partner_user_id", request.getPartnerUserId()); - params.add("item_name", request.getItemName()); - params.add("quantity", String.valueOf(request.getQuantity())); - params.add("total_amount", String.valueOf(request.getTotalAmount())); - params.add("tax_free_amount", String.valueOf(request.getTaxFreeAmount())); - params.add("approval_url", request.getApprovalUrl()); - params.add("cancel_url", request.getCancelUrl()); - params.add("fail_url", request.getFailUrl()); - return params; - } - - private MultiValueMap convertToFormData(KakaoPayApproveRequest request) { - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add("cid", request.getCid()); - params.add("tid", request.getTid()); - params.add("partner_order_id", request.getPartnerOrderId()); - params.add("partner_user_id", request.getPartnerUserId()); - params.add("pg_token", request.getPgToken()); - return params; - } - - private MultiValueMap convertToFormData(KakaoPayCancelRequest request) { - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add("cid", request.getCid()); - params.add("tid", request.getTid()); - params.add("cancel_amount", String.valueOf(request.getCancelAmount())); - params.add("cancel_tax_free_amount", String.valueOf(request.getCancelTaxFreeAmount())); - return params; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayApproveRequest.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayApproveRequest.java deleted file mode 100644 index 5bdd0512..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayApproveRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.kakaopay.dto; - -import lombok.Builder; -import lombok.Data; - -@Data -@Builder -public class KakaoPayApproveRequest { - private String cid; - private String tid; - private String partnerOrderId; - private String partnerUserId; - private String pgToken; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayApproveResponse.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayApproveResponse.java deleted file mode 100644 index 41cb9821..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayApproveResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.kakaopay.dto; - -import lombok.Data; -import java.math.BigDecimal; -import java.time.LocalDateTime; - -@Data -public class KakaoPayApproveResponse { - private String aid; - private String tid; - private String cid; - private String partnerOrderId; - private String partnerUserId; - private String paymentMethodType; - private AmountInfo amount; - private LocalDateTime approvedAt; - - @Data - public static class AmountInfo { - private BigDecimal total; - private BigDecimal taxFree; - private BigDecimal vat; - private BigDecimal point; - private BigDecimal discount; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayCancelRequest.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayCancelRequest.java deleted file mode 100644 index 3f69311e..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayCancelRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.kakaopay.dto; - -import lombok.Builder; -import lombok.Data; - -/** - * 카카오페이 Cancel 요청 DTO - */ -@Data -@Builder -public class KakaoPayCancelRequest { - - private String cid; // 가맹점 코드 - private String tid; // 결제 고유번호 - private Integer cancelAmount; // 취소 금액 - private Integer cancelTaxFreeAmount; // 취소 비과세 금액 -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayCancelResponse.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayCancelResponse.java deleted file mode 100644 index 34e01ae2..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayCancelResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.kakaopay.dto; - -import lombok.Data; - -/** - * 카카오페이 Cancel 응답 DTO - */ -@Data -public class KakaoPayCancelResponse { - - private String aid; // 요청 고유 번호 - private String tid; // 결제 고유 번호 - private String cid; // 가맹점 코드 - private String status; // 결제 상태 - private String partnerOrderId; // 가맹점 주문번호 - private String partnerUserId; // 가맹점 회원 id - private String paymentMethodType; // 결제 수단 - private Amount amount; // 결제 금액 정보 - private String createdAt; // 결제 준비 요청 시각 - private String approvedAt; // 결제 승인 시각 - private String canceledAt; // 결제 취소 시각 - - @Data - public static class Amount { - private Integer total; // 전체 결제 금액 - private Integer taxFree; // 비과세 금액 - private Integer vat; // 부가세 금액 - private Integer point; // 사용한 포인트 - private Integer discount; // 할인금액 - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayOrderRequest.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayOrderRequest.java deleted file mode 100644 index e1abf866..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayOrderRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.kakaopay.dto; - -import lombok.Builder; -import lombok.Data; - -@Data -@Builder -public class KakaoPayOrderRequest { - private String cid; - private String tid; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayOrderResponse.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayOrderResponse.java deleted file mode 100644 index 1af22f6a..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayOrderResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.kakaopay.dto; - -import lombok.Data; - -@Data -public class KakaoPayOrderResponse { - private String tid; - private String cid; - private String status; - private String partnerOrderId; - private String partnerUserId; - private String paymentMethodType; - private KakaoPayApproveResponse.AmountInfo amount; - private KakaoPayApproveResponse.AmountInfo canceledAmount; - private KakaoPayApproveResponse.AmountInfo cancelAvailableAmount; - private String itemName; - private String itemCode; - private Integer quantity; - private String createdAt; - private String approvedAt; - private String canceledAt; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayReadyRequest.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayReadyRequest.java deleted file mode 100644 index 6d72198e..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayReadyRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.kakaopay.dto; - -import lombok.Builder; -import lombok.Data; - -/** - * 카카오페이 Ready 요청 DTO - */ -@Data -@Builder -public class KakaoPayReadyRequest { - private String cid; // 가맹점 코드 - private String partnerOrderId; // 가맹점 주문번호 - private String partnerUserId; // 가맹점 회원 id - private String itemName; // 상품명 - private Integer quantity; // 상품 수량 - private Integer totalAmount; // 상품 총액 - private Integer vatAmount; // 상품 부가세금액 - private Integer taxFreeAmount; // 상품 비과세금액 - private String approvalUrl; // 결제성공시 redirect url - private String cancelUrl; // 결제취소시 redirect url - private String failUrl; // 결제실패시 redirect url -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayReadyResponse.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayReadyResponse.java deleted file mode 100644 index 7f8748be..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/kakaopay/dto/KakaoPayReadyResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.kakaopay.dto; - -import lombok.Data; - -/** - * 카카오페이 Ready 응답 DTO - */ -@Data -public class KakaoPayReadyResponse { - private String tid; // 결제 고유번호 - private String nextRedirectAppUrl; // 요청한 클라이언트가 모바일 앱일 경우 - private String nextRedirectMobileUrl; // 요청한 클라이언트가 모바일 웹일 경우 - private String nextRedirectPcUrl; // 요청한 클라이언트가 PC 웹일 경우 - private String androidAppScheme; // 카카오페이 결제화면으로 이동하는 Android 앱 스킴 - private String iosAppScheme; // 카카오페이 결제화면으로 이동하는 iOS 앱 스킴 - private String createdAt; // 결제 준비 요청 시각 -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockBankAccountGateway.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockBankAccountGateway.java deleted file mode 100644 index ac93d514..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockBankAccountGateway.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.mock; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.PgPaymentGateway; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * 은행 계좌 결제(내 통장 결제) Mock Gateway - * 운영 환경에서는 각 은행의 오픈뱅킹 API와 연동 - */ -@Slf4j -@Component -public class MockBankAccountGateway implements PgPaymentGateway { - - @Override - public boolean supports(PaymentMethod paymentMethod) { - return paymentMethod == PaymentMethod.BANK_ACCOUNT; - } - - @Override - public PgPaymentResponse requestPayment(PgPaymentRequest request) { - log.debug("[Mock 은행계좌] 결제 요청: orderId={}, amount={}", - request.getMerchantOrderId(), request.getAmount()); - - String mockPaymentId = "B" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); - - // Mock 은행계좌는 바로 승인 처리 (운영에서는 은행 앱으로 리다이렉트) - log.debug("[Mock 은행계좌] Mock 결제 - 리다이렉트 없이 바로 승인 처리"); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(mockPaymentId) - .merchantOrderId(request.getMerchantOrderId()) - .amount(request.getAmount()) - .status("READY") - .paymentUrl(null) // null이면 바로 승인 처리 - .rawResponse("Mock Bank Account Response") - .build(); - } - - @Override - public PgPaymentResponse approvePayment(String pgTransactionId, String merchantOrderId) { - log.debug("[Mock 은행계좌] 결제 승인: tid={}, orderId={}", pgTransactionId, merchantOrderId); - - // Mock 환경에서는 항상 성공 처리 - boolean isSuccess = true; - - if (isSuccess) { - log.debug("[Mock 은행계좌] 결제 승인 성공: tid={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .status("SUCCESS") - .approvedAt(LocalDateTime.now()) - .rawResponse("Mock Bank Account Approval Success") - .build(); - } else { - log.warn("[Mock 은행계좌] 결제 승인 실패: tid={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(false) - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .status("FAILED") - .errorCode("BANK_ACCOUNT_INVALID") - .errorMessage("계좌번호 또는 비밀번호가 올바르지 않습니다.") - .rawResponse("Mock Bank Account Approval Failed") - .build(); - } - } - - @Override - public PgPaymentCancelResponse cancelPayment(PgPaymentCancelRequest request) { - log.debug("[Mock 은행계좌] 결제 취소: tid={}", request.getPgTransactionId()); - - String mockCancelApprovalNumber = "BC" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); - - return PgPaymentCancelResponse.success( - request.getPgTransactionId(), - "BTX" + UUID.randomUUID().toString().substring(0, 8), // cancelTransactionId - mockCancelApprovalNumber, - request.getMerchantOrderId(), - request.getCancelAmount(), - request.getCancelReason() != null ? request.getCancelReason() : "고객 요청" - ); - } - - @Override - public PgPaymentResponse getPaymentStatus(String pgTransactionId) { - log.debug("[Mock 은행계좌] 결제 상태 조회: tid={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .status("SUCCESS") - .rawResponse("Mock Bank Account Status Success") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockBankTransferGateway.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockBankTransferGateway.java deleted file mode 100644 index 5450eed6..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockBankTransferGateway.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.mock; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.PgPaymentGateway; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * 계좌이체 결제 Mock Gateway - * 운영 환경에서는 은행 가상계좌 시스템과 연동 - */ -@Slf4j -@Component -public class MockBankTransferGateway implements PgPaymentGateway { - - @Override - public boolean supports(PaymentMethod paymentMethod) { - return paymentMethod == PaymentMethod.BANK_TRANSFER; - } - - @Override - public PgPaymentResponse requestPayment(PgPaymentRequest request) { - log.debug("[Mock 계좌이체] 결제 요청: orderId={}, amount={}", - request.getMerchantOrderId(), request.getAmount()); - - String mockPaymentId = "T" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); - - // Mock 계좌이체는 가상계좌 발급 후 입금 대기 상태 - log.debug("[Mock 계좌이체] 가상계좌 발급 완료 - 입금 대기 상태"); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(mockPaymentId) - .merchantOrderId(request.getMerchantOrderId()) - .amount(request.getAmount()) - .status("WAITING_FOR_DEPOSIT") // 입금 대기 상태 - .paymentUrl(null) // 별도 URL 없음 - .rawResponse("Mock Bank Transfer - Virtual Account Issued") - .build(); - } - - @Override - public PgPaymentResponse approvePayment(String pgTransactionId, String merchantOrderId) { - log.debug("[Mock 계좌이체] 입금 확인 및 결제 승인: tid={}, orderId={}", pgTransactionId, merchantOrderId); - - // 입금 확인 시뮬레이션 (95% 성공률 - 입금 확인됨) - boolean isSuccess = Math.random() > 0.05; - - if (isSuccess) { - log.debug("[Mock 계좌이체] 입금 확인 완료 - 결제 승인 성공: tid={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .status("SUCCESS") - .approvedAt(LocalDateTime.now()) - .rawResponse("Mock Bank Transfer - Deposit Confirmed") - .build(); - } else { - log.warn("[Mock 계좌이체] 입금 확인 실패: tid={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(false) - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .status("FAILED") - .errorCode("DEPOSIT_NOT_CONFIRMED") - .errorMessage("입금이 확인되지 않았습니다. 계좌번호와 입금금액을 다시 확인해주세요.") - .rawResponse("Mock Bank Transfer - Deposit Not Confirmed") - .build(); - } - } - - @Override - public PgPaymentCancelResponse cancelPayment(PgPaymentCancelRequest request) { - log.debug("[Mock 계좌이체] 결제 취소: tid={}", request.getPgTransactionId()); - - return PgPaymentCancelResponse.builder() - .success(true) - .pgTransactionId(request.getPgTransactionId()) - .canceledAt(LocalDateTime.now()) - .build(); - } - - @Override - public PgPaymentResponse getPaymentStatus(String pgTransactionId) { - log.debug("[Mock 계좌이체] 결제 상태 조회: tid={}", pgTransactionId); - - // 입금 대기 중인 상태로 응답 (운영에서는 은행에서 입금 상태 확인) - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .status("WAITING_FOR_DEPOSIT") - .rawResponse("Mock Bank Transfer - Waiting for Deposit") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockCreditCardGateway.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockCreditCardGateway.java deleted file mode 100644 index 40e0fa54..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockCreditCardGateway.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.mock; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.PgPaymentGateway; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * Mock 신용카드 Gateway - * 신용카드 PG 연동 전까지 테스트용으로 사용 - */ -@Slf4j -@Component -public class MockCreditCardGateway implements PgPaymentGateway { - - @Override - public boolean supports(PaymentMethod paymentMethod) { - return paymentMethod == PaymentMethod.CREDIT_CARD; - } - - @Override - public PgPaymentResponse requestPayment(PgPaymentRequest request) { - log.debug("[Mock 신용카드] 결제 요청: orderId={}, amount={}", - request.getMerchantOrderId(), request.getAmount()); - - String mockPaymentId = "C" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); - - // Mock 신용카드는 카카오페이/네이버페이와 동일하게 바로 승인 처리 - log.debug("[Mock 신용카드] Mock 결제 - 리다이렉트 없이 바로 승인 처리"); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(mockPaymentId) - .merchantOrderId(request.getMerchantOrderId()) - .amount(request.getAmount()) - .status("READY") - .paymentUrl(null) // null이면 바로 승인 처리 - .rawResponse("Mock Credit Card Response") - .build(); - } - - @Override - public PgPaymentResponse approvePayment(String pgTransactionId, String merchantOrderId) { - log.debug("[Mock 신용카드] 결제 승인: tid={}, orderId={}", pgTransactionId, merchantOrderId); - - // Mock 승인 처리 - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .status("SUCCESS") - .approvalNumber("MOCK_CARD_" + System.currentTimeMillis()) - .approvedAt(LocalDateTime.now()) - .rawResponse("Mock Credit Card Approval Success") - .build(); - } - - @Override - public PgPaymentCancelResponse cancelPayment(PgPaymentCancelRequest request) { - log.debug("[Mock 신용카드] 결제 취소: tid={}, amount={}", - request.getPgTransactionId(), request.getCancelAmount()); - - return PgPaymentCancelResponse.builder() - .success(true) - .pgTransactionId(request.getPgTransactionId()) - .merchantOrderId(request.getMerchantOrderId()) - .cancelAmount(request.getCancelAmount()) - .canceledAt(LocalDateTime.now()) - .build(); - } - - @Override - public PgPaymentResponse getPaymentStatus(String pgTransactionId) { - log.debug("[Mock 신용카드] 결제 상태 조회: tid={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .status("SUCCESS") - .rawResponse("Mock Credit Card Status Check") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockKakaoPayGateway.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockKakaoPayGateway.java deleted file mode 100644 index ba419c5c..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockKakaoPayGateway.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.mock; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.PgPaymentGateway; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * 카카오페이 Mock Gateway - */ -@Slf4j -@Component -public class MockKakaoPayGateway implements PgPaymentGateway { - - @Override - public boolean supports(PaymentMethod paymentMethod) { - return PaymentMethod.KAKAO_PAY == paymentMethod; - } - - @Override - public PgPaymentResponse requestPayment(PgPaymentRequest request) { - log.debug("[Mock 카카오페이] 결제 요청: orderId={}, amount={}", - request.getMerchantOrderId(), request.getAmount()); - - String mockTid = "T" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(mockTid) - .merchantOrderId(request.getMerchantOrderId()) - .amount(request.getAmount()) - .status("READY") - .paymentUrl(null) - .rawResponse("Mock Kakao Pay Response") - .build(); - } - - @Override - public PgPaymentResponse approvePayment(String pgTransactionId, String merchantOrderId) { - log.debug("[Mock 카카오페이] 결제 승인: tid={}, orderId={}", pgTransactionId, merchantOrderId); - - String mockApprovalNumber = "A" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .status("SUCCESS") - .approvalNumber(mockApprovalNumber) - .approvedAt(LocalDateTime.now()) - .rawResponse("Mock Kakao Pay Approve Response") - .build(); - } - - @Override - public PgPaymentCancelResponse cancelPayment(PgPaymentCancelRequest request) { - log.debug("[Mock 카카오페이] 결제 취소: tid={}, amount={}", - request.getPgTransactionId(), request.getCancelAmount()); - - String mockCancelApprovalNumber = "C" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); - - // success 메서드는 6개 파라미터를 요구함 - return PgPaymentCancelResponse.success( - request.getPgTransactionId(), - "CTX" + UUID.randomUUID().toString().substring(0, 8), // cancelTransactionId - mockCancelApprovalNumber, - request.getMerchantOrderId(), - request.getCancelAmount(), - request.getCancelReason() != null ? request.getCancelReason() : "고객 요청" - ); - } - - @Override - public PgPaymentResponse getPaymentStatus(String pgTransactionId) { - log.debug("[Mock 카카오페이] 결제 상태 조회: tid={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .status("SUCCESS") - .rawResponse("Mock Kakao Pay Status Response") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockNaverPayGateway.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockNaverPayGateway.java deleted file mode 100644 index 8dfce88d..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockNaverPayGateway.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.mock; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.PgPaymentGateway; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * 네이버페이 Mock Gateway (테스트용) - * PG 연동 전 개발/테스트를 위한 Mock 구현 - */ -@Slf4j -@Component -public class MockNaverPayGateway implements PgPaymentGateway { - - @Override - public boolean supports(PaymentMethod paymentMethod) { - return PaymentMethod.NAVER_PAY == paymentMethod; - } - - @Override - public PgPaymentResponse requestPayment(PgPaymentRequest request) { - log.debug("[Mock 네이버페이] 결제 요청: orderId={}, amount={}", - request.getMerchantOrderId(), request.getAmount()); - - String mockPaymentId = "N" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); - // Mock 환경에서는 URL 대신 null 반환하여 프론트엔드에서 바로 완료 처리 - String mockPaymentUrl = null; // 리다이렉트 없이 Mock 처리 - - log.debug("[Mock 네이버페이] Mock 결제 - 리다이렉트 없이 바로 승인 처리"); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(mockPaymentId) - .merchantOrderId(request.getMerchantOrderId()) - .amount(request.getAmount()) - .status("READY") - .paymentUrl(mockPaymentUrl) - .rawResponse("Mock Naver Pay Response - No Redirect") - .build(); - } - - @Override - public PgPaymentResponse approvePayment(String pgTransactionId, String merchantOrderId) { - log.debug("[Mock 네이버페이] 결제 승인: paymentId={}, orderId={}", pgTransactionId, merchantOrderId); - - String mockApprovalNumber = "NA" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .status("SUCCESS") - .approvalNumber(mockApprovalNumber) - .approvedAt(LocalDateTime.now()) - .rawResponse("Mock Naver Pay Approve Response") - .build(); - } - - @Override - public PgPaymentCancelResponse cancelPayment(PgPaymentCancelRequest request) { - log.debug("[Mock 네이버페이] 결제 취소: paymentId={}, amount={}", - request.getPgTransactionId(), request.getCancelAmount()); - - String mockCancelId = "NC" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); - - // success 메서드는 6개 파라미터를 요구함 - return PgPaymentCancelResponse.success( - request.getPgTransactionId(), - "NTX" + UUID.randomUUID().toString().substring(0, 8), // cancelTransactionId - mockCancelId, - request.getMerchantOrderId(), - request.getCancelAmount(), - request.getCancelReason() != null ? request.getCancelReason() : "고객 요청" - ); - } - - @Override - public PgPaymentResponse getPaymentStatus(String pgTransactionId) { - log.debug("[Mock 네이버페이] 결제 상태 조회: paymentId={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .status("SUCCESS") - .rawResponse("Mock Naver Pay Status Response") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockPaycoGateway.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockPaycoGateway.java deleted file mode 100644 index 0b577dc1..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/mock/MockPaycoGateway.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.mock; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.PgPaymentGateway; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * PAYCO 결제 Mock Gateway - * 운영 환경에서는 PAYCO API와 연동 - */ -@Slf4j -@Component -public class MockPaycoGateway implements PgPaymentGateway { - - @Override - public boolean supports(PaymentMethod paymentMethod) { - return paymentMethod == PaymentMethod.PAYCO; - } - - @Override - public PgPaymentResponse requestPayment(PgPaymentRequest request) { - log.debug("[Mock PAYCO] 결제 요청: orderId={}, amount={}", - request.getMerchantOrderId(), request.getAmount()); - - String mockPaymentId = "P" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); - - // Mock PAYCO는 바로 승인 처리 (운영에서는 PAYCO 앱으로 리다이렉트) - log.debug("[Mock PAYCO] Mock 결제 - 리다이렉트 없이 바로 승인 처리"); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(mockPaymentId) - .merchantOrderId(request.getMerchantOrderId()) - .amount(request.getAmount()) - .status("READY") - .paymentUrl(null) // null이면 바로 승인 처리 - .rawResponse("Mock PAYCO Response") - .build(); - } - - @Override - public PgPaymentResponse approvePayment(String pgTransactionId, String merchantOrderId) { - log.debug("[Mock PAYCO] 결제 승인: tid={}, orderId={}", pgTransactionId, merchantOrderId); - - // 결제 승인 성공 시뮬레이션 (95% 성공률) - boolean isSuccess = Math.random() > 0.05; - - if (isSuccess) { - log.debug("[Mock PAYCO] 결제 승인 성공: tid={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .status("SUCCESS") - .approvedAt(LocalDateTime.now()) - .rawResponse("Mock PAYCO Approval Success") - .build(); - } else { - log.warn("[Mock PAYCO] 결제 승인 실패: tid={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(false) - .pgTransactionId(pgTransactionId) - .merchantOrderId(merchantOrderId) - .status("FAILED") - .errorCode("PAYCO_APPROVAL_FAILED") - .errorMessage("PAYCO 결제 승인이 실패했습니다.") - .rawResponse("Mock PAYCO Approval Failed") - .build(); - } - } - - @Override - public PgPaymentCancelResponse cancelPayment(PgPaymentCancelRequest request) { - log.debug("[Mock PAYCO] 결제 취소: tid={}", request.getPgTransactionId()); - - return PgPaymentCancelResponse.builder() - .success(true) - .pgTransactionId(request.getPgTransactionId()) - .canceledAt(LocalDateTime.now()) - .build(); - } - - @Override - public PgPaymentResponse getPaymentStatus(String pgTransactionId) { - log.debug("[Mock PAYCO] 결제 상태 조회: tid={}", pgTransactionId); - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .status("SUCCESS") - .rawResponse("Mock PAYCO Status Success") - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/naverpay/NaverPayGateway.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/naverpay/NaverPayGateway.java deleted file mode 100644 index 9fc21197..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/naverpay/NaverPayGateway.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.naverpay; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.PgPaymentGateway; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.time.LocalDateTime; - -/** - * 네이버페이 API 연동 Gateway - */ -@Slf4j -@Component -@RequiredArgsConstructor -@Profile("never") // 실제 PG 연동은 사용하지 않음 -public class NaverPayGateway implements PgPaymentGateway { - - private final RestTemplate restTemplate; - - @Value("${payment.naverpay.client-id}") - private String clientId; - - @Value("${payment.naverpay.client-secret}") - private String clientSecret; - - private static final String NAVER_PAY_BASE_URL = "https://dev.apis.naver.com/naverpay-partner/naverpay/payments/v1"; - - @Override - public boolean supports(PaymentMethod paymentMethod) { - return PaymentMethod.NAVER_PAY == paymentMethod; - } - - @Override - public PgPaymentResponse requestPayment(PgPaymentRequest request) { - log.debug("[네이버페이] 결제 요청: orderId={}, amount={}", - request.getMerchantOrderId(), request.getAmount()); - - try { - // 네이버페이 결제 요청 로직 - // API 호출 구현 - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId("NAVER_" + System.currentTimeMillis()) - .merchantOrderId(request.getMerchantOrderId()) - .amount(request.getAmount()) - .status("READY") - .paymentUrl("https://pay.naver.com/redirect?paymentId=NAVER_" + System.currentTimeMillis()) - .rawResponse("Naver Pay Ready Success") - .build(); - - } catch (Exception e) { - log.error("[네이버페이] 결제 요청 실패", e); - return PgPaymentResponse.builder() - .success(false) - .errorCode("NAVER_PAY_ERROR") - .errorMessage(e.getMessage()) - .build(); - } - } - - @Override - public PgPaymentResponse approvePayment(String pgTransactionId, String merchantOrderId) { - log.debug("[네이버페이] 결제 승인: tid={}, orderId={}", pgTransactionId, merchantOrderId); - - try { - // 네이버페이 승인 로직 구현 - - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .status("SUCCESS") - .approvalNumber("NAVER_REAL_" + System.currentTimeMillis()) - .approvedAt(LocalDateTime.now()) - .rawResponse("Naver Pay Approve Success") - .build(); - - } catch (Exception e) { - log.error("[네이버페이] 결제 승인 실패", e); - return PgPaymentResponse.builder() - .success(false) - .errorCode("NAVER_PAY_APPROVE_ERROR") - .errorMessage(e.getMessage()) - .build(); - } - } - - @Override - public PgPaymentCancelResponse cancelPayment(PgPaymentCancelRequest request) { - log.debug("[네이버페이] 결제 취소: tid={}", request.getPgTransactionId()); - - try { - // 네이버페이 취소 로직 구현 - - return PgPaymentCancelResponse.builder() - .success(true) - .pgTransactionId(request.getPgTransactionId()) - .cancelAmount(request.getCancelAmount()) - .cancelApprovalNumber("NAVER_CANCEL_" + System.currentTimeMillis()) - .canceledAt(LocalDateTime.now()) - .build(); - - } catch (Exception e) { - log.error("[네이버페이] 결제 취소 실패", e); - return PgPaymentCancelResponse.builder() - .success(false) - .errorCode("NAVER_PAY_CANCEL_ERROR") - .errorMessage(e.getMessage()) - .build(); - } - } - - @Override - public PgPaymentResponse getPaymentStatus(String pgTransactionId) { - log.debug("[네이버페이] 결제 상태 조회: tid={}", pgTransactionId); - - // 네이버페이 상태 조회 로직 구현 - return PgPaymentResponse.builder() - .success(true) - .pgTransactionId(pgTransactionId) - .status("SUCCESS") - .rawResponse("Naver Pay Status Success") - .build(); - } - - private HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("X-Naver-Client-Id", clientId); - headers.set("X-Naver-Client-Secret", clientSecret); - return headers; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/naverpay/dto/NaverPaymentRequest.java b/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/naverpay/dto/NaverPaymentRequest.java deleted file mode 100644 index c7d9945e..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/external/pg/naverpay/dto/NaverPaymentRequest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.sudo.railo.payment.infrastructure.external.pg.naverpay.dto; - -import lombok.Builder; -import lombok.Data; - -@Data -@Builder -public class NaverPaymentRequest { - private String merchantPayKey; - private String productName; - private Long totalPayAmount; - private String returnUrl; - private ProductItem[] productItems; - - @Data - @Builder - public static class ProductItem { - private String categoryType; - private String categoryId; - private String uid; - private String name; - private Integer count; - private String payReferrer; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaDomainEventOutboxRepository.java b/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaDomainEventOutboxRepository.java deleted file mode 100644 index 4874b749..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaDomainEventOutboxRepository.java +++ /dev/null @@ -1,270 +0,0 @@ -package com.sudo.railo.payment.infrastructure.persistence; - -import com.sudo.railo.payment.domain.entity.DomainEventOutbox; -import com.sudo.railo.payment.domain.repository.DomainEventOutboxRepository; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -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; -import java.util.List; -import java.util.Optional; - -/** - * DomainEventOutbox JPA Repository 구현체 - * Outbox Pattern을 통한 안정적인 이벤트 처리 - */ -@Repository -public interface JpaDomainEventOutboxRepository - extends JpaRepository, DomainEventOutboxRepository { - - // DomainEventOutboxRepository의 모든 메서드는 JPA가 자동 구현 - // 추가적인 JPA 특화 메서드가 필요한 경우 여기에 정의 - - /** - * 처리 대기 중인 이벤트 조회 (처리 순서 보장) - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.status = 'PENDING' " + - "ORDER BY e.createdAt ASC") - List findPendingEventsOrderByCreatedAt(); - - /** - * 처리 대기 중인 이벤트 조회 (제한된 개수) - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.status = 'PENDING' " + - "ORDER BY e.createdAt ASC " + - "LIMIT :limit") - List findPendingEventsWithLimit(@Param("limit") int limit); - - /** - * 재시도 가능한 실패 이벤트 조회 - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.status = 'FAILED' " + - "AND e.retryCount < :maxRetryCount " + - "ORDER BY e.createdAt ASC") - List findRetryableFailedEvents(@Param("maxRetryCount") int maxRetryCount); - - /** - * 특정 이벤트 타입의 처리 대기 이벤트 조회 - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.eventType = :eventType " + - "AND e.status = 'PENDING' " + - "ORDER BY e.createdAt ASC") - List findPendingEventsByType( - @Param("eventType") DomainEventOutbox.EventType eventType); - - /** - * 특정 애그리거트의 이벤트 조회 - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.aggregateType = :aggregateType " + - "AND e.aggregateId = :aggregateId " + - "ORDER BY e.createdAt DESC") - List findEventsByAggregate( - @Param("aggregateType") DomainEventOutbox.AggregateType aggregateType, - @Param("aggregateId") String aggregateId); - - /** - * 특정 애그리거트의 최근 이벤트 조회 - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.aggregateType = :aggregateType " + - "AND e.aggregateId = :aggregateId " + - "AND e.status = 'COMPLETED' " + - "ORDER BY e.createdAt DESC " + - "LIMIT 1") - Optional findLatestCompletedEventByAggregate( - @Param("aggregateType") DomainEventOutbox.AggregateType aggregateType, - @Param("aggregateId") String aggregateId); - - /** - * 처리 중인 이벤트 조회 (오래된 순) - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.status = 'PROCESSING' " + - "ORDER BY e.updatedAt ASC") - List findProcessingEventsOrderByUpdatedAt(); - - /** - * 특정 시간 이전의 완료된 이벤트 조회 (정리용) - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.status = 'COMPLETED' " + - "AND e.processedAt < :beforeTime") - List findCompletedEventsBeforeTime( - @Param("beforeTime") LocalDateTime beforeTime); - - /** - * 오래된 완료 이벤트 삭제 (배치 작업용) - */ - @Override - @Modifying - @Query("DELETE FROM DomainEventOutbox e " + - "WHERE e.status = 'COMPLETED' " + - "AND e.processedAt < :beforeTime") - int deleteCompletedEventsBeforeTime(@Param("beforeTime") LocalDateTime beforeTime); - - /** - * 이벤트 처리 통계 조회 - */ - @Override - @Query("SELECT new map(" + - "COUNT(CASE WHEN e.status = 'PENDING' THEN 1 END) as pendingCount, " + - "COUNT(CASE WHEN e.status = 'PROCESSING' THEN 1 END) as processingCount, " + - "COUNT(CASE WHEN e.status = 'COMPLETED' THEN 1 END) as completedCount, " + - "COUNT(CASE WHEN e.status = 'FAILED' THEN 1 END) as failedCount) " + - "FROM DomainEventOutbox e " + - "WHERE e.createdAt >= :fromTime") - Object getEventStatistics(@Param("fromTime") LocalDateTime fromTime); - - /** - * 이벤트 타입별 통계 조회 - */ - @Override - @Query("SELECT e.eventType, COUNT(*) " + - "FROM DomainEventOutbox e " + - "WHERE e.createdAt >= :fromTime " + - "GROUP BY e.eventType") - List getEventTypeStatistics(@Param("fromTime") LocalDateTime fromTime); - - /** - * 특정 시간 범위의 이벤트 조회 (모니터링용) - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.createdAt BETWEEN :startTime AND :endTime " + - "ORDER BY e.createdAt DESC") - Page findEventsByTimeRange( - @Param("startTime") LocalDateTime startTime, - @Param("endTime") LocalDateTime endTime, - Pageable pageable); - - /** - * 실패한 이벤트들 조회 (에러 분석용) - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.status = 'FAILED' " + - "ORDER BY e.updatedAt DESC") - Page findFailedEvents(Pageable pageable); - - /** - * 특정 이벤트가 이미 처리되었는지 확인 - */ - @Override - @Query("SELECT COUNT(e) > 0 FROM DomainEventOutbox e " + - "WHERE e.id = :eventId " + - "AND e.status = 'COMPLETED'") - boolean isEventAlreadyProcessed(@Param("eventId") String eventId); - - /** - * 처리 상태별 이벤트 개수 조회 - */ - @Override - long countByStatus(DomainEventOutbox.EventStatus status); - - /** - * 이벤트 타입별 이벤트 개수 조회 - */ - @Override - long countByEventType(DomainEventOutbox.EventType eventType); - - /** - * 애그리거트 ID로 이벤트 조회 (테스트 호환용) - * 애그리거트 타입에 관계없이 ID만으로 조회 - */ - @Override - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.aggregateId = :aggregateId " + - "ORDER BY e.createdAt DESC") - List findByAggregateId(@Param("aggregateId") String aggregateId); - - /** - * 특정 시간 이전의 처리 중인 이벤트 조회 (데드락 해결용) - */ - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.status = 'PROCESSING' " + - "AND e.updatedAt < :timeoutTime " + - "ORDER BY e.updatedAt ASC") - List findTimeoutProcessingEvents(@Param("timeoutTime") LocalDateTime timeoutTime); - - /** - * 이벤트 처리 상태를 PENDING으로 복원 (타임아웃 복구용) - */ - @Modifying - @Query("UPDATE DomainEventOutbox e " + - "SET e.status = 'PENDING', e.updatedAt = CURRENT_TIMESTAMP " + - "WHERE e.status = 'PROCESSING' " + - "AND e.updatedAt < :timeoutTime") - int resetTimeoutProcessingEventsToPending(@Param("timeoutTime") LocalDateTime timeoutTime); - - /** - * 특정 애그리거트의 최근 N개 이벤트 조회 - */ - @Query("SELECT e FROM DomainEventOutbox e " + - "WHERE e.aggregateType = :aggregateType " + - "AND e.aggregateId = :aggregateId " + - "ORDER BY e.createdAt DESC " + - "LIMIT :limit") - List findRecentEventsByAggregate( - @Param("aggregateType") DomainEventOutbox.AggregateType aggregateType, - @Param("aggregateId") String aggregateId, - @Param("limit") int limit); - - // existsById는 JpaRepository가 기본 제공하므로 별도 선언 불필요 - - /** - * 처리 완료까지의 평균 시간 조회 (성능 모니터링용) - */ - @Query("SELECT AVG(TIMESTAMPDIFF(SECOND, e.createdAt, e.processedAt)) " + - "FROM DomainEventOutbox e " + - "WHERE e.status = 'COMPLETED' " + - "AND e.processedAt IS NOT NULL " + - "AND e.createdAt >= :fromTime") - Double getAverageProcessingTimeInSeconds(@Param("fromTime") LocalDateTime fromTime); - - /** - * 재시도 횟수별 이벤트 개수 조회 - */ - @Query("SELECT e.retryCount, COUNT(*) " + - "FROM DomainEventOutbox e " + - "WHERE e.status = 'FAILED' " + - "GROUP BY e.retryCount " + - "ORDER BY e.retryCount") - List getFailedEventCountByRetryCount(); - - /** - * 특정 기간의 이벤트 처리 성공률 계산 - */ - @Query("SELECT " + - "COUNT(CASE WHEN e.status = 'COMPLETED' THEN 1 END) * 100.0 / COUNT(*) as successRate " + - "FROM DomainEventOutbox e " + - "WHERE e.createdAt >= :fromTime") - Double calculateSuccessRate(@Param("fromTime") LocalDateTime fromTime); - - /** - * 이벤트 타입별 최근 처리 시간 조회 (모니터링용) - */ - @Query("SELECT e.eventType, MAX(e.processedAt) " + - "FROM DomainEventOutbox e " + - "WHERE e.status = 'COMPLETED' " + - "AND e.processedAt IS NOT NULL " + - "GROUP BY e.eventType") - List getLastProcessedTimeByEventType(); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaMileageTransactionRepository.java b/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaMileageTransactionRepository.java deleted file mode 100644 index c75ab54b..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaMileageTransactionRepository.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.sudo.railo.payment.infrastructure.persistence; - -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.domain.repository.MileageTransactionRepository; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * MileageTransaction JPA Repository 구현체 - */ -@Repository -public interface JpaMileageTransactionRepository - extends JpaRepository, MileageTransactionRepository { - - // MileageTransactionRepository의 모든 메서드는 JPA가 자동 구현 - // 추가적인 JPA 특화 메서드가 필요한 경우 여기에 정의 - - /** - * 회원의 활성 마일리지 잔액 조회 (만료되지 않은 적립분만) - */ - @Query("SELECT COALESCE(SUM(mt.pointsAmount), 0) FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.status = 'COMPLETED' " + - "AND (mt.expiresAt IS NULL OR mt.expiresAt > :currentTime)") - BigDecimal calculateActiveBalance( - @Param("memberId") Long memberId, - @Param("currentTime") LocalDateTime currentTime); - - /** - * 회원의 총 적립 포인트 (전체 기간) - */ - @Query("SELECT COALESCE(SUM(mt.pointsAmount), 0) FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'EARN' " + - "AND mt.status = 'COMPLETED'") - BigDecimal calculateTotalEarned(@Param("memberId") Long memberId); - - /** - * 회원의 총 사용 포인트 (전체 기간) - */ - @Query("SELECT COALESCE(SUM(ABS(mt.pointsAmount)), 0) FROM MileageTransaction mt " + - "WHERE mt.memberId = :memberId " + - "AND mt.type = 'USE' " + - "AND mt.status = 'COMPLETED'") - BigDecimal calculateTotalUsed(@Param("memberId") Long memberId); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaPaymentCalculationRepository.java b/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaPaymentCalculationRepository.java deleted file mode 100644 index 1f3cd2fc..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaPaymentCalculationRepository.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.sudo.railo.payment.infrastructure.persistence; - -import com.sudo.railo.payment.domain.entity.CalculationStatus; -import com.sudo.railo.payment.domain.entity.PaymentCalculation; -import com.sudo.railo.payment.domain.repository.PaymentCalculationRepository; -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; -import java.util.List; -import java.util.Optional; - -/** - * JPA 기반 PaymentCalculationRepository 구현체 - * 도메인 리포지토리 인터페이스를 JPA로 구현 - */ -@Repository -public interface JpaPaymentCalculationRepository extends JpaRepository, PaymentCalculationRepository { - - @Override - @Query("SELECT pc FROM PaymentCalculation pc WHERE pc.externalOrderId = :externalOrderId") - Optional findByExternalOrderId(@Param("externalOrderId") String externalOrderId); - - @Override - @Query("SELECT pc FROM PaymentCalculation pc WHERE pc.userIdExternal = :userId AND pc.status = :status") - List findByUserIdExternalAndStatus(@Param("userId") String userId, @Param("status") CalculationStatus status); - - @Override - @Query("SELECT pc FROM PaymentCalculation pc WHERE pc.expiresAt < :expireTime AND pc.status = :status") - List findByExpiresAtBeforeAndStatus(@Param("expireTime") LocalDateTime expireTime, - @Param("status") CalculationStatus status); - - @Override - @Modifying - @Query("UPDATE PaymentCalculation pc SET pc.status = :newStatus WHERE pc.id IN :calculationIds") - void updateStatusByIds(@Param("calculationIds") List calculationIds, @Param("newStatus") CalculationStatus newStatus); - - @Override - @Modifying - @Query("DELETE FROM PaymentCalculation pc WHERE pc.expiresAt < :expireTime AND pc.status = :status") - void deleteByExpiresAtBeforeAndStatus(@Param("expireTime") LocalDateTime expireTime, @Param("status") CalculationStatus status); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaPaymentRepository.java b/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaPaymentRepository.java deleted file mode 100644 index fa200187..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaPaymentRepository.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.sudo.railo.payment.infrastructure.persistence; - -import com.sudo.railo.payment.domain.entity.Payment; -import com.sudo.railo.payment.domain.repository.PaymentRepository; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * JPA 기반 PaymentRepository 구현체 - * 도메인 리포지토리 인터페이스를 JPA로 구현 - */ -@Repository -public interface JpaPaymentRepository extends JpaRepository, PaymentRepository { - - @Override - Optional findByReservationId(Long reservationId); - - @Override - Optional findByExternalOrderId(String externalOrderId); - - @Override - @Query("SELECT p FROM Payment p WHERE p.member.id = :memberId") - List findByMemberId(@Param("memberId") Long memberId); - - @Override - @Query("SELECT p FROM Payment p LEFT JOIN FETCH p.member WHERE p.member.id = :memberId ORDER BY p.createdAt DESC") - Page findByMemberIdOrderByCreatedAtDesc(@Param("memberId") Long memberId, Pageable pageable); - - @Override - @Query("SELECT p FROM Payment p LEFT JOIN FETCH p.member WHERE p.member.id = :memberId AND p.createdAt BETWEEN :startDate AND :endDate ORDER BY p.createdAt DESC") - Page findByMemberIdAndCreatedAtBetweenOrderByCreatedAtDesc( - @Param("memberId") Long memberId, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate, - Pageable pageable - ); - - @Override - @Query("SELECT p FROM Payment p WHERE p.nonMemberName = :name AND p.nonMemberPhone = :phone") - Optional findByNonMemberNameAndNonMemberPhone(@Param("name") String name, @Param("phone") String phone); - - @Override - boolean existsByIdempotencyKey(String idempotencyKey); - - @Override - Optional findByIdempotencyKey(String idempotencyKey); - - /** - * 비회원 정보로 모든 결제 내역 조회 (페이징) - */ - @Query("SELECT p FROM Payment p WHERE p.nonMemberName = :name AND p.nonMemberPhone = :phone ORDER BY p.createdAt DESC") - Page findByNonMemberNameAndNonMemberPhoneOrderByCreatedAtDesc( - @Param("name") String name, - @Param("phone") String phone, - Pageable pageable - ); - - @Override - @Query("SELECT p FROM Payment p WHERE p.reservationId = :reservationId AND p.deletedAt IS NULL") - Optional findByReservationIdAndNotDeleted(@Param("reservationId") Long reservationId); - - @Override - @Query("SELECT p FROM Payment p WHERE p.nonMemberName = :name AND p.nonMemberPhone = :phone AND p.deletedAt IS NULL ORDER BY p.createdAt DESC") - default Page findByNonMemberInfo(String name, String phone, Pageable pageable) { - return findByNonMemberNameAndNonMemberPhoneAndDeletedAtIsNull(name, phone, pageable); - } - - @Query("SELECT p FROM Payment p WHERE p.nonMemberName = :name AND p.nonMemberPhone = :phone AND p.deletedAt IS NULL ORDER BY p.createdAt DESC") - Page findByNonMemberNameAndNonMemberPhoneAndDeletedAtIsNull( - @Param("name") String name, - @Param("phone") String phone, - Pageable pageable - ); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaRefundCalculationRepository.java b/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaRefundCalculationRepository.java deleted file mode 100644 index e8fba1fd..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaRefundCalculationRepository.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.sudo.railo.payment.infrastructure.persistence; - -import com.sudo.railo.payment.domain.entity.RefundCalculation; -import com.sudo.railo.payment.domain.entity.RefundStatus; -import com.sudo.railo.payment.domain.entity.RefundType; -import com.sudo.railo.payment.domain.repository.RefundCalculationRepository; -import com.sudo.railo.payment.infrastructure.persistence.RefundCalculationJpaRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -/** - * 환불 계산 JPA Repository (Infrastructure 계층) - */ -@Repository -@RequiredArgsConstructor -public class JpaRefundCalculationRepository implements RefundCalculationRepository { - - private final RefundCalculationJpaRepository jpaRepository; - - @Override - public RefundCalculation save(RefundCalculation refundCalculation) { - return jpaRepository.save(refundCalculation); - } - - @Override - public Optional findById(Long refundCalculationId) { - return jpaRepository.findById(refundCalculationId); - } - - @Override - public Optional findByPaymentId(Long paymentId) { - return jpaRepository.findByPaymentId(paymentId); - } - - @Override - public Optional findByReservationId(Long reservationId) { - return jpaRepository.findByReservationId(reservationId); - } - - @Override - public List findByMemberId(Long memberId) { - return jpaRepository.findByMemberIdOrderByCreatedAtDesc(memberId); - } - - @Override - public List findByRefundStatus(RefundStatus refundStatus) { - return jpaRepository.findByRefundStatusOrderByCreatedAtDesc(refundStatus); - } - - @Override - public List findByRefundType(RefundType refundType) { - return jpaRepository.findByRefundTypeOrderByCreatedAtDesc(refundType); - } - - @Override - public List findByRefundRequestTimeBetween(LocalDateTime startTime, LocalDateTime endTime) { - return jpaRepository.findByRefundRequestTimeBetweenOrderByRefundRequestTimeDesc(startTime, endTime); - } - - @Override - public List findByMemberIdAndRefundRequestTimeBetween( - Long memberId, LocalDateTime startTime, LocalDateTime endTime) { - return jpaRepository.findByMemberIdAndRefundRequestTimeBetweenOrderByRefundRequestTimeDesc( - memberId, startTime, endTime); - } - - @Override - public List findPendingRefunds() { - return jpaRepository.findByRefundStatusOrderByCreatedAtAsc(RefundStatus.PENDING); - } - - @Override - public void delete(RefundCalculation refundCalculation) { - jpaRepository.delete(refundCalculation); - } - - @Override - public void deleteById(Long refundCalculationId) { - jpaRepository.deleteById(refundCalculationId); - } - - @Override - public boolean existsById(Long refundCalculationId) { - return jpaRepository.existsById(refundCalculationId); - } - - @Override - public boolean existsByPaymentId(Long paymentId) { - return jpaRepository.existsByPaymentId(paymentId); - } - - @Override - public boolean existsByReservationId(Long reservationId) { - return jpaRepository.existsByReservationId(reservationId); - } - - @Override - public List findByPaymentIds(List paymentIds) { - return jpaRepository.findByPaymentIds(paymentIds); - } - - @Override - public Optional findByIdempotencyKey(String idempotencyKey) { - return jpaRepository.findByIdempotencyKey(idempotencyKey); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaSavedPaymentMethodRepository.java b/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaSavedPaymentMethodRepository.java deleted file mode 100644 index e4430d5f..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/JpaSavedPaymentMethodRepository.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.sudo.railo.payment.infrastructure.persistence; - -import com.sudo.railo.payment.domain.entity.SavedPaymentMethod; -import com.sudo.railo.payment.domain.repository.SavedPaymentMethodRepository; -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.util.List; -import java.util.Optional; - -/** - * 저장된 결제수단 JPA Repository 구현체 - */ -@Repository -public interface JpaSavedPaymentMethodRepository extends JpaRepository, SavedPaymentMethodRepository { - - @Override - List findByMemberIdAndIsActive(Long memberId, Boolean isActive); - - @Override - List findByMemberId(Long memberId); - - @Override - Optional findByMemberIdAndIsDefaultTrue(Long memberId); - - @Override - @Query("SELECT CASE WHEN COUNT(s) > 0 THEN true ELSE false END FROM SavedPaymentMethod s " + - "WHERE s.memberId = :memberId AND (s.cardNumberHash = :hash OR s.accountNumberHash = :hash) " + - "AND s.isActive = true") - boolean existsByMemberIdAndHash(@Param("memberId") Long memberId, @Param("hash") String hash); - - @Override - @Modifying - @Query("UPDATE SavedPaymentMethod s SET s.isDefault = false WHERE s.memberId = :memberId") - void updateAllToNotDefault(@Param("memberId") Long memberId); - - @Override - @Query("SELECT COUNT(s) FROM SavedPaymentMethod s " + - "WHERE s.memberId = :memberId AND s.paymentMethodType = :type AND s.isActive = true") - long countByMemberIdAndTypeAndActive(@Param("memberId") Long memberId, @Param("type") String type); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/RefundAuditLogRepository.java b/src/main/java/com/sudo/railo/payment/infrastructure/persistence/RefundAuditLogRepository.java deleted file mode 100644 index 82460c48..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/RefundAuditLogRepository.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.sudo.railo.payment.infrastructure.persistence; - -import com.sudo.railo.payment.domain.entity.RefundAuditLog; -import com.sudo.railo.payment.domain.entity.RefundAuditLog.AuditEventType; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * 환불 감사 로그 Repository - */ -@Repository -public interface RefundAuditLogRepository extends JpaRepository { - - List findByPaymentIdOrderByCreatedAtDesc(Long paymentId); - - List findByEventTypeOrderByCreatedAtDesc(AuditEventType eventType); - - List findByCreatedAtBetweenOrderByCreatedAtDesc( - LocalDateTime startTime, LocalDateTime endTime); - - List findByMemberIdAndCreatedAtBetweenOrderByCreatedAtDesc( - Long memberId, LocalDateTime startTime, LocalDateTime endTime); - - Long countByEventTypeAndCreatedAtBetween( - AuditEventType eventType, LocalDateTime startTime, LocalDateTime endTime); -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/RefundCalculationJpaRepository.java b/src/main/java/com/sudo/railo/payment/infrastructure/persistence/RefundCalculationJpaRepository.java deleted file mode 100644 index 288d853d..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/RefundCalculationJpaRepository.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.sudo.railo.payment.infrastructure.persistence; - -import com.sudo.railo.payment.domain.entity.RefundCalculation; -import com.sudo.railo.payment.domain.entity.RefundStatus; -import com.sudo.railo.payment.domain.entity.RefundType; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -@Repository -public interface RefundCalculationJpaRepository extends JpaRepository { - Optional findByPaymentId(Long paymentId); - Optional findByReservationId(Long reservationId); - List findByMemberIdOrderByCreatedAtDesc(Long memberId); - List findByRefundStatusOrderByCreatedAtDesc(RefundStatus refundStatus); - List findByRefundStatusOrderByCreatedAtAsc(RefundStatus refundStatus); - List findByRefundTypeOrderByCreatedAtDesc(RefundType refundType); - List findByRefundRequestTimeBetweenOrderByRefundRequestTimeDesc(LocalDateTime startTime, LocalDateTime endTime); - List findByMemberIdAndRefundRequestTimeBetweenOrderByRefundRequestTimeDesc(Long memberId, LocalDateTime startTime, LocalDateTime endTime); - boolean existsByPaymentId(Long paymentId); - boolean existsByReservationId(Long reservationId); - - @Query("SELECT rc FROM RefundCalculation rc WHERE rc.paymentId IN :paymentIds") - List findByPaymentIds(@Param("paymentIds") List paymentIds); - - Optional findByIdempotencyKey(String idempotencyKey); -} diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/converter/CashReceiptTypeConverter.java b/src/main/java/com/sudo/railo/payment/infrastructure/persistence/converter/CashReceiptTypeConverter.java deleted file mode 100644 index f25011fe..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/persistence/converter/CashReceiptTypeConverter.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.sudo.railo.payment.infrastructure.persistence.converter; - -import com.sudo.railo.payment.domain.entity.CashReceipt; -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; - -/** - * CashReceiptType enum과 DB 값을 변환하는 JPA 컨버터 - * - * DB에 저장된 "personal", "business" 값을 enum으로 매핑 - */ -@Converter(autoApply = false) -public class CashReceiptTypeConverter implements AttributeConverter { - - @Override - public String convertToDatabaseColumn(CashReceipt.CashReceiptType attribute) { - if (attribute == null) { - return null; - } - return attribute.getCode(); - } - - @Override - public CashReceipt.CashReceiptType convertToEntityAttribute(String dbData) { - if (dbData == null) { - return null; - } - - // fromCode 메서드를 사용하여 code 값으로 enum 찾기 - return CashReceipt.CashReceiptType.fromCode(dbData); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentCryptoConfig.java b/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentCryptoConfig.java deleted file mode 100644 index 62dc0608..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentCryptoConfig.java +++ /dev/null @@ -1,146 +0,0 @@ -package com.sudo.railo.payment.infrastructure.security; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import javax.crypto.Cipher; -import javax.crypto.KeyGenerator; -import javax.crypto.SecretKey; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Base64; - -/** - * 결제 정보 암호화 설정 클래스 - * AES-256-GCM 알고리즘을 사용하여 민감한 결제 정보를 암호화 - */ -@Configuration -public class PaymentCryptoConfig { - - private static final String ALGORITHM = "AES"; - private static final String TRANSFORMATION = "AES/GCM/NoPadding"; - private static final int GCM_TAG_LENGTH = 128; - private static final int GCM_IV_LENGTH = 12; - private static final int AES_KEY_SIZE = 256; - - @Value("${payment.crypto.secret-key:}") - private String secretKeyBase64; - - @Value("${payment.crypto.key-rotation-enabled:false}") - private boolean keyRotationEnabled; - - /** - * AES 비밀키 생성 또는 로드 - */ - @Bean - public SecretKey paymentSecretKey() throws NoSuchAlgorithmException { - if (secretKeyBase64 != null && !secretKeyBase64.isEmpty()) { - try { - // 환경변수나 설정에서 키를 로드 - byte[] decodedKey = Base64.getDecoder().decode(secretKeyBase64); - - // AES-256은 32바이트 키가 필요 - if (decodedKey.length != 32) { - throw new IllegalArgumentException(String.format( - "AES-256 requires exactly 32 bytes, but got %d bytes. " + - "Please use PaymentCryptoKeyGenerator to generate a valid key.", - decodedKey.length - )); - } - - return new SecretKeySpec(decodedKey, ALGORITHM); - } catch (IllegalArgumentException e) { - throw new IllegalStateException( - "Invalid payment crypto key format. " + - "Please ensure the key is properly Base64 encoded. " + - "Use PaymentCryptoKeyGenerator to generate a valid key. " + - "Error: " + e.getMessage(), e - ); - } - } else { - // 키가 설정되지 않은 경우 명확한 가이드 제공 - String errorMessage = "\n" + - "========================================\n" + - "❌ Payment Crypto Key Configuration Missing!\n" + - "========================================\n" + - "Payment encryption requires a secret key to be configured.\n\n" + - "To generate a new key, run:\n" + - " java -cp build/classes/java/main com.sudo.railo.payment.infrastructure.security.PaymentCryptoKeyGenerator\n\n" + - "Then configure it in one of these ways:\n" + - "1. Environment variable: export PAYMENT_CRYPTO_KEY=\n" + - "2. application.yml: payment.crypto.secret-key: \n" + - "3. System property: -Dpayment.crypto.secret-key=\n\n" + - "⚠️ WARNING: Auto-generated keys are not persisted and will cause\n" + - " data loss on restart. Always configure a permanent key for production.\n" + - "========================================\n"; - - // 개발 환경에서만 자동 생성 허용 (경고 메시지와 함께) - if ("local".equals(System.getProperty("spring.profiles.active")) || - "dev".equals(System.getProperty("spring.profiles.active"))) { - System.err.println(errorMessage); - System.err.println("⚠️ Generating temporary key for development. This is NOT suitable for production!"); - - KeyGenerator keyGenerator = KeyGenerator.getInstance(ALGORITHM); - keyGenerator.init(AES_KEY_SIZE); - SecretKey tempKey = keyGenerator.generateKey(); - - // 생성된 키를 Base64로 인코딩하여 표시 - String tempKeyBase64 = Base64.getEncoder().encodeToString(tempKey.getEncoded()); - System.err.println("📌 Temporary key (save this for consistent encryption): " + tempKeyBase64); - System.err.println("========================================\n"); - - return tempKey; - } else { - // 프로덕션 환경에서는 키 누락 시 시작 실패 - throw new IllegalStateException(errorMessage); - } - } - } - - /** - * 보안 난수 생성기 - */ - @Bean - public SecureRandom secureRandom() { - return new SecureRandom(); - } - - /** - * 암호화용 Cipher 인스턴스 생성 - */ - public Cipher getEncryptCipher(SecretKey key, byte[] iv) throws Exception { - Cipher cipher = Cipher.getInstance(TRANSFORMATION); - GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); - cipher.init(Cipher.ENCRYPT_MODE, key, gcmParameterSpec); - return cipher; - } - - /** - * 복호화용 Cipher 인스턴스 생성 - */ - public Cipher getDecryptCipher(SecretKey key, byte[] iv) throws Exception { - Cipher cipher = Cipher.getInstance(TRANSFORMATION); - GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); - cipher.init(Cipher.DECRYPT_MODE, key, gcmParameterSpec); - return cipher; - } - - /** - * 초기화 벡터(IV) 생성 - */ - public byte[] generateIv(SecureRandom random) { - byte[] iv = new byte[GCM_IV_LENGTH]; - random.nextBytes(iv); - return iv; - } - - /** - * 키 로테이션 활성화 여부 - */ - public boolean isKeyRotationEnabled() { - return keyRotationEnabled; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentCryptoKeyGenerator.java b/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentCryptoKeyGenerator.java deleted file mode 100644 index eba15563..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentCryptoKeyGenerator.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.sudo.railo.payment.infrastructure.security; - -import javax.crypto.KeyGenerator; -import javax.crypto.SecretKey; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Base64; - -/** - * 결제 암호화 키 생성 유틸리티 - * AES-256 암호화를 위한 32바이트 키를 안전하게 생성하고 관리하는 도구 - */ -public class PaymentCryptoKeyGenerator { - - private static final String ALGORITHM = "AES"; - private static final int AES_KEY_SIZE = 256; - private static final int KEY_LENGTH_BYTES = 32; // AES-256은 32바이트 필요 - - /** - * 새로운 AES-256 암호화 키 생성 - * - * @return Base64로 인코딩된 32바이트 키 - * @throws NoSuchAlgorithmException AES 알고리즘을 사용할 수 없는 경우 - */ - public static String generateNewKey() throws NoSuchAlgorithmException { - KeyGenerator keyGenerator = KeyGenerator.getInstance(ALGORITHM); - keyGenerator.init(AES_KEY_SIZE, new SecureRandom()); - SecretKey secretKey = keyGenerator.generateKey(); - - byte[] keyBytes = secretKey.getEncoded(); - return Base64.getEncoder().encodeToString(keyBytes); - } - - /** - * Base64 인코딩된 키의 유효성 검증 - * - * @param base64Key Base64로 인코딩된 키 - * @return 유효한 AES-256 키인지 여부 - */ - public static boolean isValidKey(String base64Key) { - if (base64Key == null || base64Key.isEmpty()) { - return false; - } - - try { - byte[] decodedKey = Base64.getDecoder().decode(base64Key); - return decodedKey.length == KEY_LENGTH_BYTES; - } catch (IllegalArgumentException e) { - // Base64 디코딩 실패 - return false; - } - } - - /** - * 키 생성 및 설정 가이드 출력 - * 개발자가 초기 설정 시 사용할 수 있는 가이드 제공 - */ - public static void printSetupGuide() { - System.out.println("=== 결제 암호화 키 설정 가이드 ==="); - System.out.println(); - - try { - String newKey = generateNewKey(); - System.out.println("1. 새로운 암호화 키가 생성되었습니다:"); - System.out.println(" " + newKey); - System.out.println(); - - System.out.println("2. 환경별 설정 방법:"); - System.out.println(); - - System.out.println(" [개발 환경 - application-local.yml]"); - System.out.println(" payment:"); - System.out.println(" crypto:"); - System.out.println(" secret-key: " + newKey); - System.out.println(); - - System.out.println(" [운영 환경 - 환경변수]"); - System.out.println(" export PAYMENT_CRYPTO_KEY=" + newKey); - System.out.println(); - - System.out.println(" [Docker Compose]"); - System.out.println(" environment:"); - System.out.println(" - PAYMENT_CRYPTO_KEY=" + newKey); - System.out.println(); - - System.out.println("3. 주의사항:"); - System.out.println(" - 이 키는 민감한 결제 정보를 암호화하는 데 사용됩니다"); - System.out.println(" - 운영 환경에서는 반드시 안전한 방법으로 관리하세요"); - System.out.println(" - 키를 변경하면 기존 암호화된 데이터를 복호화할 수 없습니다"); - System.out.println(" - 키 로테이션이 필요한 경우 별도의 마이그레이션 전략이 필요합니다"); - System.out.println(); - - System.out.println("4. 보안 권장사항:"); - System.out.println(" - Git에 커밋하지 마세요 (.gitignore에 추가)"); - System.out.println(" - AWS Secrets Manager, HashiCorp Vault 등 사용 권장"); - System.out.println(" - 정기적인 키 로테이션 정책 수립"); - System.out.println(" - 접근 권한을 최소한으로 제한"); - - } catch (NoSuchAlgorithmException e) { - System.err.println("❌ 키 생성 실패: " + e.getMessage()); - } - - System.out.println("==========================="); - } - - /** - * 메인 메소드 - 직접 실행하여 새 키 생성 - */ - public static void main(String[] args) { - System.out.println("Payment Crypto Key Generator v1.0"); - System.out.println("---------------------------------"); - - if (args.length > 0 && "--validate".equals(args[0])) { - if (args.length < 2) { - System.err.println("사용법: java PaymentCryptoKeyGenerator --validate "); - System.exit(1); - } - - String keyToValidate = args[1]; - boolean isValid = isValidKey(keyToValidate); - - System.out.println("키 검증 결과: " + (isValid ? "✅ 유효함" : "❌ 유효하지 않음")); - if (!isValid) { - System.out.println("AES-256 키는 정확히 32바이트여야 합니다."); - } - } else { - // 기본 동작: 설정 가이드 출력 - printSetupGuide(); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentCryptoService.java b/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentCryptoService.java deleted file mode 100644 index 68bd0c48..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentCryptoService.java +++ /dev/null @@ -1,192 +0,0 @@ -package com.sudo.railo.payment.infrastructure.security; - -import com.sudo.railo.payment.exception.PaymentCryptoException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import javax.crypto.Cipher; -import javax.crypto.SecretKey; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.util.Base64; - -/** - * 결제 정보 암호화/복호화 서비스 - * - * 카드번호, 계좌번호 등 민감한 결제 정보를 안전하게 암호화하고 복호화하는 서비스 - * AES-256-GCM 알고리즘을 사용하여 기밀성과 무결성을 동시에 보장 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class PaymentCryptoService { - - private final PaymentCryptoConfig cryptoConfig; - private final SecretKey secretKey; - private final SecureRandom secureRandom; - private final PaymentSecurityAuditService auditService; - - /** - * 평문을 암호화 - * - * @param plainText 암호화할 평문 - * @return Base64로 인코딩된 암호문 (IV + 암호화된 데이터) - */ - public String encrypt(String plainText) { - if (plainText == null || plainText.isEmpty()) { - return null; - } - - try { - // IV 생성 - byte[] iv = cryptoConfig.generateIv(secureRandom); - - // 암호화 수행 - Cipher cipher = cryptoConfig.getEncryptCipher(secretKey, iv); - byte[] encryptedData = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); - - // IV와 암호화된 데이터를 함께 저장 - ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + encryptedData.length); - byteBuffer.put(iv); - byteBuffer.put(encryptedData); - - String encrypted = Base64.getEncoder().encodeToString(byteBuffer.array()); - - // 암호화 성공 로깅 (민감정보는 로깅하지 않음) - log.debug("Successfully encrypted data. Length: {}", encrypted.length()); - - return encrypted; - - } catch (Exception e) { - log.error("Encryption failed", e); - throw new PaymentCryptoException("Failed to encrypt payment data", e); - } - } - - /** - * 암호문을 복호화 - * - * @param encryptedText Base64로 인코딩된 암호문 - * @return 복호화된 평문 - */ - public String decrypt(String encryptedText) { - if (encryptedText == null || encryptedText.isEmpty()) { - return null; - } - - try { - // 복호화 권한 검증 및 감사 로깅 - auditService.logDecryptionAttempt(); - - // Base64 디코딩 - byte[] encryptedData = Base64.getDecoder().decode(encryptedText); - ByteBuffer byteBuffer = ByteBuffer.wrap(encryptedData); - - // IV 추출 - byte[] iv = new byte[12]; // GCM IV는 12바이트 - byteBuffer.get(iv); - - // 암호화된 데이터 추출 - byte[] cipherText = new byte[byteBuffer.remaining()]; - byteBuffer.get(cipherText); - - // 복호화 수행 - Cipher cipher = cryptoConfig.getDecryptCipher(secretKey, iv); - byte[] decryptedData = cipher.doFinal(cipherText); - - String decrypted = new String(decryptedData, StandardCharsets.UTF_8); - - // 복호화 성공 감사 로깅 - auditService.logDecryptionSuccess(); - - return decrypted; - - } catch (Exception e) { - log.error("Decryption failed", e); - auditService.logDecryptionFailure(e.getMessage()); - throw new PaymentCryptoException("Failed to decrypt payment data", e); - } - } - - /** - * 카드번호 마스킹 (복호화 없이 수행) - * - * @param cardNumber 카드번호 - * @return 마스킹된 카드번호 (예: **** **** **** 1234) - */ - public String maskCardNumber(String cardNumber) { - if (cardNumber == null || cardNumber.length() < 4) { - return "****"; - } - - String cleaned = cardNumber.replaceAll("[^0-9]", ""); - if (cleaned.length() < 4) { - return "****"; - } - - String lastFour = cleaned.substring(cleaned.length() - 4); - return "**** **** **** " + lastFour; - } - - /** - * 계좌번호 마스킹 (복호화 없이 수행) - * - * @param accountNumber 계좌번호 - * @return 마스킹된 계좌번호 (예: ****1234) - */ - public String maskAccountNumber(String accountNumber) { - if (accountNumber == null || accountNumber.length() < 4) { - return "****"; - } - - String cleaned = accountNumber.replaceAll("[^0-9]", ""); - if (cleaned.length() < 4) { - return "****"; - } - - String lastFour = cleaned.substring(cleaned.length() - 4); - return "****" + lastFour; - } - - /** - * 데이터 해싱 (검색용) - * 결제수단 검색을 위한 단방향 해시 생성 - * - * @param data 해싱할 데이터 - * @return SHA-256 해시값 - */ - public String hash(String data) { - if (data == null || data.isEmpty()) { - return null; - } - - try { - java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); - byte[] hash = md.digest(data.getBytes(StandardCharsets.UTF_8)); - return Base64.getEncoder().encodeToString(hash); - } catch (Exception e) { - log.error("Hashing failed", e); - throw new PaymentCryptoException("Failed to hash payment data", e); - } - } - - /** - * 암호화 상태 검증 - * 주어진 텍스트가 암호화된 형식인지 확인 - */ - public boolean isEncrypted(String text) { - if (text == null || text.isEmpty()) { - return false; - } - - try { - byte[] decoded = Base64.getDecoder().decode(text); - // 최소한 IV(12바이트) + 데이터(1바이트) + 인증태그(16바이트) = 29바이트 이상이어야 함 - return decoded.length >= 29; - } catch (IllegalArgumentException e) { - return false; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentDataMigrationService.java b/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentDataMigrationService.java deleted file mode 100644 index 10e09c05..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentDataMigrationService.java +++ /dev/null @@ -1,244 +0,0 @@ -package com.sudo.railo.payment.infrastructure.security; - -import com.sudo.railo.payment.domain.entity.SavedPaymentMethod; -import com.sudo.railo.payment.domain.repository.SavedPaymentMethodRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; - -/** - * 결제 데이터 마이그레이션 서비스 - * - * 기존 평문 데이터를 암호화된 형식으로 변환하는 서비스 - * Zero-Downtime 마이그레이션을 위해 단계별로 처리 - */ -@Slf4j -@Service -@RequiredArgsConstructor -@ConditionalOnProperty(name = "payment.crypto.migration.enabled", havingValue = "true") -public class PaymentDataMigrationService implements ApplicationRunner { - - private final SavedPaymentMethodRepository repository; - private final PaymentCryptoService cryptoService; - private final PaymentSecurityAuditService auditService; - - private static final int BATCH_SIZE = 100; - private static final String MIGRATION_VERSION = "1.0"; - - @Override - public void run(ApplicationArguments args) throws Exception { - log.info("Starting payment data migration..."); - migrateAllPaymentMethods(); - } - - /** - * 모든 결제수단 데이터 마이그레이션 - */ - @Transactional - public void migrateAllPaymentMethods() { - int pageNumber = 0; - int migratedCount = 0; - int errorCount = 0; - - try { - Page page; - do { - page = findPaymentMethodsToMigrate(PageRequest.of(pageNumber, BATCH_SIZE)); - List migratedMethods = new ArrayList<>(); - - for (SavedPaymentMethod method : page.getContent()) { - try { - if (needsMigration(method)) { - migratePaymentMethod(method); - migratedMethods.add(method); - migratedCount++; - } - } catch (Exception e) { - log.error("Failed to migrate payment method ID: {}", method.getId(), e); - errorCount++; - } - } - - // 배치 단위로 저장 - if (!migratedMethods.isEmpty()) { - for (SavedPaymentMethod method : migratedMethods) { - repository.save(method); - } - log.info("Migrated batch of {} payment methods", migratedMethods.size()); - } - - pageNumber++; - } while (page.hasNext()); - - log.info("Payment data migration completed. Migrated: {}, Errors: {}", migratedCount, errorCount); - auditService.logDataMigration("PAYMENT_METHOD_ENCRYPTION", migratedCount); - - } catch (Exception e) { - log.error("Payment data migration failed", e); - throw new RuntimeException("Failed to migrate payment data", e); - } - } - - /** - * 단일 결제수단 마이그레이션 - */ - private void migratePaymentMethod(SavedPaymentMethod method) { - // 카드 정보 마이그레이션 - if (method.isCard() && hasPlainCardData(method)) { - migrateCardData(method); - } - - // 계좌 정보 마이그레이션 - if (method.isAccount() && hasPlainAccountData(method)) { - migrateAccountData(method); - } - - // 암호화 버전 설정 - method.updateEncryptionVersion(MIGRATION_VERSION); - } - - /** - * 카드 데이터 마이그레이션 - */ - @SuppressWarnings("deprecation") - private void migrateCardData(SavedPaymentMethod method) { - // 레거시 필드에서 데이터 추출 (임시 접근) - String plainCardNumber = getPlainCardNumber(method); - if (plainCardNumber != null && !plainCardNumber.isEmpty()) { - String cleanNumber = plainCardNumber.replaceAll("[^0-9]", ""); - - // 암호화 및 해시 생성 - String encryptedNumber = cryptoService.encrypt(cleanNumber); - String numberHash = cryptoService.hash(cleanNumber); - String lastFourDigits = cleanNumber.substring(cleanNumber.length() - 4); - - // 카드 소유자명 암호화 - String holderName = getPlainCardHolderName(method); - String encryptedHolderName = holderName != null ? cryptoService.encrypt(holderName) : null; - - // 유효기간 암호화 - String expiryMonth = getPlainCardExpiryMonth(method); - String encryptedExpiryMonth = expiryMonth != null ? cryptoService.encrypt(expiryMonth) : null; - - String expiryYear = getPlainCardExpiryYear(method); - String encryptedExpiryYear = expiryYear != null ? cryptoService.encrypt(expiryYear) : null; - - // 비즈니스 메서드를 통해 설정 - method.setEncryptedCardInfo(encryptedNumber, numberHash, lastFourDigits, - encryptedHolderName, encryptedExpiryMonth, encryptedExpiryYear); - - // 레거시 필드 제거 준비 (null 설정) - clearLegacyCardFields(method); - } - } - - /** - * 계좌 데이터 마이그레이션 - */ - @SuppressWarnings("deprecation") - private void migrateAccountData(SavedPaymentMethod method) { - // 레거시 필드에서 데이터 추출 (임시 접근) - String plainAccountNumber = getPlainAccountNumber(method); - if (plainAccountNumber != null && !plainAccountNumber.isEmpty()) { - String cleanNumber = plainAccountNumber.replaceAll("[^0-9]", ""); - - // 암호화 및 해시 생성 - String encryptedNumber = cryptoService.encrypt(cleanNumber); - String numberHash = cryptoService.hash(cleanNumber); - String lastFourDigits = cleanNumber.substring(cleanNumber.length() - 4); - - // 계좌 소유자명 암호화 - String holderName = getPlainAccountHolderName(method); - String encryptedHolderName = holderName != null ? cryptoService.encrypt(holderName) : null; - - // 비즈니스 메서드를 통해 설정 - method.setEncryptedAccountInfo(encryptedNumber, numberHash, lastFourDigits, - encryptedHolderName, null); - - // 레거시 필드 제거 준비 (null 설정) - clearLegacyAccountFields(method); - } - } - - /** - * 마이그레이션이 필요한지 확인 - */ - private boolean needsMigration(SavedPaymentMethod method) { - // 암호화 버전이 없거나 현재 버전보다 낮으면 마이그레이션 필요 - return method.getEncryptionVersion() == null || - !MIGRATION_VERSION.equals(method.getEncryptionVersion()); - } - - /** - * 평문 카드 데이터가 있는지 확인 - */ - private boolean hasPlainCardData(SavedPaymentMethod method) { - // 암호화된 필드가 비어있고 레거시 필드에 데이터가 있는 경우 - return method.getCardNumberEncrypted() == null && getPlainCardNumber(method) != null; - } - - /** - * 평문 계좌 데이터가 있는지 확인 - */ - private boolean hasPlainAccountData(SavedPaymentMethod method) { - // 암호화된 필드가 비어있고 레거시 필드에 데이터가 있는 경우 - return method.getAccountNumberEncrypted() == null && getPlainAccountNumber(method) != null; - } - - // 레거시 필드 접근 메서드들 (리플렉션 사용 회피를 위한 임시 메서드) - // 실제 구현 시에는 엔티티에 @Deprecated 레거시 getter 추가 필요 - - private String getPlainCardNumber(SavedPaymentMethod method) { - // TODO: 레거시 필드 접근 로직 - return null; - } - - private String getPlainCardHolderName(SavedPaymentMethod method) { - // TODO: 레거시 필드 접근 로직 - return null; - } - - private String getPlainCardExpiryMonth(SavedPaymentMethod method) { - // TODO: 레거시 필드 접근 로직 - return null; - } - - private String getPlainCardExpiryYear(SavedPaymentMethod method) { - // TODO: 레거시 필드 접근 로직 - return null; - } - - private String getPlainAccountNumber(SavedPaymentMethod method) { - // TODO: 레거시 필드 접근 로직 - return null; - } - - private String getPlainAccountHolderName(SavedPaymentMethod method) { - // TODO: 레거시 필드 접근 로직 - return null; - } - - private void clearLegacyCardFields(SavedPaymentMethod method) { - // TODO: 레거시 필드 null 설정 - } - - private void clearLegacyAccountFields(SavedPaymentMethod method) { - // TODO: 레거시 필드 null 설정 - } - - @SuppressWarnings("unchecked") - private Page findPaymentMethodsToMigrate(PageRequest pageRequest) { - // JPA Repository의 findAll 메서드 사용 - return (Page) repository.findAll(pageRequest); - } - -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentSecurityAuditService.java b/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentSecurityAuditService.java deleted file mode 100644 index b505051f..00000000 --- a/src/main/java/com/sudo/railo/payment/infrastructure/security/PaymentSecurityAuditService.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.sudo.railo.payment.infrastructure.security; - -import com.sudo.railo.global.security.util.SecurityUtil; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; - -/** - * 결제 정보 보안 감사 서비스 - * - * 민감한 결제 정보에 대한 접근 및 처리를 감사하고 기록 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class PaymentSecurityAuditService { - - /** - * 복호화 시도 로그 기록 - */ - public void logDecryptionAttempt() { - String username = getCurrentUser(); - log.info("Payment data decryption attempted - User: {}, Time: {}", - username, LocalDateTime.now()); - } - - /** - * 복호화 성공 로그 기록 - */ - public void logDecryptionSuccess() { - String username = getCurrentUser(); - log.info("Payment data decryption successful - User: {}, Time: {}", - username, LocalDateTime.now()); - } - - /** - * 복호화 실패 로그 기록 - */ - public void logDecryptionFailure(String reason) { - String username = getCurrentUser(); - log.error("Payment data decryption failed - User: {}, Reason: {}, Time: {}", - username, reason, LocalDateTime.now()); - } - - /** - * 결제수단 저장 로그 기록 - */ - public void logPaymentMethodSaved(Long memberId, String paymentType) { - String username = getCurrentUser(); - log.info("Payment method saved - User: {}, MemberId: {}, Type: {}, Time: {}", - username, memberId, paymentType, LocalDateTime.now()); - } - - /** - * 결제수단 삭제 로그 기록 - */ - public void logPaymentMethodDeleted(Long paymentMethodId) { - String username = getCurrentUser(); - log.info("Payment method deleted - User: {}, PaymentMethodId: {}, Time: {}", - username, paymentMethodId, LocalDateTime.now()); - } - - /** - * 민감정보 조회 로그 기록 - */ - public void logSensitiveDataAccess(String dataType, String purpose) { - String username = getCurrentUser(); - log.info("Sensitive data accessed - User: {}, DataType: {}, Purpose: {}, Time: {}", - username, dataType, purpose, LocalDateTime.now()); - } - - /** - * 보안 정책 위반 로그 기록 - */ - public void logSecurityViolation(String violationType, String details) { - String username = getCurrentUser(); - log.error("Security violation detected - User: {}, Type: {}, Details: {}, Time: {}", - username, violationType, details, LocalDateTime.now()); - } - - /** - * 데이터 마이그레이션 로그 기록 - */ - public void logDataMigration(String migrationType, int recordCount) { - String username = getCurrentUser(); - log.info("Data migration performed - User: {}, Type: {}, Records: {}, Time: {}", - username, migrationType, recordCount, LocalDateTime.now()); - } - - /** - * 현재 사용자 정보 조회 - */ - private String getCurrentUser() { - try { - return SecurityUtil.getCurrentMemberNo(); - } catch (Exception e) { - return "anonymous"; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/interfaces/dto/request/PaymentConfirmRequest.java b/src/main/java/com/sudo/railo/payment/interfaces/dto/request/PaymentConfirmRequest.java deleted file mode 100644 index 871fbb5c..00000000 --- a/src/main/java/com/sudo/railo/payment/interfaces/dto/request/PaymentConfirmRequest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.sudo.railo.payment.interfaces.dto.request; - -import jakarta.validation.constraints.NotBlank; -import lombok.*; - -/** - * PG 결제 확인 요청 DTO - * 계산 세션 ID와 PG 승인번호로 결제를 최종 확인 - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@ToString -public class PaymentConfirmRequest { - - @NotBlank(message = "계산 ID는 필수입니다") - private String calculationId; - - @NotBlank(message = "PG 승인번호는 필수입니다") - private String pgAuthNumber; - - // PG사에서 반환한 거래 ID (선택) - private String pgTransactionId; - - // 추가 검증을 위한 정보 (선택) - private String paymentMethod; // CREDIT_CARD, KAKAO_PAY 등 - - // 비회원인 경우 추가 정보 - private String nonMemberName; - private String nonMemberPhone; - private String nonMemberPassword; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/interfaces/dto/response/PaymentResponse.java b/src/main/java/com/sudo/railo/payment/interfaces/dto/response/PaymentResponse.java deleted file mode 100644 index da67bc9e..00000000 --- a/src/main/java/com/sudo/railo/payment/interfaces/dto/response/PaymentResponse.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.sudo.railo.payment.interfaces.dto.response; - -import lombok.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 결제 확인 응답 DTO - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@ToString -public class PaymentResponse { - - private Long paymentId; - private String status; - private BigDecimal amount; - private String paymentMethod; - private LocalDateTime completedAt; - private String pgTransactionId; - private String pgApprovalNumber; - - // 성공 응답 생성 - public static PaymentResponse success(Long paymentId) { - return PaymentResponse.builder() - .paymentId(paymentId) - .status("SUCCESS") - .completedAt(LocalDateTime.now()) - .build(); - } - - // 실패 응답 생성 - public static PaymentResponse fail(String reason) { - return PaymentResponse.builder() - .status("FAILED") - .completedAt(LocalDateTime.now()) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/presentation/PaymentController.java b/src/main/java/com/sudo/railo/payment/presentation/PaymentController.java new file mode 100644 index 00000000..be09497e --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/presentation/PaymentController.java @@ -0,0 +1,81 @@ +package com.sudo.railo.payment.presentation; + +import java.util.List; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sudo.railo.global.success.SuccessResponse; +import com.sudo.railo.payment.application.PaymentService; +import com.sudo.railo.payment.application.dto.request.PaymentProcessAccountRequest; +import com.sudo.railo.payment.application.dto.request.PaymentProcessCardRequest; +import com.sudo.railo.payment.application.dto.response.PaymentCancelResponse; +import com.sudo.railo.payment.application.dto.response.PaymentHistoryResponse; +import com.sudo.railo.payment.application.dto.response.PaymentProcessResponse; +import com.sudo.railo.payment.success.PaymentSuccess; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Payments", description = "결제 API") +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/payments") +public class PaymentController { + + private final PaymentService paymentService; + + @Operation(summary = "결제 처리 (카드)", description = "예약에 대한 결제를 카드를 이용해 처리합니다.") + @PostMapping("/card") + public SuccessResponse processPaymentViaCard( + @Valid @RequestBody PaymentProcessCardRequest request, + @AuthenticationPrincipal UserDetails userDetails) { + + String memberNo = userDetails.getUsername(); + PaymentProcessResponse response = paymentService.processPaymentViaCard(memberNo, request); + + return SuccessResponse.of(PaymentSuccess.PAYMENT_PROCESS_SUCCESS, response); + } + + @Operation(summary = "결제 처리 (계좌이체)", description = "예약에 대한 결제를 은행 계좌를 이용해 처리합니다.") + @PostMapping("/bank-account") + public SuccessResponse processPaymentViaBankAccount( + @Valid @RequestBody PaymentProcessAccountRequest request, + @AuthenticationPrincipal UserDetails userDetails) { + + String memberNo = userDetails.getUsername(); + PaymentProcessResponse response = + paymentService.processPaymentViaBankAccount(memberNo, request); + + return SuccessResponse.of(PaymentSuccess.PAYMENT_PROCESS_SUCCESS, response); + } + + @Operation(summary = "결제 취소", description = "완료된 결제를 취소 및 환불처리 합니다.") + @PostMapping("/{paymentKey}/cancel") + public SuccessResponse cancelPayment(@PathVariable String paymentKey, + @AuthenticationPrincipal UserDetails userDetails) { + String memberNo = userDetails.getUsername(); + PaymentCancelResponse response = paymentService.cancelPayment(memberNo, paymentKey); + + return SuccessResponse.of(PaymentSuccess.PAYMENT_CANCEL_SUCCESS, response); + } + + @Operation(summary = "결제 내역 조회", description = "사용자의 결제 내역을 조회합니다.") + @GetMapping + public SuccessResponse> getPaymentHistory( + @AuthenticationPrincipal UserDetails userDetails) { + + String memberNo = userDetails.getUsername(); + List response = paymentService.getPaymentHistory(memberNo); + + return SuccessResponse.of(PaymentSuccess.PAYMENT_HISTORY_SUCCESS, response); + } +} diff --git a/src/main/java/com/sudo/railo/payment/presentation/controller/BankAccountVerificationController.java b/src/main/java/com/sudo/railo/payment/presentation/controller/BankAccountVerificationController.java deleted file mode 100644 index 40713ade..00000000 --- a/src/main/java/com/sudo/railo/payment/presentation/controller/BankAccountVerificationController.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.sudo.railo.payment.presentation.controller; - -import com.sudo.railo.payment.application.dto.request.BankAccountVerificationRequest; -import com.sudo.railo.payment.application.dto.response.BankAccountVerificationResponse; -import com.sudo.railo.payment.application.service.BankAccountVerificationService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -/** - * 은행 계좌 검증 전용 컨트롤러 - * 계좌 저장 없이 유효성만 검증 - */ -@Slf4j -@RestController -@RequestMapping("/api/v1/payment/verify-bank-account") -@RequiredArgsConstructor -@Tag(name = "BankAccountVerification", description = "은행 계좌 검증 API") -public class BankAccountVerificationController { - - private final BankAccountVerificationService verificationService; - - /** - * 은행 계좌 유효성 검증 - * 계좌번호와 비밀번호를 검증하고 예금주명을 반환 - * 검증만 수행하고 저장하지 않음 - */ - @PostMapping - @Operation(summary = "계좌 검증", description = "은행 계좌의 유효성을 검증합니다. 저장하지 않고 검증만 수행합니다.") - public ResponseEntity verifyBankAccount( - @Valid @RequestBody BankAccountVerificationRequest request) { - - log.info("계좌 검증 요청 - 은행: {}, 계좌번호: {}", - request.getBankCode(), - request.getAccountNumber().replaceAll("(\\d{4})(\\d+)(\\d{4})", "$1****$3")); - - BankAccountVerificationResponse response = verificationService.verifyAccount(request); - - return ResponseEntity.ok(response); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/presentation/controller/MileageController.java b/src/main/java/com/sudo/railo/payment/presentation/controller/MileageController.java deleted file mode 100644 index 9143681f..00000000 --- a/src/main/java/com/sudo/railo/payment/presentation/controller/MileageController.java +++ /dev/null @@ -1,277 +0,0 @@ -package com.sudo.railo.payment.presentation.controller; - -import com.sudo.railo.global.success.SuccessResponse; -import com.sudo.railo.member.infra.MemberRepository; -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.member.exception.MemberError; -import com.sudo.railo.payment.application.dto.response.MileageBalanceInfo; -import com.sudo.railo.payment.application.dto.response.MileageStatisticsResponse; -import com.sudo.railo.payment.application.dto.response.MileageTransactionResponse; -import com.sudo.railo.payment.application.service.MileageBalanceService; -import com.sudo.railo.payment.application.port.in.QueryMileageEarningUseCase; -import com.sudo.railo.payment.application.service.MileageTransactionService; -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import com.sudo.railo.payment.domain.entity.MileageTransaction; -import com.sudo.railo.payment.success.PaymentSuccess; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * 마일리지 조회 및 관리 API 컨트롤러 - * JWT 토큰에서 회원 정보를 자동으로 추출하여 사용 - */ -@RestController -@RequestMapping("/api/v1/mileage") -@RequiredArgsConstructor -@Slf4j -@Tag(name = "마일리지 관리", description = "마일리지 조회, 거래내역, 통계 등 관련 API") -public class MileageController { - - private final MileageBalanceService mileageBalanceService; - private final MileageTransactionService mileageTransactionService; - private final QueryMileageEarningUseCase queryMileageEarningUseCase; - private final MemberRepository memberRepository; - - /** - * 마일리지 잔액 조회 (상세) - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/balance") - @Operation(summary = "마일리지 잔액 조회", description = "현재 로그인한 회원의 마일리지 잔액을 조회합니다") - public ResponseEntity> getMileageBalance( - @AuthenticationPrincipal UserDetails userDetails) { - log.debug("마일리지 잔액 조회 - 회원번호: {}", userDetails.getUsername()); - - MileageBalanceInfo balanceInfo = mileageBalanceService.getMileageBalance(userDetails); - - MileageBalanceResponse response = MileageBalanceResponse.builder() - .memberId(balanceInfo.getMemberId()) - .currentBalance(balanceInfo.getCurrentBalance()) - .activeBalance(balanceInfo.getActiveBalance()) - .pendingEarning(balanceInfo.getPendingEarning()) - .expiringInMonth(balanceInfo.getExpiringInMonth()) - .lastUpdatedAt(balanceInfo.getLastTransactionAt()) - .build(); - - return ResponseEntity.ok(SuccessResponse.of(PaymentSuccess.MILEAGE_INQUIRY_SUCCESS, response)); - } - - /** - * 마일리지 잔액 조회 (간단) - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/balance/simple") - @Operation(summary = "마일리지 잔액 간단 조회", description = "현재 로그인한 회원의 마일리지 잔액만 간단히 조회합니다") - public ResponseEntity> getSimpleMileageBalance( - @AuthenticationPrincipal UserDetails userDetails) { - log.debug("마일리지 간단 잔액 조회 - 회원번호: {}", userDetails.getUsername()); - - MileageBalanceInfo balanceInfo = mileageBalanceService.getMileageBalance(userDetails); - - SimpleMileageBalanceResponse response = SimpleMileageBalanceResponse.builder() - .balance(balanceInfo.getCurrentBalance()) - .build(); - - return ResponseEntity.ok(SuccessResponse.of(PaymentSuccess.MILEAGE_INQUIRY_SUCCESS, response)); - } - - /** - * 사용 가능한 마일리지 조회 - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/available") - @Operation(summary = "사용 가능한 마일리지 조회", description = "현재 사용 가능한 마일리지를 조회합니다") - public ResponseEntity> getAvailableMileage( - @AuthenticationPrincipal UserDetails userDetails) { - log.debug("사용 가능한 마일리지 조회 - 회원번호: {}", userDetails.getUsername()); - - MileageBalanceInfo balanceInfo = mileageBalanceService.getMileageBalance(userDetails); - - AvailableMileageResponse response = AvailableMileageResponse.builder() - .availableBalance(balanceInfo.getActiveBalance()) - .minimumUsableAmount(BigDecimal.valueOf(1000)) // 최소 사용 금액 - .maximumUsablePercentage(50) // 최대 사용 비율 50% - .build(); - - return ResponseEntity.ok(SuccessResponse.of(PaymentSuccess.MILEAGE_INQUIRY_SUCCESS, response)); - } - - /** - * 마일리지 통계 조회 - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/statistics") - @Operation(summary = "마일리지 통계 조회", description = "지정 기간의 마일리지 통계를 조회합니다") - public ResponseEntity> getMileageStatistics( - @AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "조회 시작일", required = true) - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate, - - @Parameter(description = "조회 종료일", required = true) - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate) { - - log.debug("마일리지 통계 조회 - 회원번호: {}, 기간: {} ~ {}", userDetails.getUsername(), startDate, endDate); - - MileageStatisticsResponse statistics = - mileageTransactionService.getMileageStatistics(userDetails, startDate, endDate); - - return ResponseEntity.ok(SuccessResponse.of(PaymentSuccess.STATISTICS_INQUIRY_SUCCESS, statistics)); - } - - /** - * 마일리지 거래 내역 조회 - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/transactions") - @Operation(summary = "마일리지 거래 내역 조회", description = "마일리지 적립/사용 내역을 페이징으로 조회합니다") - public ResponseEntity> getMileageTransactions( - @AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") - @RequestParam(defaultValue = "0") int page, - - @Parameter(description = "페이지 크기", example = "10") - @RequestParam(defaultValue = "10") int size) { - - log.debug("마일리지 거래 내역 조회 - 회원번호: {}, 페이지: {}, 크기: {}", userDetails.getUsername(), page, size); - - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "transactionDate")); - Page transactionPage = - mileageTransactionService.getMileageTransactions(userDetails, pageable); - - MileageTransactionListResponse response = MileageTransactionListResponse.builder() - .transactions(transactionPage.getContent()) - .totalElements(transactionPage.getTotalElements()) - .totalPages(transactionPage.getTotalPages()) - .currentPage(page) - .pageSize(size) - .hasNext(transactionPage.hasNext()) - .hasPrevious(transactionPage.hasPrevious()) - .build(); - - return ResponseEntity.ok(SuccessResponse.of(PaymentSuccess.MILEAGE_TRANSACTION_INQUIRY_SUCCESS, response)); - } - - /** - * 적립 예정 마일리지 조회 - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/earning-schedules") - @Operation(summary = "적립 예정 마일리지 조회", description = "아직 적립되지 않은 예정 마일리지를 조회합니다") - public ResponseEntity>> getEarningSchedules( - @AuthenticationPrincipal UserDetails userDetails) { - log.debug("적립 예정 마일리지 조회 - 회원번호: {}", userDetails.getUsername()); - - // UserDetails에서 회원 정보 추출 - Member member = memberRepository.findByMemberNo(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - Long memberId = member.getId(); - - List schedules = - queryMileageEarningUseCase.getEarningSchedulesByMemberId( - memberId, MileageEarningSchedule.EarningStatus.SCHEDULED); - - return ResponseEntity.ok(SuccessResponse.of(PaymentSuccess.MILEAGE_SCHEDULE_INQUIRY_SUCCESS, schedules)); - } - - /** - * 지연 보상 마일리지 조회 - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/delay-compensation") - @Operation(summary = "지연 보상 마일리지 조회", description = "열차 지연으로 인한 보상 마일리지를 조회합니다") - public ResponseEntity>> getDelayCompensation( - @AuthenticationPrincipal UserDetails userDetails) { - log.debug("지연 보상 마일리지 조회 - 회원번호: {}", userDetails.getUsername()); - - List compensations = - mileageTransactionService.getDelayCompensationTransactions(userDetails); - - return ResponseEntity.ok(SuccessResponse.of(PaymentSuccess.MILEAGE_TRANSACTION_INQUIRY_SUCCESS, compensations)); - } - - /** - * 마일리지 적립 이력 조회 (Train별) - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/earning-history") - @Operation(summary = "마일리지 적립 이력 조회", description = "특정 기차별 마일리지 적립 이력을 조회합니다") - public ResponseEntity>> getEarningHistory( - @AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "기차 ID (선택사항)") - @RequestParam(required = false) String trainId, - - @Parameter(description = "조회 시작일 (선택사항)") - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate, - - @Parameter(description = "조회 종료일 (선택사항)") - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate) { - - log.debug("마일리지 적립 이력 조회 - 회원번호: {}, 기차ID: {}", userDetails.getUsername(), trainId); - - List history = - mileageTransactionService.getEarningHistory(userDetails, trainId, startDate, endDate); - - return ResponseEntity.ok(SuccessResponse.of(PaymentSuccess.MILEAGE_TRANSACTION_INQUIRY_SUCCESS, history)); - } - - // Response DTOs - @lombok.Data - @lombok.Builder - @lombok.NoArgsConstructor @lombok.AllArgsConstructor - public static class MileageBalanceResponse { - private Long memberId; - private BigDecimal currentBalance; - private BigDecimal activeBalance; - private BigDecimal pendingEarning; - private BigDecimal expiringInMonth; - private LocalDateTime lastUpdatedAt; - } - - @lombok.Data - @lombok.Builder - @lombok.NoArgsConstructor @lombok.AllArgsConstructor - public static class SimpleMileageBalanceResponse { - private BigDecimal balance; - } - - @lombok.Data - @lombok.Builder - @lombok.NoArgsConstructor @lombok.AllArgsConstructor - public static class AvailableMileageResponse { - private BigDecimal availableBalance; - private BigDecimal minimumUsableAmount; - private Integer maximumUsablePercentage; - } - - @lombok.Data - @lombok.Builder - @lombok.NoArgsConstructor @lombok.AllArgsConstructor - public static class MileageTransactionListResponse { - private List transactions; - private long totalElements; - private int totalPages; - private int currentPage; - private int pageSize; - private boolean hasNext; - private boolean hasPrevious; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/presentation/controller/MileageEarningController.java b/src/main/java/com/sudo/railo/payment/presentation/controller/MileageEarningController.java deleted file mode 100644 index 42b4b948..00000000 --- a/src/main/java/com/sudo/railo/payment/presentation/controller/MileageEarningController.java +++ /dev/null @@ -1,264 +0,0 @@ -package com.sudo.railo.payment.presentation.controller; - -import com.sudo.railo.global.success.SuccessResponse; -import com.sudo.railo.payment.application.service.DomainEventOutboxService; -import com.sudo.railo.payment.application.port.in.QueryMileageEarningUseCase; -import com.sudo.railo.payment.application.port.in.ProcessMileageEarningUseCase; -import com.sudo.railo.payment.application.port.in.ManageMileageEarningUseCase; -import com.sudo.railo.payment.application.service.TrainArrivalMonitorService; -import com.sudo.railo.payment.domain.entity.MileageEarningSchedule; -import com.sudo.railo.payment.success.PaymentSuccess; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -/** - * 마일리지 적립 시스템 REST API 컨트롤러 - * 사용자용 조회 API와 관리자용 관리 API를 제공 - */ -@RestController -@RequestMapping("/api/v1/mileage") -@RequiredArgsConstructor -@Slf4j -public class MileageEarningController { - - private final QueryMileageEarningUseCase queryMileageEarningUseCase; - private final ProcessMileageEarningUseCase processMileageEarningUseCase; - private final ManageMileageEarningUseCase manageMileageEarningUseCase; - private final DomainEventOutboxService domainEventOutboxService; - private final TrainArrivalMonitorService trainArrivalMonitorService; - - /** - * 회원의 적립 예정 마일리지 조회 - */ - @GetMapping("/pending/{memberId}") - public ResponseEntity> getPendingMileage( - @PathVariable Long memberId) { - log.info("적립 예정 마일리지 조회 - 회원ID: {}", memberId); - - BigDecimal pendingMileage = queryMileageEarningUseCase.getPendingMileageByMemberId(memberId); - - return ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.MILEAGE_INQUIRY_SUCCESS, - pendingMileage - )); - } - - /** - * 회원의 마일리지 적립 스케줄 조회 - */ - @GetMapping("/schedules/{memberId}") - public ResponseEntity>> getEarningSchedules( - @PathVariable Long memberId, - @RequestParam(required = false) MileageEarningSchedule.EarningStatus status) { - log.info("마일리지 적립 스케줄 조회 - 회원ID: {}, 상태: {}", memberId, status); - - List schedules = - queryMileageEarningUseCase.getEarningSchedulesByMemberId(memberId, status); - - return ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.MILEAGE_SCHEDULE_INQUIRY_SUCCESS, - schedules - )); - } - - /** - * 특정 결제의 마일리지 적립 스케줄 조회 - */ - @GetMapping("/schedule/payment/{paymentId}") - public ResponseEntity> getEarningScheduleByPayment( - @PathVariable String paymentId) { - log.info("결제별 마일리지 적립 스케줄 조회 - 결제ID: {}", paymentId); - - return queryMileageEarningUseCase.getEarningScheduleByPaymentId(paymentId) - .map(schedule -> ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.MILEAGE_SCHEDULE_INQUIRY_SUCCESS, - schedule - ))) - .orElse(ResponseEntity.notFound().build()); - } - - // ========== 관리자 전용 API ========== - - /** - * 열차 정시 도착 처리 - 마일리지 즉시 적립 - * 프론트엔드 결제 내역 페이지에서 호출 - */ - @PostMapping("/admin/train/{trainScheduleId}/arrival") - @PreAuthorize("hasAuthority('ADMIN')") - public ResponseEntity> recordTrainArrival( - @PathVariable Long trainScheduleId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime actualArrivalTime) { - log.info("열차 정시 도착 처리 - 스케줄ID: {}, 도착시간: {}", - trainScheduleId, actualArrivalTime); - - // 정상 도착 (지연 없음) - trainArrivalMonitorService.simulateTrainArrival(trainScheduleId, actualArrivalTime, 0); - - return ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.TRAIN_ARRIVAL_RECORDED_SUCCESS, - String.format("열차 정시 도착 처리 완료 - 해당 예약의 마일리지가 즉시 적립됩니다") - )); - } - - /** - * 열차 지연 도착 처리 - 지연 보상 마일리지 자동 추가 - * 지연 20분 이상: 12.5%, 40분 이상: 25%, 60분 이상: 50% 보상 - * 프론트엔드 결제 내역 페이지에서 호출 - */ - @PostMapping("/admin/train/{trainScheduleId}/delay") - @PreAuthorize("hasAuthority('ADMIN')") - public ResponseEntity> recordTrainDelay( - @PathVariable Long trainScheduleId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime actualArrivalTime, - @RequestParam int delayMinutes) { - log.info("열차 지연 도착 처리 - 스케줄ID: {}, 도착시간: {}, 지연: {}분", - trainScheduleId, actualArrivalTime, delayMinutes); - - if (delayMinutes < 0) { - return ResponseEntity.badRequest().body(SuccessResponse.of( - PaymentSuccess.INVALID_REQUEST, - "지연 시간은 0분 이상이어야 합니다" - )); - } - - trainArrivalMonitorService.simulateTrainArrival(trainScheduleId, actualArrivalTime, delayMinutes); - - String compensationMessage = ""; - if (delayMinutes >= 20) { - compensationMessage = String.format(" + 지연보상 마일리지 자동 추가 (지연 %d분)", delayMinutes); - } - - return ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.TRAIN_DELAY_RECORDED_SUCCESS, - String.format("열차 지연 도착 처리 완료 - 기본 마일리지 적립%s", compensationMessage) - )); - } - - /** - * 마일리지 적립 현황 통계 - 일별/월별 적립 현황 조회 - */ - @GetMapping("/admin/analytics/earning-status") - @PreAuthorize("hasAuthority('ADMIN')") - public ResponseEntity>> getEarningAnalytics( - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) { - log.info("마일리지 적립 현황 분석 - 분석기간: {} ~ {}", startTime, endTime); - - Map analytics = queryMileageEarningUseCase.getEarningStatistics(startTime, endTime); - - return ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.STATISTICS_INQUIRY_SUCCESS, - analytics - )); - } - - /** - * 지연 보상 현황 통계 - 지연별 보상 지급 현황 분석 - */ - @GetMapping("/admin/analytics/delay-compensation") - @PreAuthorize("hasAuthority('ADMIN')") - public ResponseEntity>> getDelayCompensationAnalytics( - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) { - log.info("지연 보상 현황 분석 - 분석기간: {} ~ {}", startTime, endTime); - - Map analytics = queryMileageEarningUseCase.getDelayCompensationStatistics(startTime, endTime); - - return ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.STATISTICS_INQUIRY_SUCCESS, - analytics - )); - } - - /** - * 시스템 이벤트 처리 현황 - Outbox 패턴 이벤트 처리 상태 모니터링 - */ - @GetMapping("/admin/system/event-status") - @PreAuthorize("hasAuthority('ADMIN')") - public ResponseEntity>> getSystemEventStatus( - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime fromTime) { - log.info("시스템 이벤트 처리 현황 조회 - 기준시간: {}", fromTime); - - Map eventStatus = domainEventOutboxService.getEventStatistics(fromTime); - - return ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.STATISTICS_INQUIRY_SUCCESS, - eventStatus - )); - } - - /** - * 마일리지 적립 대기 건 수동 처리 - */ - @PostMapping("/admin/batch/process-schedules") - @PreAuthorize("hasAuthority('ADMIN')") - public ResponseEntity> processSchedules( - @RequestParam(defaultValue = "50") int batchSize) { - - log.info("마일리지 적립 대기 건 수동 처리 - 배치크기: {}", batchSize); - - ProcessMileageEarningUseCase.ProcessBatchCommand command = - new ProcessMileageEarningUseCase.ProcessBatchCommand(batchSize); - ProcessMileageEarningUseCase.BatchProcessedResult result = - processMileageEarningUseCase.processReadySchedules(command); - int processedCount = result.successCount(); - - return ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.BATCH_PROCESS_SUCCESS, - String.format("마일리지 적립 처리 완료 - %d건 처리됨", processedCount) - )); - } - - /** - * 완료된 이력 데이터 정리 - */ - @PostMapping("/admin/cleanup/history") - @PreAuthorize("hasAuthority('ADMIN')") - public ResponseEntity> cleanupHistory( - @RequestParam(defaultValue = "30") int eventRetentionDays, - @RequestParam(defaultValue = "90") int scheduleRetentionDays) { - - log.info("완료된 이력 데이터 정리 - 이벤트보관: {}일, 스케줄보관: {}일", - eventRetentionDays, scheduleRetentionDays); - - int deletedEvents = domainEventOutboxService.cleanupOldCompletedEvents(eventRetentionDays); - - ManageMileageEarningUseCase.CleanupOldSchedulesCommand cleanupCommand = - new ManageMileageEarningUseCase.CleanupOldSchedulesCommand(scheduleRetentionDays); - ManageMileageEarningUseCase.CleanupResult cleanupResult = - manageMileageEarningUseCase.cleanupOldCompletedSchedules(cleanupCommand); - int deletedSchedules = cleanupResult.deletedCount(); - - return ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.DATA_CLEANUP_SUCCESS, - String.format("데이터 정리 완료 - 이벤트 %d건, 스케줄 %d건 삭제", - deletedEvents, deletedSchedules) - )); - } - - /** - * 타임아웃된 이벤트 재처리 - */ - @PostMapping("/admin/recovery/timeout-events") - @PreAuthorize("hasAuthority('ADMIN')") - public ResponseEntity> recoverTimeoutEvents() { - - log.info("타임아웃된 이벤트 재처리 실행"); - - int recoveredCount = domainEventOutboxService.recoverTimeoutProcessingEvents(); - - return ResponseEntity.ok(SuccessResponse.of( - PaymentSuccess.EVENT_RECOVERY_SUCCESS, - String.format("타임아웃 이벤트 복구 완료 - %d건 복구됨", recoveredCount) - )); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentCalculationController.java b/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentCalculationController.java deleted file mode 100644 index fe7843bc..00000000 --- a/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentCalculationController.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.sudo.railo.payment.presentation.controller; - -import com.sudo.railo.payment.application.dto.request.PaymentCalculationRequest; -import com.sudo.railo.payment.application.dto.response.PaymentCalculationResponse; -import com.sudo.railo.payment.application.service.PaymentCalculationService; -import com.sudo.railo.payment.exception.PaymentException; -import com.sudo.railo.payment.exception.PaymentValidationException; -import com.sudo.railo.global.success.SuccessResponse; -import com.sudo.railo.payment.success.PaymentCalculationSuccess; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import jakarta.validation.Valid; - -@RestController -@RequestMapping("/api/v1/payments") -@RequiredArgsConstructor -@Slf4j -public class PaymentCalculationController { - - private final PaymentCalculationService paymentCalculationService; - - @PostMapping("/calculate") - public ResponseEntity> calculatePayment( - @RequestBody @Valid PaymentCalculationRequest request) { - - log.info("결제 계산 요청 수신: orderId={}, userId={}, amount={}, reservationId={}", - request.getExternalOrderId(), request.getUserId(), request.getOriginalAmount(), request.getReservationId()); - - try { - PaymentCalculationResponse response = paymentCalculationService.calculatePayment(request); - - log.debug("결제 계산 완료: calculationId={}, finalAmount={}", - response.getId(), response.getFinalPayableAmount()); - - return ResponseEntity.ok(SuccessResponse.of(PaymentCalculationSuccess.PAYMENT_CALCULATION_SUCCESS, response)); - - } catch (PaymentValidationException e) { - log.warn("결제 계산 검증 실패: {}", e.getMessage()); - throw e; - } catch (Exception e) { - log.error("결제 계산 중 오류 발생", e); - throw new PaymentException("결제 계산 처리 중 오류가 발생했습니다", e); - } - } - - @GetMapping("/calculations/{calculationId}") - public ResponseEntity> getCalculation( - @PathVariable String calculationId) { - - PaymentCalculationResponse response = paymentCalculationService.getCalculation(calculationId); - return ResponseEntity.ok(SuccessResponse.of(PaymentCalculationSuccess.PAYMENT_CALCULATION_RETRIEVAL_SUCCESS, response)); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentExecuteController.java b/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentExecuteController.java deleted file mode 100644 index f10905df..00000000 --- a/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentExecuteController.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.sudo.railo.payment.presentation.controller; - -import com.sudo.railo.payment.application.dto.request.PaymentExecuteRequest; -import com.sudo.railo.payment.application.dto.response.PaymentExecuteResponse; -import com.sudo.railo.payment.application.service.PaymentService; -import com.sudo.railo.payment.application.service.PaymentConfirmationService; -import com.sudo.railo.payment.interfaces.dto.request.PaymentConfirmRequest; -import com.sudo.railo.payment.interfaces.dto.response.PaymentResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import jakarta.validation.Valid; - -/** - * 결제 실행 컨트롤러 - * 신용카드, 계좌이체 등 직접 결제 처리 - */ -@RestController -@RequestMapping("/api/v1/payments") -@RequiredArgsConstructor -@Slf4j -public class PaymentExecuteController { - - private final PaymentService paymentService; - private final PaymentConfirmationService paymentConfirmationService; - - /** - * 결제 실행 - * POST /api/v1/payments/execute - */ - @PostMapping("/execute") - public ResponseEntity executePayment( - @RequestBody @Valid PaymentExecuteRequest request) { - - log.info("결제 실행 요청: calculationId={}, paymentMethod={}", - request.getCalculationId(), request.getPaymentMethod().getType()); - - try { - PaymentExecuteResponse response = paymentService.executePayment(request); - - log.info("결제 실행 완료: paymentId={}, status={}", - response.getPaymentId(), response.getPaymentStatus()); - - return ResponseEntity.ok(response); - - } catch (Exception e) { - log.error("결제 실행 중 오류 발생", e); - throw e; - } - } - - /** - * PG 결제 확인 (새로운 보안 강화 API) - * POST /api/v1/payments/confirm - * - * 프론트엔드 플로우: - * 1. /calculate API로 계산 세션 생성 → calculationId 받음 - * 2. PG 결제창에서 결제 진행 → PG 승인번호 받음 - * 3. 이 API로 최종 확인 → 서버에서 PG 검증 후 결제 완료 - */ - @PostMapping("/confirm") - public ResponseEntity confirmPayment( - @RequestBody @Valid PaymentConfirmRequest request) { - - log.info("PG 결제 확인 요청: calculationId={}, pgAuthNumber={}", - request.getCalculationId(), request.getPgAuthNumber()); - - try { - PaymentResponse response = paymentConfirmationService.confirmPayment(request); - - log.info("PG 결제 확인 완료: paymentId={}, status={}", - response.getPaymentId(), response.getStatus()); - - return ResponseEntity.ok(response); - - } catch (Exception e) { - log.error("PG 결제 확인 중 오류 발생", e); - throw e; - } - } - - /** - * 결제 조회 - * GET /api/v1/payments/{paymentId} - */ - @GetMapping("/{paymentId}") - public ResponseEntity getPayment(@PathVariable Long paymentId) { - - log.debug("결제 조회 요청: paymentId={}", paymentId); - - PaymentExecuteResponse response = paymentService.getPayment(paymentId); - return ResponseEntity.ok(response); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentHistoryController.java b/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentHistoryController.java deleted file mode 100644 index aa33510d..00000000 --- a/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentHistoryController.java +++ /dev/null @@ -1,229 +0,0 @@ -package com.sudo.railo.payment.presentation.controller; - -import com.sudo.railo.payment.application.dto.response.MileageBalanceInfo; -import com.sudo.railo.payment.application.dto.response.PaymentHistoryResponse; -import com.sudo.railo.payment.application.dto.response.PaymentInfoResponse; -import com.sudo.railo.payment.application.service.PaymentHistoryService; -import com.sudo.railo.payment.application.service.MileageBalanceService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.time.LocalDateTime; - -/** - * 결제 내역 조회 REST API 컨트롤러 - * JWT 토큰에서 회원 정보를 자동으로 추출하여 사용 - */ -@RestController -@RequestMapping("/api/v1/payment-history") -@RequiredArgsConstructor -@Slf4j -@Tag(name = "결제 내역 관리", description = "결제 내역 조회, 마일리지 잔액 조회 등 관련 API") -public class PaymentHistoryController { - - private final PaymentHistoryService paymentHistoryService; - private final MileageBalanceService mileageBalanceService; - - /** - * 회원 결제 내역 조회 (페이징) - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/member") - @Operation(summary = "회원 결제 내역 조회", description = "현재 로그인한 회원의 결제 내역을 페이징으로 조회합니다") - public ResponseEntity getMemberPaymentHistory( - @AuthenticationPrincipal UserDetails userDetails, - @PageableDefault(size = 20) Pageable pageable) { - - log.debug("회원 결제 내역 조회 요청 - 회원번호: {}, 페이지: {}", userDetails.getUsername(), pageable); - - // 전체 기간 조회 (startDate, endDate, paymentMethod는 null) - PaymentHistoryResponse response = paymentHistoryService.getPaymentHistory( - userDetails, null, null, null, pageable); - - return ResponseEntity.ok(response); - } - - /** - * 회원 결제 내역 기간별 조회 - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/member/date-range") - @Operation(summary = "회원 결제 내역 기간별 조회", description = "현재 로그인한 회원의 특정 기간 결제 내역을 조회합니다") - public ResponseEntity getMemberPaymentHistoryByDateRange( - @AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "조회 시작일 (ISO 형식)", example = "2024-01-01T00:00:00") - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate, - - @Parameter(description = "조회 종료일 (ISO 형식)", example = "2024-12-31T23:59:59") - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate, - - @Parameter(description = "결제 방법 (선택사항)", example = "KAKAO_PAY") - @RequestParam(required = false) String paymentMethod, - - @PageableDefault(size = 20) Pageable pageable) { - - log.debug("회원 기간별 결제 내역 조회 요청 - 회원번호: {}, 기간: {} ~ {}", userDetails.getUsername(), startDate, endDate); - - PaymentHistoryResponse response = paymentHistoryService.getPaymentHistory( - userDetails, startDate, endDate, paymentMethod, pageable); - - return ResponseEntity.ok(response); - } - - /** - * 비회원 결제 내역 조회 - * 예약번호, 이름, 전화번호, 비밀번호로 조회 (JWT 토큰 불필요) - */ - @GetMapping("/guest") - @Operation(summary = "비회원 결제 내역 조회", description = "비회원의 예약정보로 결제 내역을 조회합니다") - public ResponseEntity getGuestPaymentHistory( - @Parameter(description = "예약번호", required = true) - @RequestParam @NotNull Long reservationId, - - @Parameter(description = "비회원 이름", required = true) - @RequestParam @NotBlank String name, - - @Parameter(description = "비회원 전화번호", required = true) - @RequestParam @NotBlank String phoneNumber, - - @Parameter(description = "비밀번호", required = true) - @RequestParam @NotBlank String password) { - - log.debug("비회원 결제 내역 조회 요청 - 예약번호: {}, 이름: {}", reservationId, name); - - PaymentInfoResponse response = paymentHistoryService.getNonMemberPayment( - reservationId, name, phoneNumber, password); - - return ResponseEntity.ok(response); - } - - /** - * 비회원 전체 결제 내역 조회 - * 이름, 전화번호, 비밀번호로 모든 예약 조회 (JWT 토큰 불필요) - */ - @GetMapping("/guest/all") - @Operation(summary = "비회원 전체 결제 내역 조회", description = "비회원의 정보로 모든 결제 내역을 조회합니다") - public ResponseEntity getAllGuestPaymentHistory( - @Parameter(description = "비회원 이름", required = true) - @RequestParam @NotBlank String name, - - @Parameter(description = "비회원 전화번호", required = true) - @RequestParam @NotBlank String phoneNumber, - - @Parameter(description = "비밀번호", required = true) - @RequestParam @NotBlank String password, - - @PageableDefault(size = 20) Pageable pageable) { - - log.debug("비회원 전체 결제 내역 조회 요청 - 이름: {}, 전화번호: {}", name, phoneNumber); - - PaymentHistoryResponse response = paymentHistoryService.getAllNonMemberPayments( - name, phoneNumber, password, pageable); - - return ResponseEntity.ok(response); - } - - /** - * 특정 결제 상세 정보 조회 - * JWT 토큰에서 memberId를 자동으로 추출하여 소유권 검증 - */ - @GetMapping("/{paymentId}") - @Operation(summary = "결제 상세 정보 조회", description = "특정 결제의 상세 정보를 조회합니다") - public ResponseEntity getPaymentDetail( - @AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "결제 ID", required = true) - @PathVariable @NotNull Long paymentId) { - - log.debug("결제 상세 정보 조회 요청 - 결제ID: {}, 회원번호: {}", paymentId, userDetails.getUsername()); - - PaymentInfoResponse response = paymentHistoryService.getPaymentDetail(paymentId, userDetails); - - return ResponseEntity.ok(response); - } - - /** - * 특정 예약번호로 결제 정보 조회 (회원용) - * JWT 토큰에서 memberId를 자동으로 추출하여 소유권 검증 - */ - @GetMapping("/member/reservation/{reservationId}") - @Operation(summary = "예약번호로 결제 정보 조회", description = "특정 예약번호의 결제 정보를 조회합니다") - public ResponseEntity getPaymentByReservationId( - @AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "예약번호", required = true) - @PathVariable @NotNull Long reservationId) { - - log.debug("예약번호로 결제 정보 조회 요청 - 예약번호: {}, 회원번호: {}", reservationId, userDetails.getUsername()); - - PaymentInfoResponse response = paymentHistoryService.getPaymentByReservationId(reservationId, userDetails); - - return ResponseEntity.ok(response); - } - - /** - * 특정 예약번호로 결제 정보 조회 (비회원/회원 공용) - * 회원인 경우 토큰으로 검증, 비회원인 경우 검증 없이 조회 - */ - @GetMapping("/reservation/{reservationId}") - @Operation(summary = "예약번호로 결제 정보 조회 (공용)", description = "예약번호로 결제 정보를 조회합니다. 비회원도 사용 가능합니다.") - public ResponseEntity getPaymentByReservationIdPublic( - @Parameter(description = "예약번호", required = true) - @PathVariable @NotNull Long reservationId) { - - log.debug("예약번호로 결제 정보 조회 요청 (공용) - 예약번호: {}", reservationId); - - // 비회원도 조회 가능하도록 memberId 없이 호출 - PaymentInfoResponse response = paymentHistoryService.getPaymentByReservationIdPublic(reservationId); - - return ResponseEntity.ok(response); - } - - /** - * 회원 마일리지 잔액 조회 (추가 기능) - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping("/mileage/balance") - @Operation(summary = "마일리지 잔액 조회", description = "현재 로그인한 회원의 마일리지 잔액을 조회합니다") - public ResponseEntity getMileageBalance( - @AuthenticationPrincipal UserDetails userDetails) { - - log.debug("마일리지 잔액 조회 요청 - 회원번호: {}", userDetails.getUsername()); - - // MileageBalanceService를 통한 실제 잔액 조회 - MileageBalanceInfo balanceInfo = - mileageBalanceService.getMileageBalance(userDetails); - - MileageBalanceResponse response = MileageBalanceResponse.builder() - .memberId(balanceInfo.getMemberId()) - .currentBalance(balanceInfo.getCurrentBalance()) - .activeBalance(balanceInfo.getActiveBalance()) - .lastUpdatedAt(balanceInfo.getLastTransactionAt()) - .build(); - - return ResponseEntity.ok(response); - } - - /** - * 마일리지 잔액 응답 DTO - */ - @lombok.Data - @lombok.Builder - @lombok.NoArgsConstructor @lombok.AllArgsConstructor - public static class MileageBalanceResponse { - private Long memberId; - private java.math.BigDecimal currentBalance; // 현재 총 잔액 - private java.math.BigDecimal activeBalance; // 활성 잔액 (만료되지 않은 것만) - private java.time.LocalDateTime lastUpdatedAt; // 마지막 업데이트 시간 - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentRefundController.java b/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentRefundController.java deleted file mode 100644 index 917a6228..00000000 --- a/src/main/java/com/sudo/railo/payment/presentation/controller/PaymentRefundController.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.sudo.railo.payment.presentation.controller; - -import com.sudo.railo.payment.application.service.PaymentRefundService; -import com.sudo.railo.payment.exception.PaymentException; -import com.sudo.railo.payment.exception.PaymentValidationException; -import com.sudo.railo.global.success.SuccessResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.NotBlank; - -/** - * 결제 환불/취소 REST API 컨트롤러 - */ -@RestController -@RequestMapping("/api/v1/payments") -@RequiredArgsConstructor -@Slf4j -@Tag(name = "결제 환불/취소", description = "결제 취소 및 환불 관련 API") -public class PaymentRefundController { - - private final PaymentRefundService paymentRefundService; - - /** - * 결제 취소 (결제 전 취소) - */ - @PostMapping("/{paymentId}/cancel") - @Operation(summary = "결제 취소", description = "결제 전 상태에서 결제를 취소합니다") - public ResponseEntity> cancelPayment( - @Parameter(description = "결제 ID", required = true) - @PathVariable @NotNull Long paymentId, - - @RequestBody @Valid CancelPaymentRequest request) { - - log.debug("결제 취소 API 호출 - paymentId: {}", paymentId); - - try { - paymentRefundService.cancelPayment(paymentId, request.getReason()); - - return ResponseEntity.ok( - SuccessResponse.of(null, "결제가 성공적으로 취소되었습니다") - ); - - } catch (PaymentValidationException e) { - log.warn("결제 취소 검증 실패 - paymentId: {}, error: {}", paymentId, e.getMessage()); - throw e; - } catch (Exception e) { - log.error("결제 취소 처리 중 오류 발생 - paymentId: {}", paymentId, e); - throw new PaymentException("결제 취소 처리 중 오류가 발생했습니다", e); - } - } - - /** - * 결제 환불 (전체 환불) - */ - @PostMapping("/{paymentId}/refund") - @Operation(summary = "결제 환불", description = "결제 완료 후 전체 환불을 처리합니다") - public ResponseEntity> refundPayment( - @Parameter(description = "결제 ID", required = true) - @PathVariable @NotNull Long paymentId, - - @RequestBody @Valid RefundPaymentRequest request) { - - log.debug("결제 환불 API 호출 - paymentId: {}", paymentId); - - try { - paymentRefundService.refundPayment(paymentId, request.getReason()); - - return ResponseEntity.ok( - SuccessResponse.of(null, "환불이 성공적으로 처리되었습니다") - ); - - } catch (PaymentValidationException e) { - log.warn("결제 환불 검증 실패 - paymentId: {}, error: {}", paymentId, e.getMessage()); - throw e; - } catch (Exception e) { - log.error("결제 환불 처리 중 오류 발생 - paymentId: {}", paymentId, e); - throw new PaymentException("결제 환불 처리 중 오류가 발생했습니다", e); - } - } - - /** - * 결제 취소 요청 DTO - */ - @lombok.Data - @lombok.NoArgsConstructor @lombok.AllArgsConstructor - public static class CancelPaymentRequest { - - @NotBlank(message = "취소 사유는 필수입니다") - private String reason; - } - - /** - * 결제 환불 요청 DTO - */ - @lombok.Data - @lombok.NoArgsConstructor @lombok.AllArgsConstructor - public static class RefundPaymentRequest { - - @NotBlank(message = "환불 사유는 필수입니다") - private String reason; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/presentation/controller/PgPaymentController.java b/src/main/java/com/sudo/railo/payment/presentation/controller/PgPaymentController.java deleted file mode 100644 index 38816501..00000000 --- a/src/main/java/com/sudo/railo/payment/presentation/controller/PgPaymentController.java +++ /dev/null @@ -1,339 +0,0 @@ -package com.sudo.railo.payment.presentation.controller; - -import com.sudo.railo.payment.domain.entity.PaymentMethod; -import com.sudo.railo.payment.infrastructure.external.pg.PgPaymentService; -import com.sudo.railo.payment.infrastructure.external.pg.dto.*; -import com.sudo.railo.payment.application.service.PaymentService; -import com.sudo.railo.payment.application.service.PaymentCalculationService; -import com.sudo.railo.payment.application.dto.request.PaymentExecuteRequest; -import com.sudo.railo.payment.application.dto.response.PaymentExecuteResponse; -import com.sudo.railo.payment.application.dto.response.PaymentCalculationResponse; -import com.sudo.railo.payment.success.PgPaymentSuccess; -import com.sudo.railo.global.success.SuccessResponse; -import com.sudo.railo.member.infra.MemberRepository; -import com.sudo.railo.member.domain.Member; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; - -import java.math.BigDecimal; - -/** - * PG 결제 연동 컨트롤러 - * 카카오페이, 네이버페이 등 외부 PG 결제 처리 - */ -@Slf4j -@RestController -@RequestMapping("/api/v1/payments/pg") -@RequiredArgsConstructor -public class PgPaymentController { - - private final PgPaymentService pgPaymentService; - private final PaymentService paymentService; - private final PaymentCalculationService paymentCalculationService; - private final MemberRepository memberRepository; - - /** - * PG 결제 요청 (결제창 URL 생성) - * POST /api/v1/payments/pg/request - */ - @PostMapping("/request") - public ResponseEntity> requestPayment(@RequestBody PgPaymentRequestDto request) { - log.debug("PG 결제 요청: paymentMethod={}, orderId={}", request.getPaymentMethod(), request.getMerchantOrderId()); - - // DTO를 PG 요청 객체로 변환 - PgPaymentRequest pgRequest = PgPaymentRequest.builder() - .merchantOrderId(request.getMerchantOrderId()) - .amount(request.getAmount()) - .paymentMethod(request.getPaymentMethod()) - .productName(request.getProductName()) - .buyerName(request.getBuyerName()) - .buyerEmail(request.getBuyerEmail()) - .buyerPhone(request.getBuyerPhone()) - .successUrl(request.getSuccessUrl()) - .failUrl(request.getFailUrl()) - .cancelUrl(request.getCancelUrl()) - .build(); - - PgPaymentResponse response = pgPaymentService.requestPayment(request.getPaymentMethod(), pgRequest); - - return ResponseEntity.ok(SuccessResponse.of(PgPaymentSuccess.PG_PAYMENT_REQUEST_SUCCESS, response)); - } - - /** - * PG 결제 승인 (결제창에서 돌아온 후 최종 승인) - * POST /api/v1/payments/pg/approve - */ - @PostMapping("/approve") - public ResponseEntity> approvePayment(@RequestBody PgPaymentApproveDto request) { - log.debug("PG 결제 승인: paymentMethod={}, tid={}", request.getPaymentMethod(), request.getPgTransactionId()); - log.info("PG 결제 승인 요청 데이터: calculationId={}, merchantOrderId={}, memberId={}", - request.getCalculationId(), request.getMerchantOrderId(), request.getMemberId()); - - // calculationId 검증 추가 - String calculationId = request.getCalculationId(); - if (calculationId == null || calculationId.trim().isEmpty()) { - log.error("calculationId가 null이거나 비어있습니다. request.getId()={}, request.getCalculationId()={}", - request.getId(), request.getCalculationId()); - throw new IllegalArgumentException("calculationId는 필수입니다."); - } - - log.info("calculationId 검증 완료: calculationId={} (getId()={}, getCalculationId()={})", - calculationId, request.getId(), request.getCalculationId()); - - // 비회원 정보 검증 - validateNonMemberInfo(request); - - // 1. PG 결제 승인 - PgPaymentResponse pgResponse = pgPaymentService.approvePayment( - request.getPaymentMethod(), - request.getPgTransactionId(), - request.getMerchantOrderId() - ); - - log.info("PG 승인 완료: success={}, status={}", pgResponse.isSuccess(), pgResponse.getStatus()); - - // PG 승인 완료 후 결제 데이터 저장 - if (pgResponse.isSuccess()) { - try { - // 1. 결제 계산 정보 조회 (마일리지 사용량 포함) - log.info("결제 계산 정보 조회 시작 - calculationId: {} (from request.getCalculationId())", calculationId); - PaymentCalculationResponse calculationResponse = paymentCalculationService.getCalculation(calculationId); - log.info("결제 계산 정보 조회 완료 - calculationId: {}, 마일리지 사용: {}, 최종금액: {}", - calculationResponse.getId(), - calculationResponse.getMileageInfo().getUsedMileage(), - calculationResponse.getFinalPayableAmount()); - - // PaymentExecuteRequest 생성 전 상세 로깅 - log.info("PaymentExecuteRequest 생성 시작 - calculationId: {}, merchantOrderId: {}, memberId: {}", - calculationId, request.getMerchantOrderId(), request.getMemberId()); - log.info("PaymentCalculation 정보 - reservationId: {}, externalOrderId: {}, finalAmount: {}", - calculationResponse.getReservationId(), calculationResponse.getExternalOrderId(), - calculationResponse.getFinalPayableAmount()); - - // 회원 정보 처리: JWT 토큰에서 자동으로 가져오기 - Long actualMemberId = null; - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication != null && authentication.isAuthenticated() && - !"anonymousUser".equals(authentication.getPrincipal())) { - // JWT 토큰에서 memberNo 가져오기 - String memberNo = authentication.getName(); // JWT의 subject (memberNo) - log.info("JWT 토큰에서 회원 정보 추출 - memberNo: {}", memberNo); - - // memberNo로 회원 조회 - if (!"guest_user".equals(memberNo)) { - Member member = memberRepository.findByMemberNo(memberNo) - .orElseThrow(() -> new IllegalArgumentException("회원 정보를 찾을 수 없습니다. memberNo: " + memberNo)); - - actualMemberId = member.getId(); - log.info("회원 정보 확인 완료 - id: {}, memberNo: {}", - member.getId(), memberNo); - } - } else if (request.getMemberId() != null) { - // 폴백: 요청에 memberId가 있는 경우 사용 (하위 호환성) - actualMemberId = request.getMemberId(); - log.info("요청 데이터에서 회원 정보 확인 - memberId: {}", actualMemberId); - - // 회원 존재 여부 확인 - final Long memberIdForLambda = actualMemberId; - Member member = memberRepository.findById(actualMemberId) - .orElseThrow(() -> new IllegalArgumentException("회원 정보를 찾을 수 없습니다. ID: " + memberIdForLambda)); - - log.info("회원 정보 확인 완료 - id: {}, memberNo: {}", - member.getId(), - member.getMemberDetail() != null ? member.getMemberDetail().getMemberNo() : "N/A"); - } else { - log.info("비회원 결제로 처리"); - } - - PaymentExecuteRequest paymentRequest = PaymentExecuteRequest.builder() - .calculationId(calculationId) // request.getId() 대신 calculationId 사용 - .idempotencyKey("pg_" + request.getPgTransactionId()) - .paymentMethod(PaymentExecuteRequest.PaymentMethodInfo.builder() - .type(request.getPaymentMethod().name()) - .pgProvider(request.getPaymentMethod().name()) - .pgToken("mock_token_" + request.getPgTransactionId()) - .build()) - // 회원 정보 설정 (로그인된 경우) - 변환된 실제 ID 사용 - .memberId(actualMemberId) - // 마일리지 정보 추가 (계산 결과에서 가져옴) - .mileageToUse(calculationResponse.getMileageInfo().getUsedMileage()) - .availableMileage(calculationResponse.getMileageInfo().getAvailableMileage()) - // 비회원 정보 설정 (actualMemberId가 없는 경우에만) - .nonMemberName(actualMemberId != null ? null : request.getNonMemberName()) - .nonMemberPhone(actualMemberId != null ? null : request.getNonMemberPhone()) - .nonMemberPassword(actualMemberId != null ? null : request.getNonMemberPassword()) - // 현금영수증 정보 설정 - .requestReceipt(request.getRequestReceipt() != null ? request.getRequestReceipt() : false) - .receiptType(request.getReceiptType()) - .receiptPhoneNumber(request.getReceiptPhoneNumber()) - .businessNumber(request.getBusinessNumber()) - .build(); - - // PaymentExecuteRequest 생성 완료 로깅 - log.info("🎯 PaymentExecuteRequest 생성 완료 - 회원타입: {}, 결제수단: {}, PG제공자: {}", - request.getMemberId() != null ? "회원" : "비회원", - paymentRequest.getPaymentMethod().getType(), - paymentRequest.getPaymentMethod().getPgProvider()); - - // 결제 처리 및 DB 저장 - log.info("💳 PaymentService.executePayment 호출 시작"); - PaymentExecuteResponse paymentResponse = paymentService.executePayment(paymentRequest); - - log.info("✅ 결제 데이터 저장 완료: paymentId={}, status={}, reservationId={}", - paymentResponse.getId(), paymentResponse.getPaymentStatus(), paymentResponse.getReservationId()); - - return ResponseEntity.ok(SuccessResponse.of(PgPaymentSuccess.PG_PAYMENT_APPROVE_SUCCESS, paymentResponse)); - - } catch (Exception e) { - log.error("❌ 결제 데이터 저장 실패 - calculationId: {}, merchantOrderId: {}, 예외타입: {}, 오류메시지: {}", - calculationId, request.getMerchantOrderId(), e.getClass().getName(), e.getMessage(), e); - - // 저장 실패해도 PG는 성공했으므로 응답 처리 - PaymentExecuteResponse errorResponse = PaymentExecuteResponse.builder() - .paymentId(null) - .externalOrderId(request.getMerchantOrderId()) - .paymentStatus(com.sudo.railo.payment.domain.entity.PaymentExecutionStatus.PROCESSING) - .amountPaid(pgResponse.getAmount()) - .result(PaymentExecuteResponse.PaymentResult.builder() - .success(true) - .message("PG 승인 완료 (데이터 저장 재처리 필요)") - .build()) - .build(); - - return ResponseEntity.ok(SuccessResponse.of(PgPaymentSuccess.PG_PAYMENT_APPROVE_SUCCESS, errorResponse)); - } - } else { - // PG 승인 실패 처리 - log.warn("PG 결제 승인 실패: code={}, message={}", pgResponse.getErrorCode(), pgResponse.getErrorMessage()); - - PaymentExecuteResponse errorResponse = PaymentExecuteResponse.builder() - .paymentId(null) - .externalOrderId(request.getMerchantOrderId()) - .paymentStatus(com.sudo.railo.payment.domain.entity.PaymentExecutionStatus.FAILED) - .amountPaid(BigDecimal.ZERO) - .result(PaymentExecuteResponse.PaymentResult.builder() - .success(false) - .errorCode(pgResponse.getErrorCode()) - .message(pgResponse.getErrorMessage() != null ? pgResponse.getErrorMessage() : "결제 승인이 실패했습니다.") - .build()) - .build(); - - return ResponseEntity.ok(SuccessResponse.of(PgPaymentSuccess.PG_PAYMENT_APPROVE_SUCCESS, errorResponse)); - } - } - - /** - * PG 결제 취소/환불 - * POST /api/v1/payments/pg/cancel - */ - @PostMapping("/cancel") - public ResponseEntity> cancelPayment(@RequestBody PgPaymentCancelDto request) { - log.debug("PG 결제 취소: paymentMethod={}, tid={}", request.getPaymentMethod(), request.getPgTransactionId()); - - PgPaymentCancelRequest cancelRequest = PgPaymentCancelRequest.builder() - .pgTransactionId(request.getPgTransactionId()) - .merchantOrderId(request.getMerchantOrderId()) - .cancelAmount(request.getCancelAmount()) - .cancelReason(request.getCancelReason()) - .requestedBy(request.getRequestedBy()) - .build(); - - PgPaymentCancelResponse response = pgPaymentService.cancelPayment(request.getPaymentMethod(), cancelRequest); - - return ResponseEntity.ok(SuccessResponse.of(PgPaymentSuccess.PG_PAYMENT_CANCEL_SUCCESS, response)); - } - - /** - * PG 결제 상태 조회 - * GET /api/v1/payments/pg/status/{paymentMethod}/{pgTransactionId} - */ - @GetMapping("/status/{paymentMethod}/{pgTransactionId}") - public ResponseEntity> getPaymentStatus( - @PathVariable PaymentMethod paymentMethod, - @PathVariable String pgTransactionId) { - log.debug("PG 결제 상태 조회: paymentMethod={}, tid={}", paymentMethod, pgTransactionId); - - PgPaymentResponse response = pgPaymentService.getPaymentStatus(paymentMethod, pgTransactionId); - - return ResponseEntity.ok(SuccessResponse.of(PgPaymentSuccess.PG_PAYMENT_STATUS_SUCCESS, response)); - } - - /** - * 비회원 정보 검증 - */ - private void validateNonMemberInfo(PgPaymentApproveDto request) { - // 비회원인 경우 비회원 정보 검증 - if (request.getMemberId() == null) { - if (request.getNonMemberName() == null || request.getNonMemberName().trim().isEmpty()) { - throw new IllegalArgumentException("비회원 이름은 필수입니다."); - } - if (request.getNonMemberPhone() == null || !request.getNonMemberPhone().matches("^[0-9]{11}$")) { - throw new IllegalArgumentException("전화번호는 11자리 숫자여야 합니다. (예: 01012345678)"); - } - if (request.getNonMemberPassword() == null || !request.getNonMemberPassword().matches("^[0-9]{5}$")) { - throw new IllegalArgumentException("비밀번호는 5자리 숫자여야 합니다. (예: 12345)"); - } - log.debug("비회원 정보 검증 완료"); - } - } - - // === DTO 클래스들 === - - @lombok.Data - public static class PgPaymentRequestDto { - private String merchantOrderId; - private BigDecimal amount; - private PaymentMethod paymentMethod; - private String productName; - private String buyerName; - private String buyerEmail; - private String buyerPhone; - private String successUrl; - private String failUrl; - private String cancelUrl; - } - - @lombok.Data - public static class PgPaymentApproveDto { - private PaymentMethod paymentMethod; - private String pgTransactionId; - private String merchantOrderId; - private String calculationId; - - // 회원 정보 - private Long memberId; // 로그인된 회원 ID (비회원인 경우 null) - - // 비회원 정보 (비회원인 경우 필수) - private String nonMemberName; // 예약자 이름 - private String nonMemberPhone; // 전화번호 - private String nonMemberPassword; // 비밀번호 - - // 현금영수증 정보 - private Boolean requestReceipt; - private String receiptType; // "personal" 또는 "business" - private String receiptPhoneNumber; // 개인 소득공제용 휴대폰 번호 - private String businessNumber; // 사업자 증빙용 사업자등록번호 - - /** - * ID 조회 (하위 호환성) - */ - public String getId() { - return calculationId; - } - } - - @lombok.Data - public static class PgPaymentCancelDto { - private PaymentMethod paymentMethod; - private String pgTransactionId; - private String merchantOrderId; - private BigDecimal cancelAmount; - private String cancelReason; - private String requestedBy; - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/presentation/controller/RefundController.java b/src/main/java/com/sudo/railo/payment/presentation/controller/RefundController.java deleted file mode 100644 index 7719614f..00000000 --- a/src/main/java/com/sudo/railo/payment/presentation/controller/RefundController.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.sudo.railo.payment.presentation.controller; - -import com.sudo.railo.payment.application.dto.request.RefundRequestDto; -import com.sudo.railo.payment.application.dto.response.RefundResponseDto; -import com.sudo.railo.payment.application.service.RefundService; -import com.sudo.railo.payment.domain.entity.RefundCalculation; -import com.sudo.railo.payment.domain.service.RefundCalculationService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import jakarta.validation.Valid; -import java.util.List; -import java.util.stream.Collectors; - -/** - * 환불 관련 REST API Controller - */ -@RestController -@RequestMapping("/api/v1/refunds") -@RequiredArgsConstructor -@Slf4j -public class RefundController { - - private final RefundService refundService; - private final RefundCalculationService refundCalculationService; - - /** - * 환불 계산 및 요청 - */ - @PostMapping("/calculate") - public ResponseEntity calculateRefund(@Valid @RequestBody RefundRequestDto request) { - log.info("환불 계산 요청 - paymentId: {}, refundType: {}", - request.getId(), request.getRefundType()); - - RefundCalculation refundCalculation = refundService.calculateRefund( - request.getId(), - request.getRefundType(), - request.getTrainDepartureTime(), - request.getTrainArrivalTime(), - request.getRefundReason(), - request.getIdempotencyKey() - ); - - RefundResponseDto response = RefundResponseDto.from(refundCalculation, refundCalculationService); - - log.info("환불 계산 완료 - refundCalculationId: {}, refundAmount: {}", - response.getId(), response.getRefundAmount()); - - return ResponseEntity.ok(response); - } - - /** - * 환불 처리 실행 - */ - @PostMapping("/{refundCalculationId}/process") - public ResponseEntity processRefund(@PathVariable Long refundCalculationId) { - log.info("환불 처리 요청 - refundCalculationId: {}", refundCalculationId); - - refundService.processRefund(refundCalculationId); - - log.info("환불 처리 완료 - refundCalculationId: {}", refundCalculationId); - - return ResponseEntity.ok().build(); - } - - /** - * 환불 계산 조회 - */ - @GetMapping("/{refundCalculationId}") - public ResponseEntity getRefundCalculation(@PathVariable Long refundCalculationId) { - log.info("환불 계산 조회 - refundCalculationId: {}", refundCalculationId); - - return refundService.getRefundCalculation(refundCalculationId) - .map(calculation -> RefundResponseDto.from(calculation, refundCalculationService)) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - - /** - * 결제별 환불 계산 조회 - */ - @GetMapping("/payment/{paymentId}") - public ResponseEntity getRefundCalculationByPaymentId(@PathVariable Long paymentId) { - log.info("결제별 환불 계산 조회 - paymentId: {}", paymentId); - - return refundService.getRefundCalculationByPaymentId(paymentId) - .map(calculation -> RefundResponseDto.from(calculation, refundCalculationService)) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - - /** - * 회원별 환불 내역 조회 - */ - @GetMapping("/member/{memberId}") - public ResponseEntity> getRefundHistoryByMember(@PathVariable Long memberId) { - log.info("회원별 환불 내역 조회 - memberId: {}", memberId); - - List refundHistory = refundService.getRefundHistoryByMember(memberId); - List response = refundHistory.stream() - .map(calculation -> RefundResponseDto.from(calculation, refundCalculationService)) - .collect(Collectors.toList()); - - log.info("회원별 환불 내역 조회 완료 - memberId: {}, count: {}", memberId, response.size()); - - return ResponseEntity.ok(response); - } - - /** - * 처리 대기 중인 환불 목록 조회 (관리자용) - */ - @GetMapping("/pending") - public ResponseEntity> getPendingRefunds() { - log.info("처리 대기 중인 환불 목록 조회"); - - List pendingRefunds = refundService.getPendingRefunds(); - List response = pendingRefunds.stream() - .map(calculation -> RefundResponseDto.from(calculation, refundCalculationService)) - .collect(Collectors.toList()); - - log.info("처리 대기 중인 환불 목록 조회 완료 - count: {}", response.size()); - - return ResponseEntity.ok(response); - } - - /** - * 환불 취소 - */ - @PostMapping("/{refundCalculationId}/cancel") - public ResponseEntity cancelRefund( - @PathVariable Long refundCalculationId, - @RequestParam String reason) { - log.info("환불 취소 요청 - refundCalculationId: {}, reason: {}", refundCalculationId, reason); - - refundService.cancelRefund(refundCalculationId, reason); - - log.info("환불 취소 완료 - refundCalculationId: {}", refundCalculationId); - - return ResponseEntity.ok().build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/presentation/controller/SavedPaymentMethodController.java b/src/main/java/com/sudo/railo/payment/presentation/controller/SavedPaymentMethodController.java deleted file mode 100644 index 142b15d0..00000000 --- a/src/main/java/com/sudo/railo/payment/presentation/controller/SavedPaymentMethodController.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.sudo.railo.payment.presentation.controller; - -import com.sudo.railo.payment.application.dto.request.CreateSavedPaymentMethodRequest; -import com.sudo.railo.payment.application.dto.SavedPaymentMethodRequestDto; -import com.sudo.railo.payment.application.dto.SavedPaymentMethodResponseDto; -import com.sudo.railo.payment.application.service.SavedPaymentMethodService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; - -import java.util.List; - -/** - * 저장된 결제수단 관리 API 컨트롤러 - * - * 보안이 강화된 결제수단 저장/조회 API - */ -@Slf4j -@RestController -@RequestMapping("/api/v1/saved-payment-methods") -@RequiredArgsConstructor -@Tag(name = "SavedPaymentMethod", description = "저장된 결제수단 관리 API") -public class SavedPaymentMethodController { - - private final SavedPaymentMethodService savedPaymentMethodService; - - /** - * 새로운 결제수단 저장 - * JWT 토큰에서 memberId를 자동으로 추출하여 요청 DTO에 설정 - */ - @PostMapping - @PreAuthorize("isAuthenticated()") - @Operation(summary = "결제수단 저장", description = "새로운 결제수단을 암호화하여 저장합니다.") - public ResponseEntity savePaymentMethod( - @AuthenticationPrincipal UserDetails userDetails, - @Valid @RequestBody CreateSavedPaymentMethodRequest request) { - - // CreateSavedPaymentMethodRequest를 SavedPaymentMethodRequestDto로 변환 - SavedPaymentMethodRequestDto dto = SavedPaymentMethodRequestDto.builder() - .memberId(null) // Service에서 UserDetails로 memberId 설정 - .paymentMethodType(request.getPaymentMethodType()) - .alias(request.getAlias()) - .cardNumber(request.getCardNumber()) - .cardHolderName(request.getCardHolderName()) - .cardExpiryMonth(request.getCardExpiryMonth()) - .cardExpiryYear(request.getCardExpiryYear()) - .cardCvc(request.getCardCvc()) - .bankCode(request.getBankCode()) - .accountNumber(request.getAccountNumber()) - .accountHolderName(request.getAccountHolderName()) - .accountPassword(request.getAccountPassword()) - .isDefault(request.getIsDefault()) - .build(); - - log.info("Save payment method request for memberNo: {}", userDetails.getUsername()); - SavedPaymentMethodResponseDto response = savedPaymentMethodService.savePaymentMethod(dto, userDetails); - - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - /** - * 회원의 저장된 결제수단 목록 조회 (마스킹된 정보) - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @GetMapping - @PreAuthorize("isAuthenticated()") - @Operation(summary = "결제수단 목록 조회", description = "회원의 저장된 결제수단 목록을 조회합니다. 민감정보는 마스킹됩니다.") - public ResponseEntity> getPaymentMethods( - @AuthenticationPrincipal UserDetails userDetails) { - - log.debug("Get payment methods for memberNo: {}", userDetails.getUsername()); - List methods = savedPaymentMethodService.getPaymentMethods(userDetails); - - return ResponseEntity.ok(methods); - } - - /** - * 결제 실행을 위한 결제수단 상세 조회 (원본 정보) - * JWT 토큰에서 memberId를 자동으로 추출 - * 실제 결제 실행 시에만 사용하며, 특별한 권한 검증과 감사 로그를 남김 - */ - @GetMapping("/{id}/raw") - @PreAuthorize("isAuthenticated()") - @Operation(summary = "결제수단 상세 조회 (결제용)", - description = "실제 결제를 위한 결제수단 정보를 조회합니다. 복호화된 원본 정보가 반환됩니다.") - public ResponseEntity getPaymentMethodForPayment( - @AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "결제수단 ID") @PathVariable Long id, - @Parameter(description = "결제 세션 ID") @RequestParam(required = false) String sessionId) { - - log.info("Get payment method for payment - ID: {}, MemberNo: {}, Session: {}", id, userDetails.getUsername(), sessionId); - SavedPaymentMethodResponseDto method = savedPaymentMethodService.getPaymentMethodForPayment(id, userDetails); - - return ResponseEntity.ok(method); - } - - /** - * 결제수단 삭제 - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @DeleteMapping("/{id}") - @PreAuthorize("isAuthenticated()") - @Operation(summary = "결제수단 삭제", description = "저장된 결제수단을 삭제합니다.") - public ResponseEntity deletePaymentMethod( - @AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "결제수단 ID") @PathVariable Long id) { - - log.info("Delete payment method - ID: {}, MemberNo: {}", id, userDetails.getUsername()); - savedPaymentMethodService.deletePaymentMethod(id, userDetails); - - return ResponseEntity.noContent().build(); - } - - /** - * 기본 결제수단 설정 - * JWT 토큰에서 memberId를 자동으로 추출 - */ - @PutMapping("/{id}/default") - @PreAuthorize("isAuthenticated()") - @Operation(summary = "기본 결제수단 설정", description = "특정 결제수단을 기본 결제수단으로 설정합니다.") - public ResponseEntity setDefaultPaymentMethod( - @AuthenticationPrincipal UserDetails userDetails, - @Parameter(description = "결제수단 ID") @PathVariable Long id) { - - log.info("Set default payment method - ID: {}, MemberNo: {}", id, userDetails.getUsername()); - savedPaymentMethodService.setDefaultPaymentMethod(id, userDetails); - - return ResponseEntity.ok().build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/success/MileageSuccess.java b/src/main/java/com/sudo/railo/payment/success/MileageSuccess.java deleted file mode 100644 index f17bba0a..00000000 --- a/src/main/java/com/sudo/railo/payment/success/MileageSuccess.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.sudo.railo.payment.success; - -import com.sudo.railo.global.success.SuccessCode; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum MileageSuccess implements SuccessCode { - - MILEAGE_BALANCE_SUCCESS(HttpStatus.OK, "마일리지 잔액 조회가 성공적으로 완료되었습니다."), - MILEAGE_AVAILABLE_SUCCESS(HttpStatus.OK, "사용 가능한 마일리지 조회가 성공적으로 완료되었습니다."), - MILEAGE_STATISTICS_SUCCESS(HttpStatus.OK, "마일리지 통계 조회가 성공적으로 완료되었습니다."), - MILEAGE_EARNING_SUCCESS(HttpStatus.OK, "마일리지 적립이 성공적으로 완료되었습니다."), - MILEAGE_USAGE_SUCCESS(HttpStatus.OK, "마일리지 사용이 성공적으로 완료되었습니다."); - - private final HttpStatus status; - private final String message; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/success/PaymentCalculationSuccess.java b/src/main/java/com/sudo/railo/payment/success/PaymentCalculationSuccess.java deleted file mode 100644 index d4463a1c..00000000 --- a/src/main/java/com/sudo/railo/payment/success/PaymentCalculationSuccess.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.sudo.railo.payment.success; - -import com.sudo.railo.global.success.SuccessCode; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum PaymentCalculationSuccess implements SuccessCode { - - PAYMENT_CALCULATION_SUCCESS(HttpStatus.OK, "결제 계산이 완료되었습니다."), - PAYMENT_CALCULATION_RETRIEVAL_SUCCESS(HttpStatus.OK, "결제 계산 정보를 조회했습니다."); - - private final HttpStatus status; - private final String message; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/success/PaymentSuccess.java b/src/main/java/com/sudo/railo/payment/success/PaymentSuccess.java index 2ba75f08..5bec62e5 100644 --- a/src/main/java/com/sudo/railo/payment/success/PaymentSuccess.java +++ b/src/main/java/com/sudo/railo/payment/success/PaymentSuccess.java @@ -1,30 +1,24 @@ package com.sudo.railo.payment.success; +import org.springframework.http.HttpStatus; + import com.sudo.railo.global.success.SuccessCode; -import lombok.AllArgsConstructor; + import lombok.Getter; -import org.springframework.http.HttpStatus; +import lombok.RequiredArgsConstructor; +@RequiredArgsConstructor @Getter -@AllArgsConstructor public enum PaymentSuccess implements SuccessCode { - - // 마일리지 관련 성공 메시지 - MILEAGE_INQUIRY_SUCCESS(HttpStatus.OK, "마일리지 조회가 성공적으로 완료되었습니다."), - MILEAGE_SCHEDULE_INQUIRY_SUCCESS(HttpStatus.OK, "마일리지 적립 스케줄 조회가 성공적으로 완료되었습니다."), - MILEAGE_TRANSACTION_INQUIRY_SUCCESS(HttpStatus.OK, "마일리지 거래 내역 조회가 성공적으로 완료되었습니다."), - - // 관리자 기능 성공 메시지 - TRAIN_ARRIVAL_RECORDED_SUCCESS(HttpStatus.OK, "열차 도착 기록이 성공적으로 완료되었습니다."), - TRAIN_DELAY_RECORDED_SUCCESS(HttpStatus.OK, "열차 지연 기록이 성공적으로 완료되었습니다."), - STATISTICS_INQUIRY_SUCCESS(HttpStatus.OK, "통계 조회가 성공적으로 완료되었습니다."), - BATCH_PROCESS_SUCCESS(HttpStatus.OK, "배치 처리가 성공적으로 완료되었습니다."), - DATA_CLEANUP_SUCCESS(HttpStatus.OK, "데이터 정리가 성공적으로 완료되었습니다."), - EVENT_RECOVERY_SUCCESS(HttpStatus.OK, "이벤트 복구가 성공적으로 완료되었습니다."), - - // 일반적인 성공 메시지 - INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."); - - private final HttpStatus status; - private final String message; -} \ No newline at end of file + + // 결제 처리 관련 + PAYMENT_PROCESS_SUCCESS(HttpStatus.OK, "결제가 성공적으로 처리되었습니다."), + PAYMENT_CANCEL_SUCCESS(HttpStatus.OK, "결제가 성공적으로 취소되었습니다."), + + // 결제 조회 관련 + PAYMENT_HISTORY_SUCCESS(HttpStatus.OK, "결제 내역을 성공적으로 조회했습니다."), + PAYMENT_DETAIL_SUCCESS(HttpStatus.OK, "결제 상세 정보를 성공적으로 조회했습니다."); + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/sudo/railo/payment/success/PgPaymentSuccess.java b/src/main/java/com/sudo/railo/payment/success/PgPaymentSuccess.java deleted file mode 100644 index 5db3dc91..00000000 --- a/src/main/java/com/sudo/railo/payment/success/PgPaymentSuccess.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.sudo.railo.payment.success; - -import org.springframework.http.HttpStatus; -import com.sudo.railo.global.success.SuccessCode; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum PgPaymentSuccess implements SuccessCode { - - PG_PAYMENT_REQUEST_SUCCESS(HttpStatus.OK, "PG 결제 요청이 성공적으로 처리되었습니다."), - PG_PAYMENT_APPROVE_SUCCESS(HttpStatus.OK, "PG 결제 승인이 성공적으로 완료되었습니다."), - PG_PAYMENT_CANCEL_SUCCESS(HttpStatus.OK, "PG 결제 취소가 성공적으로 처리되었습니다."), - PG_PAYMENT_STATUS_SUCCESS(HttpStatus.OK, "PG 결제 상태 조회가 성공적으로 완료되었습니다."); - - private final HttpStatus status; - private final String message; -} \ No newline at end of file diff --git a/src/main/java/com/sudo/railo/payment/util/PaymentKeyGenerator.java b/src/main/java/com/sudo/railo/payment/util/PaymentKeyGenerator.java new file mode 100644 index 00000000..c8b60f23 --- /dev/null +++ b/src/main/java/com/sudo/railo/payment/util/PaymentKeyGenerator.java @@ -0,0 +1,47 @@ +package com.sudo.railo.payment.util; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PaymentKeyGenerator { + + private static final String KEY_PREFIX = "paymentKey:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + + private final RedisTemplate stringRedisTemplate; + + public String generatePaymentKey(String memberNo) { + String today = LocalDateTime.now().format(DATE_FORMATTER); + String redisKey = KEY_PREFIX + today; + + Long counter = stringRedisTemplate.opsForValue().increment(redisKey); + + // 키에 만료 시간이 설정되어 있지 않은 경우에만 만료 시간 설정 + Long ttl = stringRedisTemplate.getExpire(redisKey); + if (ttl == -1) { + ZonedDateTime midnightToday = + ZonedDateTime.of(LocalDate.now(ZONE_ID), LocalTime.MAX, ZONE_ID); + Instant midnightInstant = midnightToday.toInstant(); + + // 자정 만료 + stringRedisTemplate.expireAt(redisKey, midnightInstant); + } + + String convertedCounter = String.format("%03d", counter); + + return String.format("%s-%s-%s", today, memberNo, convertedCounter); + } +} diff --git a/src/main/java/com/sudo/railo/train/application/TrainSearchApplicationService.java b/src/main/java/com/sudo/railo/train/application/TrainSearchApplicationService.java index ffd633d6..03e3c197 100644 --- a/src/main/java/com/sudo/railo/train/application/TrainSearchApplicationService.java +++ b/src/main/java/com/sudo/railo/train/application/TrainSearchApplicationService.java @@ -2,15 +2,19 @@ import java.util.List; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.sudo.railo.train.application.dto.TrainScheduleBasicInfo; import com.sudo.railo.train.application.dto.request.TrainCarListRequest; import com.sudo.railo.train.application.dto.request.TrainCarSeatDetailRequest; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.OperationCalendarItem; import com.sudo.railo.train.application.dto.response.TrainCarInfo; import com.sudo.railo.train.application.dto.response.TrainCarListResponse; import com.sudo.railo.train.application.dto.response.TrainCarSeatDetailResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,9 +25,27 @@ @Slf4j public class TrainSearchApplicationService { - private final TrainScheduleService trainScheduleService; + private final TrainSearchService trainSearchService; private final TrainSeatQueryService trainCarService; + /** + * 운행 캘린더 조회 + */ + public List getOperationCalendar() { + return trainSearchService.getOperationCalendar(); + } + + /** + * 통합 열차 조회 (열차 스케줄 검색) + */ + public TrainSearchSlicePageResponse searchTrains(TrainSearchRequest request, Pageable pageable) { + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, pageable); + + log.info("열차 검색 완료: {} 건 조회, hasNext: {}", response.numberOfElements(), response.hasNext()); + + return response; + } + /** * 열차 객차 목록 조회 (잔여 좌석이 있는 객차만) */ @@ -33,7 +55,7 @@ public TrainCarListResponse getAvailableTrainCars(TrainCarListRequest request) { request.arrivalStationId(), request.passengerCount()); // 1. 열차 스케줄 기본 정보 조회 - TrainScheduleBasicInfo scheduleInfo = trainScheduleService.getTrainScheduleBasicInfo(request.trainScheduleId()); + TrainScheduleBasicInfo scheduleInfo = trainSearchService.getTrainScheduleBasicInfo(request.trainScheduleId()); // 2. 잔여 좌석이 있는 객차 목록 조회 List availableCars = trainCarService.getAvailableTrainCars( @@ -47,6 +69,7 @@ public TrainCarListResponse getAvailableTrainCars(TrainCarListRequest request) { scheduleInfo.trainClassificationCode(), scheduleInfo.trainNumber()); return TrainCarListResponse.of( + request.trainScheduleId(), recommendedCarNumber, availableCars.size(), scheduleInfo.trainClassificationCode(), @@ -73,12 +96,17 @@ public TrainCarSeatDetailResponse getTrainCarSeatDetail(TrainCarSeatDetailReques * TODO: 조금 더 고도화된 객차 추천 알고리즘 필요 */ private String selectRecommendedCar(List availableCars, int passengerCount) { - // 승객 수보다 잔여 좌석이 많은 객차 중에서 중간 위치 선택 - return availableCars.stream() + // 승객 수보다 잔여 좌석이 많은 객차 필터링 + List suitableCars = availableCars.stream() .filter(car -> car.remainingSeats() >= passengerCount) - .skip(availableCars.size() / 2) // 중간 객차 선택 - .findFirst() - .map(TrainCarInfo::carNumber) - .orElse(availableCars.get(0).carNumber()); // 없으면 첫 번째 객차 + .toList(); + + // 적합한 객차가 있으면 중간 위치 선택, 없으면 첫 번째 객차 + if (!suitableCars.isEmpty()) { + int middleIndex = suitableCars.size() / 2; + return suitableCars.get(middleIndex).carNumber(); + } + + return availableCars.get(0).carNumber(); } } diff --git a/src/main/java/com/sudo/railo/train/application/TrainScheduleService.java b/src/main/java/com/sudo/railo/train/application/TrainSearchService.java similarity index 55% rename from src/main/java/com/sudo/railo/train/application/TrainScheduleService.java rename to src/main/java/com/sudo/railo/train/application/TrainSearchService.java index 2120916c..79fab6a3 100644 --- a/src/main/java/com/sudo/railo/train/application/TrainScheduleService.java +++ b/src/main/java/com/sudo/railo/train/application/TrainSearchService.java @@ -19,6 +19,7 @@ import com.sudo.railo.train.application.dto.SectionSeatStatus; import com.sudo.railo.train.application.dto.TrainBasicInfo; import com.sudo.railo.train.application.dto.TrainScheduleBasicInfo; +import com.sudo.railo.train.application.dto.projection.TrainSeatInfoBatch; import com.sudo.railo.train.application.dto.request.TrainSearchRequest; import com.sudo.railo.train.application.dto.response.OperationCalendarItem; import com.sudo.railo.train.application.dto.response.SeatTypeInfo; @@ -43,10 +44,12 @@ @RequiredArgsConstructor @Transactional(readOnly = true) @Slf4j -public class TrainScheduleService { +public class TrainSearchService { @Value("${train.standing.ratio:0.15}") // 기본값 0.15 - private double standingRatio; //TODO: 열차 종류별 다른 % 적용 + private double standingRatio; // 입석 좌석 개수 비율 + private static final double STANDING_FARE_DISCOUNT_RATE = 0.15; + private static final int SEAT_BUFFER_THRESHOLD = 20; // 여유석 판단 기준 private final TrainSearchValidator trainSearchValidator; private final TrainScheduleRepository trainScheduleRepository; @@ -57,7 +60,7 @@ public class TrainScheduleService { /** * 운행 캘린더 조회 - * @return + * @return List */ public List getOperationCalendar() { LocalDate startDate = LocalDate.now(); @@ -75,7 +78,7 @@ public List getOperationCalendar() { }) .toList(); - log.info("운행 캘린더 조회 완료: {} ~ {} ({} 일), 운행일수: {}", + log.info("운행 캘린더 조회 : {} ~ {} ({} 일), 운행일수: {}", startDate, endDate, calendar.size(), datesWithSchedule.size()); return calendar; @@ -116,17 +119,14 @@ private boolean isHoliday(LocalDate date) { * 4. 최종 조회 결과 반환 */ public TrainSearchSlicePageResponse searchTrains(TrainSearchRequest request, Pageable pageable) { - log.info("열차 조회 시작: {} -> {}, {}, 승객: {}명, 출발 시간: {}시 이후", - request.departureStationId(), request.arrivalStationId(), - request.operationDate(), request.passengerCount(), request.departureHour()); - + // request 검증 (route, operationDate, departureTime) trainSearchValidator.validateTrainSearchRequest(request); // 1. 조회 조건에 맞는 기본 열차 정보 조회 - Slice trainSlice = findTrainBasicInfo(request, pageable); + Slice trainInfoSlice = findTrainBasicInfo(request, pageable); // 2. 빈 결과 처리 - 정상 응답으로 반환 - if (trainSlice.isEmpty()) { + if (trainInfoSlice.isEmpty()) { log.info("열차 조회 결과 없음: {}역 -> {}역, {}, {}시 이후 - 빈 결과 반환", request.departureStationId(), request.arrivalStationId(), request.operationDate(), request.departureHour()); @@ -137,12 +137,12 @@ public TrainSearchSlicePageResponse searchTrains(TrainSearchRequest request, Pag StationFare fare = findStationFare(request.departureStationId(), request.arrivalStationId()); // 4. 각 열차별 좌석 상태 계산 및 응답 생성 - List trainSearchResults = processTrainSearchResults(trainSlice.getContent(), fare, + List trainSearchResults = processTrainSearchResults(trainInfoSlice.getContent(), fare, request); - log.info("Slice 기반 열차 조회 완료: {}건 조회, hasNext: {}", trainSearchResults.size(), trainSlice.hasNext()); + log.info("Slice 기반 열차 조회: {}건 조회, hasNext: {}", trainSearchResults.size(), trainInfoSlice.hasNext()); - return createTrainSearchPageResponse(trainSearchResults, trainSlice); + return createTrainSearchPageResponse(trainSearchResults, trainInfoSlice); } /** @@ -193,18 +193,60 @@ private StationFare findStationFare(Long departureStationId, Long arrivalStation } /** - * 열차 조회 결과 일괄 처리 (각 열차별로 좌석 상태 계산) - * @param trainInfos 기본 열차 정보 리스트 - * @param fare 구간 요금 정보 - * @param request 조회 요청 정보 - * @return 좌석 상태가 포함된 열차 조회 결과 + * 열차 조회 결과 일괄 처리 (배치 쿼리 사용) + * 모든 열차의 데이터를 배치로 조회한 후 개별 처리 */ - private List processTrainSearchResults(List trainInfos, StationFare fare, - TrainSearchRequest request) { + private List processTrainSearchResults(List trainInfoSlice, + StationFare fare, TrainSearchRequest request) { + + // 1. trainScheduleId 리스트 추출 + List trainScheduleIds = trainInfoSlice.stream() + .map(TrainBasicInfo::trainScheduleId) + .toList(); - List results = trainInfos.stream() - .map(trainInfo -> processIndividualTrain(trainInfo, fare, request)) // 각 개별 열차 처리 - .filter(Objects::nonNull) // 처리 실패한 열차 제외 + log.info("배치 쿼리 시작: {}건의 열차 일괄 처리", trainScheduleIds.size()); + + // 2. 배치 쿼리로 모든 데이터 한번에 조회 + // 2-1. 좌석 정보 통합 조회 (기존 쿼리 2개 → 1개) + TrainSeatInfoBatch seatInfoBatch = trainScheduleRepositoryCustom + .findTrainSeatInfoBatch(trainScheduleIds); + + // 2-2. 겹치는 예약 조회 + Map> overlappingReservationsMap = + seatReservationRepositoryCustom.findOverlappingReservationsBatch( + trainScheduleIds, request.departureStationId(), request.arrivalStationId()); + + // 2-3. 열차의 입석 예약 수 조회 + Map standingReservationsMap = + seatReservationRepositoryCustom.countOverlappingStandingReservationsBatch( + trainScheduleIds, request.departureStationId(), request.arrivalStationId()); + + // 3. 각 열차별로 배치 조회된 데이터를 사용해 응답 생성 + List results = trainInfoSlice.stream() + .map(trainInfo -> { + try { + Long trainScheduleId = trainInfo.trainScheduleId(); + + List overlappingReservations = + overlappingReservationsMap.getOrDefault(trainScheduleId, List.of()); + Map totalSeatsByCarType = + seatInfoBatch.getSeatsCountByCarType(trainScheduleId); + Integer totalSeatCount = + seatInfoBatch.getTotalSeatsCount(trainScheduleId); + Integer standingReservations = standingReservationsMap.getOrDefault(trainScheduleId, 0); + + // 좌석 상태 계산 (일반실, 특실, 입석) + SectionSeatStatus sectionStatus = calculateSectionSeatStatusWithBatchData( + overlappingReservations, totalSeatsByCarType, totalSeatCount, + standingReservations, request.passengerCount()); + + return createTrainSearchResponse(trainInfo, sectionStatus, fare, request.passengerCount()); + } catch (Exception e) { + log.warn("열차 {} 처리 실패: {}", trainInfo.trainNumber(), e.getMessage()); + return null; + } + }) + .filter(Objects::nonNull) .toList(); if (results.isEmpty()) { @@ -212,7 +254,7 @@ private List processTrainSearchResults(List throw new BusinessException(TrainErrorCode.NO_SEARCH_RESULTS); } - log.info("열차 조회 완료: 전체 {}건 중 {}건 처리 성공", trainInfos.size(), results.size()); + log.info("배치 처리 완료: 전체 {}건 중 {}건 성공", trainInfoSlice.size(), results.size()); return results; } @@ -228,62 +270,43 @@ private TrainSearchSlicePageResponse createTrainSearchPageResponse(List 승객 수) 인 경우 + boolean shouldShowStanding = !sectionStatus.canReserveStandard() + && sectionStatus.canReserveStanding(passengerCount, standingRatio); if (shouldShowStanding) { - int standingFare = (int)(fare.getStandardFare() * 0.9); - return StandingTypeInfo.create(sectionStatus.maxAdditionalStanding(), 50, standingFare); + int standingFare = (int)(fare.getStandardFare() * (1.0 - STANDING_FARE_DISCOUNT_RATE)); + + return StandingTypeInfo.create( + sectionStatus.getStandingRemaining(standingRatio), + sectionStatus.getMaxStandingCapacity(standingRatio), + standingFare + ); } return null; } - // ============================================ - // 좌석 계산 및 상태 판정 메소드 - // ============================================ - /** - * 구간별 좌석 상태 종합 계산 + * 배치로 조회된 데이터를 사용해 좌석 상태 계산 */ - private SectionSeatStatus calculateSectionSeatStatus(Long trainScheduleId, Long departureStationId, - Long arrivalStationId, int passengerCount) { - - // 겹치는 예약 정보 조회 - List overlappingReservations = seatReservationRepositoryCustom.findOverlappingReservations( - trainScheduleId, departureStationId, arrivalStationId); + private SectionSeatStatus calculateSectionSeatStatusWithBatchData( + List overlappingReservations, + Map totalSeats, + Integer totalSeatCount, + Integer standingReservations, + int requestedPassengerCount) { - // 열차 별 좌석 수 조회 - Map totalSeats = trainScheduleRepositoryCustom.findTotalSeatsByCarType(trainScheduleId); + // 1. 좌석 계산 + SeatCalculationResult seatResult = calculateRemainingSeats(totalSeats, overlappingReservations); - // 좌석 계산 (일반 좌석, 입석) - SeatCalculationResult seatResult = calculateAvailableSeats(totalSeats, overlappingReservations); - StandingCalculationResult standingResult = calculateStandingAvailability(trainScheduleId, departureStationId, - arrivalStationId, passengerCount); + // 2. 입석 계산 + int maxAllowedStandingCount = (int)(totalSeatCount * standingRatio); + int remainingStanding = Math.max(0, maxAllowedStandingCount - standingReservations); - boolean canReserveStandard = seatResult.standardAvailable() >= passengerCount; - boolean canReserveFirstClass = seatResult.firstClassAvailable() >= passengerCount; - boolean canReserveStanding = standingResult.canReserveStanding(); + // 3. 예약 가능 여부 판단 + boolean canReserveStandard = seatResult.standardRemaining() >= requestedPassengerCount; + boolean canReserveFirstClass = seatResult.firstClassRemaining() >= requestedPassengerCount; return SectionSeatStatus.of( - seatResult.standardAvailable(), seatResult.standardTotal(), - seatResult.firstClassAvailable(), seatResult.firstClassTotal(), + seatResult.standardRemaining(), seatResult.standardTotal(), + seatResult.firstClassRemaining(), seatResult.firstClassTotal(), canReserveStandard, canReserveFirstClass, - standingResult.standingAvailable(), standingResult.maxAdditionalStanding(), - canReserveStanding, standingResult.maxOccupancyInRoute() + standingReservations, totalSeatCount ); } /** * 좌석 타입별 잔여 좌석 계산 */ - private SeatCalculationResult calculateAvailableSeats(Map totalSeats, + private SeatCalculationResult calculateRemainingSeats(Map totalSeats, List overlappingReservations) { // 총 좌석 수 int standardTotal = totalSeats.getOrDefault(CarType.STANDARD, 0); int firstClassTotal = totalSeats.getOrDefault(CarType.FIRST_CLASS, 0); // 예약된 좌석 수 계산 - Map occupiedSeats = overlappingReservations.stream() + Map reservedSeats = overlappingReservations.stream() .collect(Collectors.groupingBy(SeatReservationInfo::carType, Collectors.counting())); - int standardOccupied = occupiedSeats.getOrDefault(CarType.STANDARD, 0L).intValue(); - int firstClassOccupied = occupiedSeats.getOrDefault(CarType.FIRST_CLASS, 0L).intValue(); + int standardReserved = reservedSeats.getOrDefault(CarType.STANDARD, 0L).intValue(); + int firstClassReserved = reservedSeats.getOrDefault(CarType.FIRST_CLASS, 0L).intValue(); return new SeatCalculationResult( - Math.max(0, standardTotal - standardOccupied), standardTotal, - Math.max(0, firstClassTotal - firstClassOccupied), firstClassTotal + Math.max(0, standardTotal - standardReserved), standardTotal, + Math.max(0, firstClassTotal - firstClassReserved), firstClassTotal ); } - /** - * 입석 가능 여부 및 수량 계산 - */ - private StandingCalculationResult calculateStandingAvailability(Long trainScheduleId, Long departureStationId, - Long arrivalStationId, int passengerCount) { - try { - int totalSeats = trainScheduleRepositoryCustom.findTotalSeatsByTrainScheduleId(trainScheduleId); - - int maxAllowedStandingCount = (int)(totalSeats * standingRatio); - - // 현재 구간에서 예약된 입석 수 조회, 추가 입석 가능 인원 수 계산 - int currentStandingReservations = seatReservationRepositoryCustom.countOverlappingStandingReservations( - trainScheduleId, departureStationId, arrivalStationId); - int maxAdditionalStanding = Math.max(0, maxAllowedStandingCount - currentStandingReservations); - - boolean standingAvailable = maxAdditionalStanding > 0; - boolean canReserveStanding = maxAdditionalStanding >= passengerCount; - - log.debug("입석 계산 완료: 총허용={}, 현재예약={}, 추가가능={}, 예약가능={}", - maxAllowedStandingCount, currentStandingReservations, maxAdditionalStanding, canReserveStanding); - - return new StandingCalculationResult(standingAvailable, maxAdditionalStanding, canReserveStanding, - currentStandingReservations); - } catch (Exception e) { - log.warn("입석 계산 중 오류: trainScheduleId={}", trainScheduleId, e); - return new StandingCalculationResult(false, 0, false, 0); - } - } - // ============================================ // Service Layer 전용 내부 Records // ============================================ private record SeatCalculationResult( - int standardAvailable, int standardTotal, - int firstClassAvailable, int firstClassTotal + int standardRemaining, int standardTotal, + int firstClassRemaining, int firstClassTotal ) { } - - private record StandingCalculationResult( - boolean standingAvailable, int maxAdditionalStanding, - boolean canReserveStanding, int maxOccupancyInRoute - ) { - } - - // ============================================ - // Payment 도메인을 위한 추가 메서드 - // ============================================ - - /** - * 열차 시간 정보 조회 - * @param trainScheduleId 열차 스케줄 ID - * @return 출발/도착 시간 정보 - */ - public TrainTimeInfo getTrainTimeInfo(Long trainScheduleId) { - TrainSchedule schedule = trainScheduleRepository.findById(trainScheduleId) - .orElseThrow(() -> new BusinessException(TrainErrorCode.TRAIN_SCHEDULE_NOT_FOUND)); - - java.time.LocalDateTime departureTime = schedule.getOperationDate().atTime(schedule.getDepartureTime()); - java.time.LocalDateTime scheduledArrivalTime = schedule.getOperationDate().atTime(schedule.getArrivalTime()); - java.time.LocalDateTime actualArrivalTime = schedule.getActualArrivalTime(); - - return new TrainTimeInfo( - departureTime, - scheduledArrivalTime, - actualArrivalTime, - schedule.getDelayMinutes() - ); - } - - /** - * 경로 정보 조회 - * @param trainScheduleId 열차 스케줄 ID - * @return 출발역-도착역 형식의 경로 정보 - */ - public String getRouteInfo(Long trainScheduleId) { - TrainSchedule schedule = trainScheduleRepository.findById(trainScheduleId) - .orElseThrow(() -> new BusinessException(TrainErrorCode.TRAIN_SCHEDULE_NOT_FOUND)); - - return schedule.getDepartureStation().getStationName() + "-" + schedule.getArrivalStation().getStationName(); - } - - /** - * 열차 시간 정보 내부 클래스 - */ - public static record TrainTimeInfo( - java.time.LocalDateTime departureTime, - java.time.LocalDateTime scheduledArrivalTime, - java.time.LocalDateTime actualArrivalTime, - int delayMinutes - ) {} } diff --git a/src/main/java/com/sudo/railo/train/application/TrainService.java b/src/main/java/com/sudo/railo/train/application/TrainService.java index 344cdea8..eeba95d2 100644 --- a/src/main/java/com/sudo/railo/train/application/TrainService.java +++ b/src/main/java/com/sudo/railo/train/application/TrainService.java @@ -29,7 +29,7 @@ public class TrainService { @Transactional(readOnly = true) public Map getTrainMap() { - return trainRepository.findAllWithCars().stream() + return trainRepository.findAll().stream() .collect(Collectors.toMap(Train::getTrainNumber, Function.identity())); } @@ -38,7 +38,7 @@ public Map getTrainMap() { */ @Transactional public Map findOrCreateTrains(List trainData) { - Map trainMap = findExistingTrains(); + Map trainMap = getTrainMap(); // 열차 생성 List trains = trainData.stream() @@ -62,14 +62,6 @@ public Map findOrCreateTrains(List trainData) { return getTrainMap(); } - /** - * 이미 존재하는 열차 조회 - */ - private Map findExistingTrains() { - return trainRepository.findAllWithCars().stream() - .collect(Collectors.toMap(Train::getTrainNumber, Function.identity())); - } - /** * 열차 ID를 가져오기 위한 메서드 */ diff --git a/src/main/java/com/sudo/railo/train/application/dto/SectionSeatStatus.java b/src/main/java/com/sudo/railo/train/application/dto/SectionSeatStatus.java index 40ab0304..3c962fe0 100644 --- a/src/main/java/com/sudo/railo/train/application/dto/SectionSeatStatus.java +++ b/src/main/java/com/sudo/railo/train/application/dto/SectionSeatStatus.java @@ -15,53 +15,75 @@ public record SectionSeatStatus( // 일반실 정보 @Schema(description = "일반실 잔여 좌석 수", example = "45") - int standardAvailable, + int standardRemaining, @Schema(description = "일반실 전체 좌석 수", example = "246") int standardTotal, // 특실 정보 @Schema(description = "특실 잔여 좌석 수", example = "12") - int firstClassAvailable, + int firstClassRemaining, @Schema(description = "특실 전체 좌석 수", example = "117") int firstClassTotal, // 예약 가능 여부 - @Schema(description = "일반실 예약 가능 여부", example = "true") + @Schema(description = "승객수 고려한 일반실 예약 가능 여부", example = "true") boolean canReserveStandard, - @Schema(description = "특실 예약 가능 여부", example = "true") + @Schema(description = "승객수 고려한 특실 예약 가능 여부", example = "true") boolean canReserveFirstClass, // 입석 정보 - @Schema(description = "입석 가능 여부", example = "true") - boolean standingAvailable, + @Schema(description = "현재 예약된 입석 인원", example = "15") + int currentStandingReservations, - @Schema(description = "추가 입석 가능 최대 인원", example = "25") - int maxAdditionalStanding, + @Schema(description = "전체 좌석 수 (입석 계산용)", example = "363") + int totalSeats - @Schema(description = "입석 예약 가능 여부", example = "true") - boolean canReserveStanding, - - // 구간 분석 정보 - @Schema(description = "해당 구간 내 최대 탑승 인원", example = "340") - int maxOccupancyInRoute ) { /** * 정적 팩토리 메서드 */ public static SectionSeatStatus of( - int standardAvailable, int standardTotal, - int firstClassAvailable, int firstClassTotal, + int standardRemaining, int standardTotal, + int firstClassRemaining, int firstClassTotal, boolean canReserveStandard, boolean canReserveFirstClass, - boolean standingAvailable, int maxAdditionalStanding, - boolean canReserveStanding, int maxOccupancyInRoute) { + int currentStandingReservations, int totalSeats) { return new SectionSeatStatus( - standardAvailable, standardTotal, firstClassAvailable, firstClassTotal, + standardRemaining, standardTotal, + firstClassRemaining, firstClassTotal, canReserveStandard, canReserveFirstClass, - standingAvailable, maxAdditionalStanding, canReserveStanding, maxOccupancyInRoute + currentStandingReservations, totalSeats ); } + + /** + * 열차의 최대 입석 수용 인원 + * @param standingRatio + * @return + */ + public int getMaxStandingCapacity(double standingRatio) { + return (int)(totalSeats * standingRatio); + } + + /** + * 잔여 입석 개수 + * @param standingRatio + * @return + */ + public int getStandingRemaining(double standingRatio) { + return Math.max(0, getMaxStandingCapacity(standingRatio) - currentStandingReservations); + } + + /** + * 승객수로 입석 예약 가능 여부 판단 + * @param passengerCount + * @param standingRatio + * @return + */ + public boolean canReserveStanding(int passengerCount, double standingRatio) { + return getStandingRemaining(standingRatio) >= passengerCount; + } } diff --git a/src/main/java/com/sudo/railo/train/application/dto/projection/ReservationInfoProjection.java b/src/main/java/com/sudo/railo/train/application/dto/projection/ReservationInfoProjection.java new file mode 100644 index 00000000..bd9f88c3 --- /dev/null +++ b/src/main/java/com/sudo/railo/train/application/dto/projection/ReservationInfoProjection.java @@ -0,0 +1,28 @@ +package com.sudo.railo.train.application.dto.projection; + +import com.querydsl.core.annotations.QueryProjection; +import com.sudo.railo.train.domain.type.CarType; + +import lombok.Getter; + +@Getter +public class ReservationInfoProjection { + + private final Long trainScheduleId; + private final Long seatId; + private final CarType carType; + private final Long departureStationId; + private final Long arrivalStationId; + private final Boolean isStanding; + + @QueryProjection + public ReservationInfoProjection(Long trainScheduleId, Long seatId, CarType carType, + Long departureStationId, Long arrivalStationId, Boolean isStanding) { + this.trainScheduleId = trainScheduleId; + this.seatId = seatId; + this.carType = carType; + this.departureStationId = departureStationId; + this.arrivalStationId = arrivalStationId; + this.isStanding = isStanding; + } +} diff --git a/src/main/java/com/sudo/railo/train/application/dto/projection/SeatProjection.java b/src/main/java/com/sudo/railo/train/application/dto/projection/SeatProjection.java index 3572cb81..83cda657 100644 --- a/src/main/java/com/sudo/railo/train/application/dto/projection/SeatProjection.java +++ b/src/main/java/com/sudo/railo/train/application/dto/projection/SeatProjection.java @@ -14,8 +14,6 @@ public class SeatProjection { private final Long seatId; private final String seatNumber; - private final int seatRow; - private final String seatColumn; private final SeatType seatType; private final String directionCode; private final boolean isReserved; @@ -25,8 +23,6 @@ public class SeatProjection { public SeatProjection( Long seatId, String seatNumber, - int seatRow, - String seatColumn, SeatType seatType, String directionCode, boolean isReserved, @@ -34,21 +30,12 @@ public SeatProjection( ) { this.seatId = seatId; this.seatNumber = seatNumber; - this.seatRow = seatRow; - this.seatColumn = seatColumn; this.seatType = seatType; this.directionCode = directionCode; this.isReserved = isReserved; this.specialMessage = specialMessage; } - /** - * 좌석 위치 반환 (예: "1D") - */ - public String getSeatPosition() { - return seatRow + seatColumn; - } - /** * 예약 가능 여부 반환 */ diff --git a/src/main/java/com/sudo/railo/train/application/dto/projection/TrainSeatInfoBatch.java b/src/main/java/com/sudo/railo/train/application/dto/projection/TrainSeatInfoBatch.java new file mode 100644 index 00000000..e7e9d3d4 --- /dev/null +++ b/src/main/java/com/sudo/railo/train/application/dto/projection/TrainSeatInfoBatch.java @@ -0,0 +1,28 @@ +package com.sudo.railo.train.application.dto.projection; + +import java.util.Map; + +import com.sudo.railo.train.domain.type.CarType; + +import lombok.Getter; + +@Getter +public class TrainSeatInfoBatch { + + private final Map> seatsCountByCarType; + private final Map totalSeatsCount; + + public TrainSeatInfoBatch(Map> seatsCountByCarType, + Map totalSeatsCount) { + this.seatsCountByCarType = seatsCountByCarType; + this.totalSeatsCount = totalSeatsCount; + } + + public Map getSeatsCountByCarType(Long trainScheduleId) { + return seatsCountByCarType.getOrDefault(trainScheduleId, Map.of()); + } + + public Integer getTotalSeatsCount(Long trainScheduleId) { + return totalSeatsCount.getOrDefault(trainScheduleId, 0); + } +} diff --git a/src/main/java/com/sudo/railo/train/application/dto/projection/TrainSeatInfoProjection.java b/src/main/java/com/sudo/railo/train/application/dto/projection/TrainSeatInfoProjection.java new file mode 100644 index 00000000..a849cb1b --- /dev/null +++ b/src/main/java/com/sudo/railo/train/application/dto/projection/TrainSeatInfoProjection.java @@ -0,0 +1,21 @@ +package com.sudo.railo.train.application.dto.projection; + +import com.querydsl.core.annotations.QueryProjection; +import com.sudo.railo.train.domain.type.CarType; + +import lombok.Getter; + +@Getter +public class TrainSeatInfoProjection { + + private final Long trainScheduleId; + private final CarType carType; + private final Integer seatCount; + + @QueryProjection + public TrainSeatInfoProjection(Long trainScheduleId, CarType carType, Integer seatCount) { + this.trainScheduleId = trainScheduleId; + this.carType = carType; + this.seatCount = seatCount; + } +} diff --git a/src/main/java/com/sudo/railo/train/application/dto/response/SeatTypeInfo.java b/src/main/java/com/sudo/railo/train/application/dto/response/SeatTypeInfo.java index 887ddbfc..85a541ea 100644 --- a/src/main/java/com/sudo/railo/train/application/dto/response/SeatTypeInfo.java +++ b/src/main/java/com/sudo/railo/train/application/dto/response/SeatTypeInfo.java @@ -8,7 +8,7 @@ public record SeatTypeInfo( @Schema(description = "잔여 좌석 수", example = "45") - int availableSeats, + int remainingSeats, @Schema(description = "전체 좌석 수", example = "300") int totalSeats, @@ -23,56 +23,78 @@ public record SeatTypeInfo( boolean canReserve, @Schema(description = "화면 표시용 텍스트", example = "일반실") - String displayText + String displayText, + + @Schema(description = "상세 설명", example = "잔여 45석") + String description ) { // 입석 정보 포함 - public static SeatTypeInfo create(int availableSeats, int totalSeats, int fare, - int passengerCount, String seatTypeName, boolean hasStanding) { + public static SeatTypeInfo create(int availableSeats, + int totalSeats, + int fare, + int passengerCount, + String seatTypeName, + boolean hasStandingOption, + boolean canReserve) { + + SeatAvailabilityStatus status = determineSeatStatus(availableSeats, passengerCount, hasStandingOption, + totalSeats); - SeatAvailabilityStatus status = determineSeatStatus(availableSeats, passengerCount, hasStanding); - boolean canReserve = availableSeats >= passengerCount; - String displayText = createDisplayText(status, seatTypeName, availableSeats, passengerCount); + String displayText = createDisplayText(status, seatTypeName); + String description = createDescription(status, availableSeats, passengerCount); - return new SeatTypeInfo(availableSeats, totalSeats, fare, status, canReserve, displayText); + return new SeatTypeInfo(availableSeats, totalSeats, fare, status, canReserve, displayText, description); } /** * 좌석 수와 승객 수로 예약 가능한 좌석 상태 결정 - * // TODO : 기준 값 변수로 처리 */ private static SeatAvailabilityStatus determineSeatStatus(int availableSeats, int passengerCount, - boolean hasStanding) { - if (availableSeats >= 11) { - return SeatAvailabilityStatus.AVAILABLE; - } else if (availableSeats >= 6) { - return SeatAvailabilityStatus.LIMITED; - } else if (availableSeats >= 1) { - return SeatAvailabilityStatus.FEW_REMAINING; - } else if (availableSeats == 0 && hasStanding) { - return SeatAvailabilityStatus.STANDING_AVAILABLE; // 좌석 없지만 입석 가능 + boolean hasStandingOption, int totalSeats) { + if (availableSeats == 0) { + // 좌석은 매진이지만 입석이 가능한 경우 + return hasStandingOption ? SeatAvailabilityStatus.STANDING_ONLY : SeatAvailabilityStatus.SOLD_OUT; + } + + // 좌석 부족 + if (availableSeats < passengerCount) { + // 입석이 가능하다면 STANDING_ONLY, 불가능하다면 INSUFFICIENT + return hasStandingOption ? SeatAvailabilityStatus.STANDING_ONLY : SeatAvailabilityStatus.INSUFFICIENT; + } + + double availabilityRatio = (double)availableSeats / totalSeats; + + if (availabilityRatio >= 0.25) { + return SeatAvailabilityStatus.AVAILABLE; // 25% 이상이면 여유 } else { - return SeatAvailabilityStatus.SOLD_OUT; + return SeatAvailabilityStatus.LIMITED; } } /** * 화면 표시용 텍스트 생성 */ - private static String createDisplayText(SeatAvailabilityStatus status, String seatTypeName, - int availableSeats, int passengerCount) { + private static String createDisplayText(SeatAvailabilityStatus status, String seatTypeName) { + return switch (status) { + case SOLD_OUT -> status.getText(); // "매진" + case INSUFFICIENT -> status.getText(); // "좌석부족" + case STANDING_ONLY -> seatTypeName + "(" + status.getText() + ")"; // "일반실(입석)" + case LIMITED -> seatTypeName + "(" + status.getText() + ")"; // "일반실(매진임박)" + case AVAILABLE -> seatTypeName; // "일반실" / "특실" + }; + } + + /** + * 상세 설명 생성 (enum description + 구체적 수치) + */ + private static String createDescription(SeatAvailabilityStatus status, int availableSeats, + int passengerCount) { return switch (status) { - case SOLD_OUT -> "매진"; - case FEW_REMAINING -> String.format("%s(매진임박)", seatTypeName); - case STANDING_AVAILABLE -> "입석+좌석"; - case LIMITED -> { - // 승객 수보다 적으면 "좌석부족", 충분하면 "좌석유형(좌석부족)" - if (availableSeats < passengerCount) { - yield "좌석부족"; - } else { - yield String.format("%s(좌석부족)", seatTypeName); - } - } - case AVAILABLE -> seatTypeName; // "일반실" or "특실" + case SOLD_OUT, STANDING_ONLY -> status.getDescription(); + case INSUFFICIENT -> String.format("%s (%d명 예약 시 %d석 부족)", + status.getDescription(), passengerCount, passengerCount - availableSeats); + case LIMITED -> String.format("%s (잔여 %d석)", status.getDescription(), availableSeats); + case AVAILABLE -> String.format("잔여 %d석", availableSeats); }; } } diff --git a/src/main/java/com/sudo/railo/train/application/dto/response/StandingTypeInfo.java b/src/main/java/com/sudo/railo/train/application/dto/response/StandingTypeInfo.java index 08639d26..9067d0d0 100644 --- a/src/main/java/com/sudo/railo/train/application/dto/response/StandingTypeInfo.java +++ b/src/main/java/com/sudo/railo/train/application/dto/response/StandingTypeInfo.java @@ -5,7 +5,7 @@ public record StandingTypeInfo( @Schema(description = "입석 가능 인원", example = "25") - int availableStanding, + int remainingStanding, @Schema(description = "최대 입석 인원", example = "50") int maxStanding, diff --git a/src/main/java/com/sudo/railo/train/application/dto/response/StationStopInfo.java b/src/main/java/com/sudo/railo/train/application/dto/response/StationStopInfo.java deleted file mode 100644 index a7671b0a..00000000 --- a/src/main/java/com/sudo/railo/train/application/dto/response/StationStopInfo.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.sudo.railo.train.application.dto.response; - -import java.time.Duration; -import java.time.LocalTime; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "정차역 정보") -public record StationStopInfo( - - @Schema(description = "역 ID") - Long stationId, - - @Schema(description = "역명", example = "서울") - String stationName, - - @Schema(description = "정차 순서", example = "1") - int stopOrder, - - @Schema(description = "도착 시간", example = "09:00") - LocalTime arrivalTime, - - @Schema(description = "출발 시간", example = "09:03") - LocalTime departureTime -) { - public static StationStopInfo of(Long stationId, String stationName, int stopOrder, - LocalTime arrivalTime, LocalTime departureTime) { - return new StationStopInfo(stationId, stationName, stopOrder, arrivalTime, departureTime); - } - - /** - * 정차 시간(분) 계산 - */ - public int getStopDurationMinutes() { - if (arrivalTime != null && departureTime != null) { - return (int)Duration.between(arrivalTime, departureTime).toMinutes(); - } - return 0; - } -} diff --git a/src/main/java/com/sudo/railo/train/application/dto/response/TrainCarListResponse.java b/src/main/java/com/sudo/railo/train/application/dto/response/TrainCarListResponse.java index eb243f36..c29d8020 100644 --- a/src/main/java/com/sudo/railo/train/application/dto/response/TrainCarListResponse.java +++ b/src/main/java/com/sudo/railo/train/application/dto/response/TrainCarListResponse.java @@ -10,6 +10,9 @@ @Schema(description = "열차 객차 목록 조회 응답") public record TrainCarListResponse( + @Schema(description = "열차 스케줄 ID", example = "26") + Long trainScheduleId, + @Schema(description = "AI가 추천하는 최적 객차 번호 (승객수, 위치 고려)", example = "14") String recommendedCarNumber, @@ -25,10 +28,10 @@ public record TrainCarListResponse( @Schema(description = "좌석 선택 가능한 객차 정보 목록") List carInfos ) { - public static TrainCarListResponse of(String recommendedCarNumber, int totalCarCount, + public static TrainCarListResponse of(Long trainScheduleId, String recommendedCarNumber, int totalCarCount, String trainClassificationCode, String trainNumber, List carInfos) { - return new TrainCarListResponse(recommendedCarNumber, totalCarCount, + return new TrainCarListResponse(trainScheduleId, recommendedCarNumber, totalCarCount, trainClassificationCode, trainNumber, carInfos); } } diff --git a/src/main/java/com/sudo/railo/train/application/dto/response/TrainCompositionInfo.java b/src/main/java/com/sudo/railo/train/application/dto/response/TrainCompositionInfo.java deleted file mode 100644 index 1ad0c16f..00000000 --- a/src/main/java/com/sudo/railo/train/application/dto/response/TrainCompositionInfo.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.sudo.railo.train.application.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -/** - * 열차 편성 정보 - */ -@Schema(description = "열차 편성 정보") -public record TrainCompositionInfo( - - @Schema(description = "총 객차 수", example = "8") - int totalCars, - - @Schema(description = "일반실 객차 수", example = "6") - int standardCars, - - @Schema(description = "특실 객차 수", example = "2") - int firstClassCars, - - @Schema(description = "총 좌석 수", example = "363") - int totalSeats, - - @Schema(description = "일반실 좌석 수", example = "246") - int standardSeats, - - @Schema(description = "특실 좌석 수", example = "117") - int firstClassSeats -) { - public static TrainCompositionInfo of(int totalCars, int standardCars, int firstClassCars, - int totalSeats, int standardSeats, int firstClassSeats) { - return new TrainCompositionInfo(totalCars, standardCars, firstClassCars, - totalSeats, standardSeats, firstClassSeats); - } -} diff --git a/src/main/java/com/sudo/railo/train/application/dto/response/TrainSearchResponse.java b/src/main/java/com/sudo/railo/train/application/dto/response/TrainSearchResponse.java index 53291da1..8e7b9a53 100644 --- a/src/main/java/com/sudo/railo/train/application/dto/response/TrainSearchResponse.java +++ b/src/main/java/com/sudo/railo/train/application/dto/response/TrainSearchResponse.java @@ -98,7 +98,7 @@ private static void validateTrainSearchData(Long trainScheduleId, String trainNu /** * 입석 정보 존재 여부 */ - public boolean hasStanding() { + public boolean hasStandingInfo() { return standing != null; } diff --git a/src/main/java/com/sudo/railo/train/application/validator/TrainSearchValidator.java b/src/main/java/com/sudo/railo/train/application/validator/TrainSearchValidator.java index 7f98cdd8..bdbe03c9 100644 --- a/src/main/java/com/sudo/railo/train/application/validator/TrainSearchValidator.java +++ b/src/main/java/com/sudo/railo/train/application/validator/TrainSearchValidator.java @@ -33,6 +33,9 @@ private void validateOperationDate(TrainSearchRequest request) { } } + /** + * 예약 날짜가 오늘(date)이면, 시(hour) 단위로만 비교 + */ private void validateDepartureTime(TrainSearchRequest request) { if (request.operationDate().equals(LocalDate.now())) { int requestHour = Integer.parseInt(request.departureHour()); diff --git a/src/main/java/com/sudo/railo/train/domain/ScheduleStop.java b/src/main/java/com/sudo/railo/train/domain/ScheduleStop.java index 9a1e555a..59131085 100644 --- a/src/main/java/com/sudo/railo/train/domain/ScheduleStop.java +++ b/src/main/java/com/sudo/railo/train/domain/ScheduleStop.java @@ -23,16 +23,13 @@ @Table( name = "schedule_stop", indexes = { - // 1. 경로 검색 최적화 인덱스 - @Index(name = "idx_schedule_stop_route", + // 1. 출발역 필터 + 시간 조건 + 조인 및 stop_order 비교 + @Index(name = "idx_stop_depart_filter", columnList = "station_id, departure_time, train_schedule_id, stop_order"), - // 2. FK 조인 성능용 인덱스 - @Index(name = "idx_schedule_stop_schedule_fk", - columnList = "train_schedule_id"), - - @Index(name = "idx_schedule_stop_station_fk", - columnList = "station_id"), + // 2. 도착역 필터 + 조인 + stop_order 비교 + @Index(name = "idx_stop_arrival_filter", + columnList = "station_id, train_schedule_id, stop_order"), } ) public class ScheduleStop { diff --git a/src/main/java/com/sudo/railo/train/domain/Train.java b/src/main/java/com/sudo/railo/train/domain/Train.java index f4549c60..e750758b 100644 --- a/src/main/java/com/sudo/railo/train/domain/Train.java +++ b/src/main/java/com/sudo/railo/train/domain/Train.java @@ -11,7 +11,6 @@ import com.sudo.railo.train.domain.type.CarType; import com.sudo.railo.train.domain.type.TrainType; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -19,7 +18,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -44,9 +42,6 @@ public class Train { private int totalCars; - @OneToMany(mappedBy = "train", cascade = CascadeType.ALL) - private final List trainCars = new ArrayList<>(); - /* 생성 메서드 */ /** @@ -86,35 +81,4 @@ public List generateTrainCars(Map layouts, TrainT } return trainCars; } - - /* 조회 로직 */ - - /** - * 좌석 타입별 총 좌석 수 계산 - */ - public int getTotalSeatsByType(CarType carType) { - return trainCars.stream() - .filter(car -> car.getCarType() == carType) - .mapToInt(TrainCar::getTotalSeats) - .sum(); - } - - /** - * 좌석 타입별 칸 목록 - */ - public List getCarsByType(CarType carType) { - return trainCars.stream() - .filter(car -> car.getCarType() == carType) - .toList(); - } - - /** - * 지원하는 좌석 타입 - */ - public List getSupportedCarTypes() { - return trainCars.stream() - .map(TrainCar::getCarType) - .distinct() - .toList(); - } } diff --git a/src/main/java/com/sudo/railo/train/domain/TrainCar.java b/src/main/java/com/sudo/railo/train/domain/TrainCar.java index a2e6104a..50922fc4 100644 --- a/src/main/java/com/sudo/railo/train/domain/TrainCar.java +++ b/src/main/java/com/sudo/railo/train/domain/TrainCar.java @@ -9,7 +9,6 @@ import com.sudo.railo.train.domain.type.CarType; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -20,7 +19,6 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -40,7 +38,7 @@ public class TrainCar { @Enumerated(EnumType.STRING) private CarType carType; - + private int seatRowCount; private int totalSeats; @@ -53,9 +51,6 @@ public class TrainCar { @JoinColumn(name = "train_id") private Train train; - @OneToMany(mappedBy = "trainCar", cascade = CascadeType.ALL) - private final List seats = new ArrayList<>(); - /* 생성 메서드 */ /** diff --git a/src/main/java/com/sudo/railo/train/domain/TrainSchedule.java b/src/main/java/com/sudo/railo/train/domain/TrainSchedule.java index 4b006578..b396e30c 100644 --- a/src/main/java/com/sudo/railo/train/domain/TrainSchedule.java +++ b/src/main/java/com/sudo/railo/train/domain/TrainSchedule.java @@ -1,22 +1,11 @@ package com.sudo.railo.train.domain; -import java.time.Duration; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; import com.sudo.railo.train.domain.status.OperationStatus; -import com.sudo.railo.train.domain.type.CarType; -import com.sudo.railo.train.domain.type.SeatAvailabilityStatus; -import jakarta.persistence.CascadeType; -import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; -import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -27,9 +16,6 @@ import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.MapKeyColumn; -import jakarta.persistence.MapKeyEnumerated; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -75,13 +61,6 @@ public class TrainSchedule { private int delayMinutes; - // 마일리지 시스템용 추가 필드들 - @Column(name = "actual_arrival_time") - private LocalDateTime actualArrivalTime; - - @Column(name = "mileage_processed", nullable = false, columnDefinition = "BOOLEAN DEFAULT FALSE") - private boolean mileageProcessed = false; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "train_id") private Train train; @@ -94,18 +73,6 @@ public class TrainSchedule { @JoinColumn(name = "arrival_station_id") private Station arrivalStation; - // 좌석 타입별 잔여 좌석 수 (Map 형태로 저장) - @ElementCollection - @CollectionTable(name = "schedule_available_seats", - joinColumns = @JoinColumn(name = "train_schedule_id")) - @MapKeyColumn(name = "car_type") - @MapKeyEnumerated(EnumType.STRING) - @Column(name = "available_seats") - private Map availableSeatsMap = new HashMap<>(); - - @OneToMany(mappedBy = "trainSchedule", cascade = CascadeType.ALL) - private final List scheduleStops = new ArrayList<>(); - /* 생성 메서드 */ /** @@ -127,17 +94,10 @@ private TrainSchedule( this.operationStatus = OperationStatus.ACTIVE; this.delayMinutes = 0; - // 초기 실제 도착시간 설정 (예정 시간으로) - this.actualArrivalTime = operationDate.atTime(arrivalTime); - this.mileageProcessed = false; - // 연관관계 설정 this.train = train; this.departureStation = departureStation; this.arrivalStation = arrivalStation; - - // 초기 좌석 수 설정 - initializeAvailableSeats(); } /** @@ -156,14 +116,6 @@ public static TrainSchedule create(LocalDate operationDate, TrainScheduleTemplat ); } - /** 초기 좌석 수 설정 */ - private void initializeAvailableSeats() { - for (CarType carType : train.getSupportedCarTypes()) { - int totalSeats = train.getTotalSeatsByType(carType); - availableSeatsMap.put(carType, totalSeats); - } - } - /* 비즈니스 메서드 */ public void updateOperationStatus(OperationStatus status) { this.operationStatus = status; @@ -183,148 +135,10 @@ public void addDelay(int minutes) { if (this.delayMinutes >= 5) { this.operationStatus = OperationStatus.DELAYED; } - - // 실제 도착시간도 지연 시간만큼 업데이트 - this.actualArrivalTime = operationDate.atTime(arrivalTime).plusMinutes(this.delayMinutes); } public void recoverDelay() { this.delayMinutes = 0; this.operationStatus = OperationStatus.ACTIVE; - - // 실제 도착시간을 원래 예정 시간으로 복구 - this.actualArrivalTime = operationDate.atTime(arrivalTime); - } - - /* 조회 로직 */ - - // 특정 타입 잔여 좌석 수 조회 - public int getAvailableSeats(CarType carType) { - return availableSeatsMap.getOrDefault(carType, 0); - } - - // 특정 타입 총 좌석 수 조회 - public int getTotalSeats(CarType carType) { - return train.getTotalSeatsByType(carType); - } - - // 예약 가능 여부 확인 - public boolean canReserveSeats(CarType carType, int seatCount) { - if (!isOperational()) - return false; - if (!train.getSupportedCarTypes().contains(carType)) - return false; - return getAvailableSeats(carType) >= seatCount; - } - - // 좌석 가용성 상태 확인 - public SeatAvailabilityStatus getSeatAvailabilityStatus(CarType carType) { - int available = getAvailableSeats(carType); - - if (available == 0) { - return SeatAvailabilityStatus.SOLD_OUT; - } else if (available <= 5) { - return SeatAvailabilityStatus.FEW_REMAINING; - } else if (available <= 10) { - return SeatAvailabilityStatus.LIMITED; - } else { - return SeatAvailabilityStatus.AVAILABLE; - } - } - - // 운행 가능 여부 - public boolean isOperational() { - return operationStatus == OperationStatus.ACTIVE || - operationStatus == OperationStatus.DELAYED; - } - - // 소요 시간 계산 - public Duration getTravelDuration() { - return Duration.between(departureTime, arrivalTime); - } - - /* 검증 로직 */ - - // 좌석 예약 검증 - private void validateSeatReservation(CarType carType, int seatCount) { - if (seatCount <= 0) { - throw new IllegalArgumentException("좌석 수는 1 이상이어야 합니다"); - } - - if (!isOperational()) { - throw new IllegalStateException("운행이 중단된 열차입니다"); - } - - if (!train.getSupportedCarTypes().contains(carType)) { - throw new IllegalArgumentException("지원하지 않는 좌석 타입입니다: " + carType); - } - - if (getAvailableSeats(carType) < seatCount) { - throw new IllegalStateException( - "좌석이 부족합니다. 요청: " + seatCount + "석, 잔여: " + getAvailableSeats(carType) + "석"); - } - } - - /* 마일리지 시스템용 메서드들 */ - - /** - * 관리자가 수동으로 실제 도착시간을 설정 - * @param actualArrivalTime 실제 도착한 시간 - */ - public void setActualArrivalTime(LocalDateTime actualArrivalTime) { - this.actualArrivalTime = actualArrivalTime; - - // 지연 시간 자동 계산 - LocalDateTime scheduledArrival = operationDate.atTime(arrivalTime); - if (actualArrivalTime.isAfter(scheduledArrival)) { - Duration delay = Duration.between(scheduledArrival, actualArrivalTime); - this.delayMinutes = (int) delay.toMinutes(); - - if (this.delayMinutes >= 5) { - this.operationStatus = OperationStatus.DELAYED; - } - } - } - - /** - * 마일리지 처리 완료 표시 - */ - public void markMileageProcessed() { - this.mileageProcessed = true; - } - - /** - * 마일리지 처리 준비됨 여부 확인 - * @param currentTime 현재 시간 - * @return 마일리지 처리 가능 여부 - */ - public boolean isReadyForMileageProcessing(LocalDateTime currentTime) { - return actualArrivalTime != null - && currentTime.isAfter(actualArrivalTime) - && !mileageProcessed; - } - - /** - * 중요한 지연 여부 확인 (20분 이상) - * @return 20분 이상 지연 여부 - */ - public boolean hasSignificantDelay() { - return delayMinutes >= 20; - } - - /** - * 지연 분 반환 - * @return 지연 시간(분) - */ - public int getDelayMinutes() { - return delayMinutes; - } - - /** - * 실제 도착시간 반환 - * @return 실제 도착시간 - */ - public LocalDateTime getActualArrivalTime() { - return actualArrivalTime; } } diff --git a/src/main/java/com/sudo/railo/train/domain/type/SeatAvailabilityStatus.java b/src/main/java/com/sudo/railo/train/domain/type/SeatAvailabilityStatus.java index 7569d27e..0a4b4121 100644 --- a/src/main/java/com/sudo/railo/train/domain/type/SeatAvailabilityStatus.java +++ b/src/main/java/com/sudo/railo/train/domain/type/SeatAvailabilityStatus.java @@ -7,11 +7,12 @@ @RequiredArgsConstructor public enum SeatAvailabilityStatus { - AVAILABLE("여유"), // 11석 이상 - LIMITED("좌석부족"), // 6~10석 - FEW_REMAINING("매진임박"), // 1~5석 - STANDING_AVAILABLE("입석+좌석"), // 좌석이 부족하지만 입석 가능 - SOLD_OUT("매진"); // 0석 (매진) + AVAILABLE("여유", "충분한 좌석이 있습니다"), + LIMITED("매진임박", "좌석이 얼마 남지 않았습니다"), + INSUFFICIENT("좌석부족", "요청하신 인원보다 좌석이 부족합니다"), + SOLD_OUT("매진", "모든 좌석이 매진되었습니다"), + STANDING_ONLY("입석", "좌석은 매진이지만 입석으로 이용 가능합니다"); + private final String text; private final String description; } diff --git a/src/main/java/com/sudo/railo/train/exception/TrainErrorCode.java b/src/main/java/com/sudo/railo/train/exception/TrainErrorCode.java index ec233fff..9d347222 100644 --- a/src/main/java/com/sudo/railo/train/exception/TrainErrorCode.java +++ b/src/main/java/com/sudo/railo/train/exception/TrainErrorCode.java @@ -15,7 +15,7 @@ public enum TrainErrorCode implements ErrorCode { TRAIN_SCHEDULE_NOT_FOUND("해당 날짜에 운행하는 열차가 없습니다.", HttpStatus.NOT_FOUND, "T4001"), TRAIN_OPERATION_CANCELLED("해당 열차는 운행이 취소되었습니다.", HttpStatus.BAD_REQUEST, "T4002"), TRAIN_OPERATION_DELAYED("해당 열차는 지연 운행 중입니다.", HttpStatus.BAD_REQUEST, "T4003"), - TRAIN_SCHEDULE_DETAIL_NOT_FOUND("요청하신 열차 스케줄이 존재하지 않습니다.", HttpStatus.NOT_FOUND, "T4004"), + TRAIN_SCHEDULE_DETAIL_NOT_FOUND("해당 열차 스케줄을 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "T4004"), NO_AVAILABLE_CARS("잔여 좌석이 있는 객차가 없습니다.", HttpStatus.NOT_FOUND, "TR_4005"), TRAIN_CAR_NOT_FOUND("해당 객차를 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "TR_4006"), diff --git a/src/main/java/com/sudo/railo/train/infrastructure/ScheduleStopRepository.java b/src/main/java/com/sudo/railo/train/infrastructure/ScheduleStopRepository.java index f00b7b58..dd43683b 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/ScheduleStopRepository.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/ScheduleStopRepository.java @@ -1,5 +1,7 @@ package com.sudo.railo.train.infrastructure; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -7,4 +9,6 @@ @Repository public interface ScheduleStopRepository extends JpaRepository { + + Optional findByTrainScheduleIdAndStationId(Long trainScheduleId, Long id); } diff --git a/src/main/java/com/sudo/railo/train/infrastructure/SeatRepository.java b/src/main/java/com/sudo/railo/train/infrastructure/SeatRepository.java index c7c3289f..804b8c45 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/SeatRepository.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/SeatRepository.java @@ -1,8 +1,23 @@ package com.sudo.railo.train.infrastructure; +import java.util.List; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import com.sudo.railo.train.domain.Seat; +import com.sudo.railo.train.domain.type.CarType; + +import jakarta.persistence.LockModeType; public interface SeatRepository extends JpaRepository { + + @Query("SELECT DISTINCT tc.carType FROM Seat s JOIN TrainCar tc ON tc = s.trainCar WHERE s.id IN :seatIds") + List findCarTypes(List seatIds); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Seat s WHERE s.id = :seatId") + Optional findByIdWithLock(Long seatId); } diff --git a/src/main/java/com/sudo/railo/train/infrastructure/SeatRepositoryCustomImpl.java b/src/main/java/com/sudo/railo/train/infrastructure/SeatRepositoryCustomImpl.java index 5ede3f75..61854aea 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/SeatRepositoryCustomImpl.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/SeatRepositoryCustomImpl.java @@ -1,5 +1,6 @@ package com.sudo.railo.train.infrastructure; +import static com.sudo.railo.booking.domain.QReservation.*; import static com.sudo.railo.booking.domain.QSeatReservation.*; import static com.sudo.railo.train.domain.QSeat.*; import static com.sudo.railo.train.domain.QTrainCar.*; @@ -10,13 +11,12 @@ import org.springframework.stereotype.Repository; import com.querydsl.core.Tuple; -import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; -import com.sudo.railo.booking.domain.SeatStatus; import com.sudo.railo.train.application.TrainCarSeatInfo; import com.sudo.railo.train.application.dto.projection.QSeatProjection; import com.sudo.railo.train.application.dto.projection.SeatProjection; +import com.sudo.railo.train.domain.QScheduleStop; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,47 +28,49 @@ public class SeatRepositoryCustomImpl implements SeatRepositoryCustom { private final JPAQueryFactory queryFactory; + /** + * 특정 객차의 모든 좌석 상세 정보 및 예약 상태 조회 + * - LEFT JOIN으로 예약 정보 연결 (예약 없는 좌석도 포함) + * - stopOrder 기반으로 해당 구간에서의 예약 상태 판단 + * - 좌석별 방향성, 특별 메시지 등 부가 정보 포함 + */ @Override public TrainCarSeatInfo findTrainCarSeatDetail(Long trainCarId, Long trainScheduleId, Long departureStationId, Long arrivalStationId) { // 1. 객차 기본 정보 조회 - Tuple carInfo = queryFactory.select(trainCar.carNumber, trainCar.carType, trainCar.seatArrangement, - trainCar.totalSeats).from(trainCar).where(trainCar.id.eq(trainCarId)).fetchOne(); - - Integer maxSeatRow = queryFactory.select(seat.seatRow.max()) - .from(seat) - .where(seat.trainCar.id.eq(trainCarId)) + Tuple carInfo = queryFactory.select( + trainCar.carNumber, + trainCar.carType, + trainCar.seatArrangement, + trainCar.totalSeats, + trainCar.seatRowCount) + .from(trainCar) + .where(trainCar.id.eq(trainCarId)) .fetchOne(); - int middleRow = (maxSeatRow != null) ? (maxSeatRow + 1) / 2 : 8; // 중간 지점 계산 - - // 2. 좌석별 상세 정보 조회 (예약 상태 포함) - - /** - * 상행 / 하행 방향 판단 : 출발역 < 도착역이면 하행, 아니면 상행 - * 역 ID가 낮은 쪽 → 높은 쪽: 하행 (예: 서울(10) → 부산(50)) - * 역 ID가 높은 쪽 → 낮은 쪽: 상행 (예: 부산(50) → 서울(10)) - */ - boolean isDownward = departureStationId < arrivalStationId; + // seatRowCount 로 middleRow 계산 + Integer seatRowCount = carInfo.get(trainCar.seatRowCount); + int middleRow = (seatRowCount != null) ? (seatRowCount + 1) / 2 : 8; // 중간 지점 계산 - // 구간 겹침 조건 정의 - BooleanExpression overlapCondition = isDownward - ? seatReservation.departureStation.id.lt(arrivalStationId) - .and(seatReservation.arrivalStation.id.gt(departureStationId)) // 하행: 기존출발 < 검색도착 && 기존도착 > 검색출발 - : seatReservation.departureStation.id.gt(arrivalStationId) - .and(seatReservation.arrivalStation.id.lt(departureStationId)); // 상행: 기존출발 > 검색도착 && 기존도착 < 검색출발 + // 2. 객차 내 모든 좌석 상세 정보 조회 (예약 상태 포함) + // stopOrder 기반 구간 겹침을 위한 ScheduleStop 조인 + QScheduleStop reservedDepartureStop = new QScheduleStop("reservedDepartureStop"); + QScheduleStop reservedArrivalStop = new QScheduleStop("reservedArrivalStop"); + QScheduleStop searchDepartureStop = new QScheduleStop("searchDepartureStop"); + QScheduleStop searchArrivalStop = new QScheduleStop("searchArrivalStop"); List seatProjections = queryFactory.select( - new QSeatProjection(seat.id, seat.seatRow.stringValue().concat(seat.seatColumn), seat.seatRow, - seat.seatColumn, seat.seatType, + new QSeatProjection(seat.id, + seat.seatRow.stringValue().concat(seat.seatColumn), + seat.seatType, // directionCode new CaseBuilder().when(seat.seatRow.loe(middleRow)) // 중간 이하 : 순방향 .then("009") // 순방향 .otherwise("010"), // 역방향 - // 해당 구간에 예약이 있는지 확인 + // isReserved new CaseBuilder().when(seatReservation.id.isNotNull()).then(true).otherwise(false), - // 4인 동반석 안내 메시지 + // specialMessage new CaseBuilder().when(seat.seatRow.between(middleRow, middleRow + 1)) .then(new CaseBuilder().when(seat.seatRow.eq(middleRow)) .then("KTX 4인동반석 순방향 좌석 입니다. 맞은편 좌석에 다른 승객이 승차할 수 있습니다.") @@ -77,19 +79,41 @@ public TrainCarSeatInfo findTrainCarSeatDetail(Long trainCarId, Long trainSchedu .otherwise("")) .otherwise(""))) .from(seat) - .leftJoin(seatReservation) - .on(seatReservation.seat.id.eq(seat.id) - .and(seatReservation.trainSchedule.id.eq(trainScheduleId)) - .and(seatReservation.seatStatus.in(SeatStatus.RESERVED, SeatStatus.LOCKED)) - .and(seatReservation.isStanding.isFalse()) - .and(overlapCondition) // 구간 겹침 확인 + .leftJoin(seatReservation).on( + seatReservation.seat.id.eq(seat.id) + .and(seatReservation.trainSchedule.id.eq(trainScheduleId)) + .and(seatReservation.seat.isNotNull()) // 실제 좌석 예약 (입석 X) + ) + .leftJoin(seatReservation.reservation, reservation) + // 기존 예약 정보 left join + .leftJoin(reservation.departureStop, reservedDepartureStop) + .leftJoin(reservation.arrivalStop, reservedArrivalStop) + // 검색 구간 정보 left join + .leftJoin(searchDepartureStop).on( + searchDepartureStop.trainSchedule.id.eq(trainScheduleId) + .and(searchDepartureStop.station.id.eq(departureStationId)) + ) + .leftJoin(searchArrivalStop).on( + searchArrivalStop.trainSchedule.id.eq(trainScheduleId) + .and(searchArrivalStop.station.id.eq(arrivalStationId)) + ) + .where( + seat.trainCar.id.eq(trainCarId), + // stopOrder 기반 구간 겹침 조건 + // 구간 겹침: NOT(예약종료 <= 검색시작 OR 예약시작 >= 검색종료) + // = 예약종료 > 검색시작 AND 예약시작 < 검색종료 + reservation.id.isNull().or( + reservedArrivalStop.stopOrder.gt(searchDepartureStop.stopOrder) + .and(reservedDepartureStop.stopOrder.lt(searchArrivalStop.stopOrder)) + ) ) - .where(seat.trainCar.id.eq(trainCarId)) .orderBy(seat.seatRow.asc(), seat.seatColumn.asc()) .fetch(); // 3. 잔여 좌석 수 계산 - long remainingSeats = seatProjections.stream().mapToLong(projection -> projection.isAvailable() ? 1 : 0).sum(); + long remainingSeats = seatProjections.stream() + .mapToLong(projection -> projection.isAvailable() ? 1 : 0) + .sum(); return new TrainCarSeatInfo(String.valueOf(carInfo.get(trainCar.carNumber)), carInfo.get(trainCar.carType), carInfo.get(trainCar.seatArrangement), Optional.ofNullable(carInfo.get(trainCar.totalSeats)).orElse(0), diff --git a/src/main/java/com/sudo/railo/train/infrastructure/SeatReservationRepositoryCustom.java b/src/main/java/com/sudo/railo/train/infrastructure/SeatReservationRepositoryCustom.java index 8a9892b6..e9c38fb1 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/SeatReservationRepositoryCustom.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/SeatReservationRepositoryCustom.java @@ -1,35 +1,44 @@ package com.sudo.railo.train.infrastructure; import java.util.List; +import java.util.Map; import com.sudo.railo.train.application.dto.SeatReservationInfo; +import com.sudo.railo.booking.application.dto.projection.SeatInfoProjection; public interface SeatReservationRepositoryCustom { /** - * 특정 구간과 겹치는 좌석 예약 조회 - * - 요청한 출발역~도착역 구간과 겹치는 예약 찾기 - * - * @param trainScheduleId 기차 스케줄 ID + * 여러 열차의 겹치는 예약 정보를 일괄 조회 + * @param trainScheduleIds 조회할 열차 스케줄 ID 목록 * @param departureStationId 출발역 ID * @param arrivalStationId 도착역 ID - * @return 겹치는 좌석 예약 정보 리스트 + * @return Map<열차스케줄ID, 예약정보리스트> */ - List findOverlappingReservations( - Long trainScheduleId, + Map> findOverlappingReservationsBatch( + List trainScheduleIds, Long departureStationId, Long arrivalStationId ); /** - * 특정 구간에서 겹치는 입석(Standing) 예약 수 조회 + * 여러 열차의 입석 예약 수를 일괄 조회 + * @param trainScheduleIds 조회할 열차 스케줄 ID 목록 + * @param departureStationId 출발역 ID + * @param arrivalStationId 도착역 ID + * @return Map<열차스케줄ID, 입석예약수> */ - int countOverlappingStandingReservations(Long trainScheduleId, Long departureStationId, Long arrivalStationId); + Map countOverlappingStandingReservationsBatch( + List trainScheduleIds, + Long departureStationId, + Long arrivalStationId + ); /** - * 특정 좌석의 예약 가능 여부 확인 - * 해당 구간에서 좌석이 이미 점유되어있는지 확인 + * 예약 ID로 해당 예약의 좌석 정보와 승객 타입을 조회 (PaymentService용) + * + * @param reservationId 예약 ID + * @return 좌석 정보와 승객 타입 리스트 */ - boolean isSeatAvailableForSection(Long trainScheduleId, Long seatId, - Long departureStationId, Long arrivalStationId); + List findSeatInfoByReservationId(Long reservationId); } diff --git a/src/main/java/com/sudo/railo/train/infrastructure/SeatReservationRepositoryCustomImpl.java b/src/main/java/com/sudo/railo/train/infrastructure/SeatReservationRepositoryCustomImpl.java index 130ca7f7..0dbb15d5 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/SeatReservationRepositoryCustomImpl.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/SeatReservationRepositoryCustomImpl.java @@ -1,17 +1,23 @@ package com.sudo.railo.train.infrastructure; +import static com.sudo.railo.booking.domain.QReservation.*; +import static com.sudo.railo.booking.domain.QSeatReservation.*; + import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.springframework.stereotype.Repository; -import com.querydsl.core.types.Projections; -import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.Tuple; import com.querydsl.jpa.impl.JPAQueryFactory; +import com.sudo.railo.booking.application.dto.projection.QSeatInfoProjection; +import com.sudo.railo.booking.application.dto.projection.SeatInfoProjection; import com.sudo.railo.booking.domain.QSeatReservation; -import com.sudo.railo.booking.domain.SeatStatus; import com.sudo.railo.train.application.dto.SeatReservationInfo; import com.sudo.railo.train.domain.QScheduleStop; import com.sudo.railo.train.domain.QSeat; +import com.sudo.railo.train.domain.QStation; import com.sudo.railo.train.domain.QTrainCar; import lombok.RequiredArgsConstructor; @@ -23,119 +29,134 @@ public class SeatReservationRepositoryCustomImpl implements SeatReservationRepos private final JPAQueryFactory queryFactory; /** - * 특정 구간과 겹치는 좌석 예약 조회 + * 여러 열차의 특정 구간에서 겹치는 예약 정보를 일괄 조회 * - 요청한 출발역~도착역 구간과 겹치는 예약 찾기 */ @Override - public List findOverlappingReservations(Long trainScheduleId, Long departureStationId, - Long arrivalStationId) { - QSeatReservation reservation = QSeatReservation.seatReservation; - QSeat s = QSeat.seat; - QTrainCar tc = QTrainCar.trainCar; - - /** - * 상행 / 하행 방향 판단 : 출발역 < 도착역이면 하행, 아니면 상행 - * 역 ID가 낮은 쪽 → 높은 쪽: 하행 (예: 서울(10) → 부산(50)) - * 역 ID가 높은 쪽 → 낮은 쪽: 상행 (예: 부산(50) → 서울(10)) - */ - boolean isDownward = departureStationId < arrivalStationId; - - // 구간 겹침 조건 정의 - // 예약 구간: [기존출발 ~ 기존도착] - // 요청 구간: [검색출발 ~ 검색도착] - // 겹침 조건: 기존출발 < 검색도착 AND 기존도착 > 검색출발 (하행 기준) - // 기존출발 > 검색도착 AND 기존도착 < 검색출발 (상행 기준) - BooleanExpression overlapCondition = isDownward - ? reservation.departureStation.id.lt(arrivalStationId) - .and(reservation.arrivalStation.id.gt(departureStationId)) // 하행 - : reservation.departureStation.id.gt(arrivalStationId) - .and(reservation.arrivalStation.id.lt(departureStationId)); // 상행 + public Map> findOverlappingReservationsBatch(List trainScheduleIds, + Long departureStationId, Long arrivalStationId) { - return queryFactory - .select(Projections.constructor( - SeatReservationInfo.class, - reservation.seat.id, - tc.carType, - reservation.departureStation.id, - reservation.arrivalStation.id - )) - .from(reservation) - .join(s).on(s.id.eq(reservation.seat.id)) - .join(tc).on(tc.id.eq(s.trainCar.id)) + if (trainScheduleIds.isEmpty()) { + return Map.of(); + } + + QSeatReservation seatReservation = QSeatReservation.seatReservation; + QSeat seat = QSeat.seat; + QTrainCar trainCar = QTrainCar.trainCar; + QScheduleStop reservedDepartureStop = new QScheduleStop("reservedDepartureStop"); + QScheduleStop reservedArrivalStop = new QScheduleStop("reservedArrivalStop"); + QStation reservedDepartureStation = new QStation("reservedDepartureStation"); + QStation reservedArrivalStation = new QStation("reservedArrivalStation"); + QScheduleStop searchDepartureStop = new QScheduleStop("searchDepartureStop"); + QScheduleStop searchArrivalStop = new QScheduleStop("searchArrivalStop"); + + List results = queryFactory + .select( + seatReservation.trainSchedule.id, + seatReservation.seat.id, + trainCar.carType, + reservedDepartureStation.id, + reservedArrivalStation.id + ) + .from(seatReservation) + .join(seat).on(seat.id.eq(seatReservation.seat.id)) // 좌석 정보 + .join(trainCar).on(trainCar.id.eq(seat.trainCar.id)) // 객차 정보 (객차 타입 판별 : 일반실/특실) + .join(seatReservation.reservation, reservation) // 예약 정보 (seatReservation 에만 좌석 정보 존재) + .join(reservation.departureStop, reservedDepartureStop) // 출발역 + .join(reservation.arrivalStop, reservedArrivalStop) // 도착역 + .join(reservedDepartureStop.station, reservedDepartureStation) + .join(reservedArrivalStop.station, reservedArrivalStation) + .join(searchDepartureStop).on( + searchDepartureStop.trainSchedule.id.in(trainScheduleIds) + .and(searchDepartureStop.station.id.eq(departureStationId)) + ) + .join(searchArrivalStop).on( + searchArrivalStop.trainSchedule.id.eq(seatReservation.trainSchedule.id) + .and(searchArrivalStop.station.id.eq(arrivalStationId)) + ) .where( - reservation.trainSchedule.id.eq(trainScheduleId), - reservation.seatStatus.in(SeatStatus.RESERVED, SeatStatus.LOCKED), - reservation.isStanding.isFalse(), - overlapCondition // 구간 겹침 조건 + seatReservation.trainSchedule.id.in(trainScheduleIds), // 해당 trainScheduleId 모두 조회 + seatReservation.seat.isNotNull(), // 실제 좌석 예약(입석 X) + reservedArrivalStop.stopOrder.gt(searchDepartureStop.stopOrder) // 구간 겹침 조건 + .and(reservedDepartureStop.stopOrder.lt(searchArrivalStop.stopOrder)) ) .fetch(); + + // 결과를 trainScheduleId별로 그룹핑 + return results.stream() + .collect(Collectors.groupingBy( + tuple -> tuple.get(seatReservation.trainSchedule.id), + Collectors.mapping(tuple -> new SeatReservationInfo( + tuple.get(seatReservation.seat.id), + tuple.get(trainCar.carType), + tuple.get(reservedDepartureStation.id), + tuple.get(reservedArrivalStation.id) + ), Collectors.toList()) + )); } /** - * 특정 구간에서 겹치는 입석(Standing) 예약 수 조회 + * 여러 열차의 특정 구간에서 겹치는 입석 예약 수를 일괄 조회 */ @Override - public int countOverlappingStandingReservations(Long trainScheduleId, Long departureStationId, - Long arrivalStationId) { - QSeatReservation reservation = QSeatReservation.seatReservation; - QScheduleStop searchDepartureStop = new QScheduleStop("searchDepartureStop"); - QScheduleStop searchArrivalStop = new QScheduleStop("searchArrivalStop"); + public Map countOverlappingStandingReservationsBatch(List trainScheduleIds, + Long departureStationId, Long arrivalStationId) { + + if (trainScheduleIds.isEmpty()) { + return Map.of(); + } + + QSeatReservation seatReservation = QSeatReservation.seatReservation; QScheduleStop reservedDepartureStop = new QScheduleStop("reservedDepartureStop"); QScheduleStop reservedArrivalStop = new QScheduleStop("reservedArrivalStop"); + QScheduleStop searchDepartureStop = new QScheduleStop("searchDepartureStop"); + QScheduleStop searchArrivalStop = new QScheduleStop("searchArrivalStop"); - Long count = queryFactory.select(reservation.count()) - .from(reservation) - // 기존 예약의 출발역 정보 조회 - .join(reservedDepartureStop) - .on(reservedDepartureStop.trainSchedule.id.eq(reservation.trainSchedule.id) - .and(reservedDepartureStop.station.id.eq(reservation.departureStation.id))) - // 기존 예약의 도착역 정보 조회 - .join(reservedArrivalStop) - .on(reservedArrivalStop.trainSchedule.id.eq(reservation.trainSchedule.id) - .and(reservedArrivalStop.station.id.eq(reservation.arrivalStation.id))) - // 검색 구간의 출발역 정보 - .join(searchDepartureStop) - .on(searchDepartureStop.trainSchedule.id.eq(trainScheduleId) - .and(searchDepartureStop.station.id.eq(departureStationId))) - // 검색 구간의 도착역 정보 - .join(searchArrivalStop) - .on(searchArrivalStop.trainSchedule.id.eq(trainScheduleId) - .and(searchArrivalStop.station.id.eq(arrivalStationId))) + List results = queryFactory + .select( + seatReservation.trainSchedule.id, + seatReservation.count() + ) + .from(seatReservation) + .join(seatReservation.reservation, reservation) + .join(reservation.departureStop, reservedDepartureStop) + .join(reservation.arrivalStop, reservedArrivalStop) + .join(searchDepartureStop).on( + searchDepartureStop.trainSchedule.id.in(trainScheduleIds) + .and(searchDepartureStop.station.id.eq(departureStationId)) + ) + .join(searchArrivalStop).on( + searchArrivalStop.trainSchedule.id.eq(seatReservation.trainSchedule.id) + .and(searchArrivalStop.station.id.eq(arrivalStationId)) + ) .where( - reservation.trainSchedule.id.eq(trainScheduleId), - reservation.seatStatus.in(SeatStatus.RESERVED, SeatStatus.LOCKED), - reservation.isStanding.isTrue(), - - // 구간 겹침 조건 (Interval Overlap Algorithm) - // NOT(end1 <= start2 OR start1 >= end2) = NOT(안겹침) - // 기존출발 < 검색도착 AND 기존도착 > 검색출발 - reservedDepartureStop.stopOrder.lt(searchArrivalStop.stopOrder) // less than - .and(reservedArrivalStop.stopOrder.gt(searchDepartureStop.stopOrder)) // greater than + seatReservation.trainSchedule.id.in(trainScheduleIds), + seatReservation.seat.isNull(), // 입석만 + reservedArrivalStop.stopOrder.gt(searchDepartureStop.stopOrder) + .and(reservedDepartureStop.stopOrder.lt(searchArrivalStop.stopOrder)) ) - .fetchOne(); + .groupBy(seatReservation.trainSchedule.id) + .fetch(); - return count != null ? count.intValue() : 0; + return results.stream() + .collect(Collectors.toMap( + tuple -> tuple.get(seatReservation.trainSchedule.id), + tuple -> tuple.get(seatReservation.count()).intValue() + )); } /** - * 특정 좌석의 예약 가능 여부 확인 - * - 해당 구간에서 좌석이 이미 점유되어 있는지 확인 + * 예약 ID로 해당 예약의 좌석 정보와 승객 타입을 조회 (PaymentService용) */ @Override - public boolean isSeatAvailableForSection(Long trainScheduleId, Long seatId, Long departureStationId, - Long arrivalStationId) { - QSeatReservation sr = QSeatReservation.seatReservation; - - Long count = queryFactory.select(sr.count()) - .from(sr) - .where(sr.trainSchedule.id.eq(trainScheduleId), sr.seat.id.eq(seatId), - // 점유 상태인 예약들만 확인 - sr.seatStatus.in(SeatStatus.RESERVED, SeatStatus.LOCKED), sr.isStanding.isFalse(), - - // 구간 겹침 확인 - sr.departureStation.id.lt(arrivalStationId).and(sr.arrivalStation.id.gt(departureStationId))) - .fetchOne(); - - return count == null || count == 0; + public List findSeatInfoByReservationId(Long reservationId) { + return queryFactory + .select(new QSeatInfoProjection( + seatReservation.seat, + seatReservation.passengerType + )) + .from(seatReservation) + .where(seatReservation.reservation.id.eq(reservationId)) + .fetch(); } } diff --git a/src/main/java/com/sudo/railo/train/infrastructure/TrainCarQueryRepositoryCustomImpl.java b/src/main/java/com/sudo/railo/train/infrastructure/TrainCarQueryRepositoryCustomImpl.java index b09d1d21..11aef406 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/TrainCarQueryRepositoryCustomImpl.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/TrainCarQueryRepositoryCustomImpl.java @@ -1,19 +1,20 @@ package com.sudo.railo.train.infrastructure; +import static com.sudo.railo.booking.domain.QReservation.*; + import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.springframework.stereotype.Repository; -import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import com.sudo.railo.booking.domain.QSeatReservation; -import com.sudo.railo.booking.domain.SeatStatus; import com.sudo.railo.train.application.dto.projection.QTrainCarProjection; import com.sudo.railo.train.application.dto.projection.TrainCarProjection; import com.sudo.railo.train.application.dto.response.TrainCarInfo; +import com.sudo.railo.train.domain.QScheduleStop; import com.sudo.railo.train.domain.QSeat; import com.sudo.railo.train.domain.QTrain; import com.sudo.railo.train.domain.QTrainCar; @@ -33,65 +34,68 @@ public class TrainCarQueryRepositoryCustomImpl implements TrainCarQueryRepositor @Override public List findAvailableTrainCars(Long trainScheduleId, Long departureStationId, Long arrivalStationId) { - QTrainSchedule ts = QTrainSchedule.trainSchedule; - QTrain t = QTrain.train; - QTrainCar tc = QTrainCar.trainCar; - QSeat s = QSeat.seat; - QSeatReservation sr = QSeatReservation.seatReservation; - - /** - * 상행 / 하행 방향 판단 : 출발역 < 도착역이면 하행, 아니면 상행 - * 역 ID가 낮은 쪽 → 높은 쪽: 하행 (예: 서울(10) → 부산(50)) - * 역 ID가 높은 쪽 → 낮은 쪽: 상행 (예: 부산(50) → 서울(10)) - */ - boolean isDownward = departureStationId < arrivalStationId; + QTrainSchedule trainSchedule = QTrainSchedule.trainSchedule; + QTrain train = QTrain.train; + QTrainCar trainCar = QTrainCar.trainCar; + QSeat seat = QSeat.seat; + QSeatReservation seatReservation = QSeatReservation.seatReservation; - // 구간 겹침 조건 정의 - // 예약 구간: [기존출발 ~ 기존도착] - // 요청 구간: [검색출발 ~ 검색도착] - // 겹침 조건: 기존출발 < 검색도착 AND 기존도착 > 검색출발 (하행 기준) - // 기존출발 > 검색도착 AND 기존도착 < 검색출발 (상행 기준) - BooleanExpression overlapCondition = isDownward - ? sr.departureStation.id.lt(arrivalStationId) - .and(sr.arrivalStation.id.gt(departureStationId)) // 하행 - : sr.departureStation.id.gt(arrivalStationId) - .and(sr.arrivalStation.id.lt(departureStationId)); // 상행 + // stopOrder 기반 구간 겹침을 위한 ScheduleStop 조인 + QScheduleStop reservedDepartureStop = new QScheduleStop("reservedDepartureStop"); + QScheduleStop reservedArrivalStop = new QScheduleStop("reservedArrivalStop"); + QScheduleStop searchDepartureStop = new QScheduleStop("searchDepartureStop"); + QScheduleStop searchArrivalStop = new QScheduleStop("searchArrivalStop"); // 1. 해당 trainScheduleId의 객차(trainCar) 조회 List carProjections = queryFactory .select(new QTrainCarProjection( - tc.id, - tc.carNumber, - tc.carType, - tc.totalSeats, + trainCar.id, + trainCar.carNumber, + trainCar.carType, + trainCar.totalSeats, Expressions.constant(0), // 임시 remainingSeats 기본값 처리 - tc.seatArrangement + trainCar.seatArrangement )) - .from(ts) - .join(ts.train, t) - .join(t.trainCars, tc) - .where(ts.id.eq(trainScheduleId)) - .orderBy(tc.carNumber.asc()) + .from(trainSchedule) + .join(trainSchedule.train, train) + .join(trainCar).on(trainCar.train.id.eq(train.id)) + .where(trainSchedule.id.eq(trainScheduleId)) + .orderBy(trainCar.carNumber.asc()) .fetch(); // 2. 각 객차별 예약된 좌석 수 계산 Map occupiedSeatsPerCar = queryFactory - .select(tc.id, sr.count()) - .from(sr) - .join(sr.seat, s) - .join(s.trainCar, tc) + .select(trainCar.id, seatReservation.count()) + .from(seatReservation) + .join(seatReservation.seat, seat) + .join(seat.trainCar, trainCar) + .join(seatReservation.reservation, reservation) + // stopOrder 기반 구간 겹침 조건 + .join(reservation.departureStop, reservedDepartureStop) + .join(reservation.arrivalStop, reservedArrivalStop) + .join(searchDepartureStop).on( + searchDepartureStop.trainSchedule.id.eq(trainScheduleId) + .and(searchDepartureStop.station.id.eq(departureStationId)) + ) + .join(searchArrivalStop).on( + searchArrivalStop.trainSchedule.id.eq(trainScheduleId) + .and(searchArrivalStop.station.id.eq(arrivalStationId)) + ) .where( - sr.trainSchedule.id.eq(trainScheduleId), // trainSchedule 직접 참조 - sr.seatStatus.in(SeatStatus.RESERVED, SeatStatus.LOCKED), - sr.isStanding.isFalse(), - overlapCondition // 구간 겹침 조건 + seatReservation.trainSchedule.id.eq(trainScheduleId), + seatReservation.seat.isNotNull(), + // stopOrder 기반 구간 겹침 조건 + // 구간 겹침: NOT(예약종료 <= 검색시작 OR 예약시작 >= 검색종료) + // = 예약종료 > 검색시작 AND 예약시작 < 검색종료 + reservedArrivalStop.stopOrder.gt(searchDepartureStop.stopOrder) + .and(reservedDepartureStop.stopOrder.lt(searchArrivalStop.stopOrder)) ) - .groupBy(tc.id) + .groupBy(trainCar.id) .fetch() .stream() .collect(Collectors.toMap( - tuple -> tuple.get(tc.id), - tuple -> tuple.get(sr.count()) + tuple -> tuple.get(trainCar.id), + tuple -> tuple.get(seatReservation.count()) )); // 3. remainingSeats 계산하여 업데이트하고 응답용 record로 변환 diff --git a/src/main/java/com/sudo/railo/train/infrastructure/TrainCarRepository.java b/src/main/java/com/sudo/railo/train/infrastructure/TrainCarRepository.java index e3e32bc5..379792aa 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/TrainCarRepository.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/TrainCarRepository.java @@ -11,4 +11,6 @@ public interface TrainCarRepository extends JpaRepository { List findByTrainIn(Collection trains); + + List findAllByTrainId(Long trainId); } diff --git a/src/main/java/com/sudo/railo/train/infrastructure/TrainRepository.java b/src/main/java/com/sudo/railo/train/infrastructure/TrainRepository.java index 45b168ce..b77851a2 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/TrainRepository.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/TrainRepository.java @@ -4,14 +4,10 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import com.sudo.railo.train.domain.Train; public interface TrainRepository extends JpaRepository { - @Query("SELECT t FROM Train t JOIN FETCH t.trainCars") - List findAllWithCars(); - List findByTrainNumberIn(Collection trainNumbers); } diff --git a/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepository.java b/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepository.java index 8e50e66e..1d8f53dd 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepository.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepository.java @@ -1,16 +1,13 @@ package com.sudo.railo.train.infrastructure; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; 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 com.sudo.railo.train.domain.TrainSchedule; @@ -26,24 +23,4 @@ List findByScheduleNameInAndOperationDateIn( Collection scheduleNames, Collection operationDate ); - - /** - * 마일리지 처리가 필요한 도착한 열차 조회 - * @param currentTime 현재 시간 - * @return 마일리지 미처리 도착 열차 목록 - */ - @Query("SELECT ts FROM TrainSchedule ts " + - "WHERE ts.actualArrivalTime <= :currentTime " + - "AND ts.mileageProcessed = false " + - "ORDER BY ts.actualArrivalTime ASC") - List findArrivedTrainsForMileageProcessing( - @Param("currentTime") LocalDateTime currentTime); - - /** - * 마일리지 처리 완료 표시 - * @param trainScheduleId 열차 스케줄 ID - */ - @Modifying - @Query("UPDATE TrainSchedule ts SET ts.mileageProcessed = true WHERE ts.id = :trainScheduleId") - void markMileageProcessed(@Param("trainScheduleId") Long trainScheduleId); } diff --git a/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepositoryCustom.java b/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepositoryCustom.java index 0a495104..a961c8cf 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepositoryCustom.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepositoryCustom.java @@ -2,20 +2,20 @@ import java.time.LocalDate; import java.time.LocalTime; -import java.util.Map; +import java.util.List; import java.util.Set; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import com.sudo.railo.train.application.dto.TrainBasicInfo; -import com.sudo.railo.train.domain.type.CarType; +import com.sudo.railo.train.application.dto.projection.TrainSeatInfoBatch; public interface TrainScheduleRepositoryCustom { /** * 날짜 범위에서 활성 스케줄이 있는 날짜들 조회 (운행 스케줄 캘린더 조회) - * TODO : 성능 모니터링 필요 : operation_calender 테이블, 배치, 캐시로 성능 개선 예정 + * TODO : 성능 모니터링 필요 : operation_calendar 테이블, 배치, 캐시로 성능 개선 예정 */ Set findDatesWithActiveSchedules(LocalDate startDate, LocalDate endDate); @@ -38,14 +38,9 @@ Slice findTrainBasicInfo( ); /** - * 열차의 좌석 타입별 전체 좌석 수 조회 - * 좌석 상태 계산을 위한 기준 데이터 + * 여러 열차의 객차 타입별, 열차 전체 인원 조회 + * @param trainScheduleIds + * @return TrainSeatInfoBatch (Map<열차스케줄ID, Map < 객차타입, 좌석수>>, Map<열차스케줄ID, 전체좌석수>) */ - Map findTotalSeatsByCarType(Long trainScheduleId); - - /** - * 열차 최대 수용 인원 조회 (입석 포함) - * 입석 가능 여부 판단용 - */ - int findTotalSeatsByTrainScheduleId(Long trainScheduleId); + TrainSeatInfoBatch findTrainSeatInfoBatch(List trainScheduleIds); } diff --git a/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepositoryCustomImpl.java b/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepositoryCustomImpl.java index bdf895cd..9bf5ac5f 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepositoryCustomImpl.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/TrainScheduleRepositoryCustomImpl.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -13,11 +14,12 @@ import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Repository; -import com.querydsl.core.Tuple; import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import com.sudo.railo.train.application.dto.TrainBasicInfo; +import com.sudo.railo.train.application.dto.projection.TrainSeatInfoBatch; +import com.sudo.railo.train.application.dto.projection.TrainSeatInfoProjection; import com.sudo.railo.train.domain.QScheduleStop; import com.sudo.railo.train.domain.QStation; import com.sudo.railo.train.domain.QTrain; @@ -82,8 +84,8 @@ public Slice findTrainBasicInfo( JPAQuery validScheduleSubQuery = queryFactory .select(ts.id) .from(ts) - .join(ts.scheduleStops, departureStop) - .join(ts.scheduleStops, arrivalStop) + .join(departureStop).on(departureStop.trainSchedule.id.eq(ts.id)) + .join(arrivalStop).on(arrivalStop.trainSchedule.id.eq(ts.id)) .where( ts.operationDate.eq(operationDate) .and(ts.operationStatus.eq(OperationStatus.ACTIVE)) @@ -110,9 +112,9 @@ public Slice findTrainBasicInfo( arrivalStation.stationName)) .from(ts) .join(ts.train, t) - .join(ts.scheduleStops, depStop2) + .join(depStop2).on(depStop2.trainSchedule.id.eq(ts.id)) .join(depStop2.station, departureStation) - .join(ts.scheduleStops, arrStop2) + .join(arrStop2).on(arrStop2.trainSchedule.id.eq(ts.id)) .join(arrStop2.station, arrivalStation) .where( ts.id.in(validScheduleSubQuery) @@ -147,52 +149,49 @@ public Slice findTrainBasicInfo( } /** - * 열차의 좌석 타입별 전체 좌석 수 조회 - * - 일반실/특실별 총 좌석 수 계산 - * - 좌석 상태 계산의 기준 데이터 + * 객차 타입별, 열차 전체 인원 조회 */ @Override - public Map findTotalSeatsByCarType(Long trainScheduleId) { - QTrainSchedule ts = QTrainSchedule.trainSchedule; - QTrain t = QTrain.train; - QTrainCar tc = QTrainCar.trainCar; + public TrainSeatInfoBatch findTrainSeatInfoBatch(List trainScheduleIds) { + if (trainScheduleIds.isEmpty()) { + return new TrainSeatInfoBatch(Map.of(), Map.of()); + } - // 객차별 좌석 수를 타입별로 합계 계산 - List results = queryFactory - .select(tc.carType, tc.totalSeats.sum()) // 객차타입별 좌석수 합계 - .from(ts) - .join(ts.train, t) // 열차 조인 - .join(t.trainCars, tc) // 객차 조인 - .where(ts.id.eq(trainScheduleId)) // 특정 열차 스케줄 - .groupBy(tc.carType) // 객차 타입별 그룹화 + QTrainSchedule trainSchedule = QTrainSchedule.trainSchedule; + QTrain train = QTrain.train; + QTrainCar trainCar = QTrainCar.trainCar; + + List seatInfoResults = queryFactory + .select(Projections.constructor(TrainSeatInfoProjection.class, + trainSchedule.id, + trainCar.carType, + trainCar.totalSeats.sum() + )) + .from(trainSchedule) + .join(trainSchedule.train, train) + .join(trainCar).on(trainCar.train.id.eq(train.id)) + .where(trainSchedule.id.in(trainScheduleIds)) + .groupBy(trainSchedule.id, trainCar.carType) .fetch(); - // Map으로 변환: {STANDARD=246, FIRST_CLASS=117} - return results.stream().collect(Collectors.toMap( - tuple -> tuple.get(tc.carType), // Key: 객차타입 - tuple -> tuple.get(tc.totalSeats.sum()).intValue() // Value: 좌석수 - )); - } + // 결과 변환: 객차별 좌석 수 + 전체 좌석 수 동시 계산 + Map> seatsByCarType = new HashMap<>(); + Map totalSeats = new HashMap<>(); - /** - * 열차 최대 수용 인원 조회 - */ - @Override - public int findTotalSeatsByTrainScheduleId(Long trainScheduleId) { - QTrainSchedule ts = QTrainSchedule.trainSchedule; - QTrain t = QTrain.train; - QTrainCar tc = QTrainCar.trainCar; + for (TrainSeatInfoProjection dto : seatInfoResults) { + Long trainScheduleId = dto.getTrainScheduleId(); + CarType carType = dto.getCarType(); + Integer seatCount = dto.getSeatCount(); - // 해당 열차의 전체 좌석 수 계산 - Integer totalSeats = queryFactory - .select(tc.totalSeats.sum()) - .from(ts) - .join(ts.train, t) - .join(t.trainCars, tc) - .where(ts.id.eq(trainScheduleId)) - .fetchOne(); + // 1. 객차별 좌석 수 저장 + seatsByCarType.computeIfAbsent(trainScheduleId, k -> new HashMap<>()) + .put(carType, seatCount); + + // 2. 전체 좌석 수 누적 계산 + totalSeats.merge(trainScheduleId, seatCount, Integer::sum); + } - return totalSeats != null ? totalSeats : 0; + return new TrainSeatInfoBatch(seatsByCarType, totalSeats); } @Getter diff --git a/src/main/java/com/sudo/railo/train/presentation/TrainSearchController.java b/src/main/java/com/sudo/railo/train/presentation/TrainSearchController.java index 36e8420c..2bdd3a00 100644 --- a/src/main/java/com/sudo/railo/train/presentation/TrainSearchController.java +++ b/src/main/java/com/sudo/railo/train/presentation/TrainSearchController.java @@ -11,8 +11,8 @@ import org.springframework.web.bind.annotation.RestController; import com.sudo.railo.global.success.SuccessResponse; -import com.sudo.railo.train.application.TrainScheduleService; import com.sudo.railo.train.application.TrainSearchApplicationService; +import com.sudo.railo.train.application.TrainSearchService; import com.sudo.railo.train.application.dto.request.TrainCarListRequest; import com.sudo.railo.train.application.dto.request.TrainCarSeatDetailRequest; import com.sudo.railo.train.application.dto.request.TrainSearchRequest; @@ -36,7 +36,7 @@ @Slf4j public class TrainSearchController { - private final TrainScheduleService trainScheduleService; + private final TrainSearchService trainSearchService; private final TrainSearchApplicationService trainSearchApplicationService; /** @@ -46,7 +46,7 @@ public class TrainSearchController { @Operation(summary = "운행 캘린더 조회", description = "금일로부터 한 달간의 운행 캘린더를 조회합니다.") public SuccessResponse> getOperationCalendar() { log.info("운행 캘린더 조회"); - List calendar = trainScheduleService.getOperationCalendar(); + List calendar = trainSearchApplicationService.getOperationCalendar(); log.info("운행 캘린더 조회: {} 건", calendar.size()); return SuccessResponse.of(TrainSearchSuccess.OPERATION_CALENDAR_SUCCESS, calendar); @@ -70,7 +70,7 @@ public SuccessResponse searchTrainSchedules( request.operationDate(), request.passengerCount(), request.departureHour(), pageable.getPageNumber(), pageable.getPageSize()); - TrainSearchSlicePageResponse response = trainScheduleService.searchTrains(request, pageable); + TrainSearchSlicePageResponse response = trainSearchApplicationService.searchTrains(request, pageable); return SuccessResponse.of(TrainSearchSuccess.TRAIN_SEARCH_SUCCESS, response); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 00000000..01cb5b7a --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,94 @@ +spring: + config: + activate: + on-profile: dev + import: + - optional:file:.env[.properties] + - classpath:train-template.yml + + jackson: + time-zone: Asia/Seoul + + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PW} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + order_inserts: true + format_sql: true + jdbc: + batch_size: 500 + + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + + batch: + jdbc: + initialize-schema: always + job: + enabled: false + +train: + schedule: + excel: + filename: ${TRAIN_SCHEDULE_FILENAME} + station-fare: + excel: + filename: ${STATION_FARE_FILENAME} + standing: + ratio: 0.15 + +cors: + allowed-origins: http://localhost:3000 + allowed-methods: GET, POST, PUT, DELETE + allowed-headers: Access-Control-Allow-Origin, Content-type, Access-Control-Allow-Headers, Authorization, X-Requested-With + +jwt: + secret: ${JWT_KEY} + +cookie: + domain: localhost + +booking: + expiration: + reservation: 10 + +# Prometheus +management: + endpoints: + web: + exposure: + include: prometheus, health, metrics + metrics: + distribution: + percentiles-histogram: + "[http.server.requests]": true + tags: + application: raillo-backend + endpoint: + prometheus: + access: unrestricted diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 1256a179..0d0289f9 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -8,7 +8,7 @@ spring: jackson: time-zone: Asia/Seoul - + data: redis: host: redis-service # k8s의 redis 서비스명 @@ -22,11 +22,11 @@ spring: jpa: hibernate: ddl-auto: update - show-sql: true + show-sql: false properties: hibernate: order_inserts: true - format_sql: true + format_sql: false jdbc: batch_size: 500 @@ -46,6 +46,12 @@ spring: timeout: 5000 writetimeout: 5000 + batch: + jdbc: + initialize-schema: always + job: + enabled: false + train: schedule: excel: @@ -57,13 +63,16 @@ train: ratio: 0.15 cors: - allowed-origins: http://localhost:3000, https://www.raillo.shop + allowed-origins: https://www.raillo.shop allowed-methods: GET, POST, PUT, DELETE allowed-headers: Access-Control-Allow-Origin, Content-type, Access-Control-Allow-Headers, Authorization, X-Requested-With jwt: secret: ${JWT_KEY} +cookie: + domain: .raillo.shop + booking: expiration: reservation: 10 @@ -83,19 +92,3 @@ management: endpoint: prometheus: access: unrestricted - -# Payment Configuration -payment: - kakaopay: - admin-key: ${KAKAO_PAY_ADMIN_KEY:SECRET_KEY test_admin_key_from_developers_kakao} - cid: ${KAKAO_PAY_CID:TC0ONETIME} - ready-url: https://open-api.kakaopay.com/online/v1/payment/ready - approve-url: https://open-api.kakaopay.com/online/v1/payment/approve - cancel-url: https://open-api.kakaopay.com/online/v1/payment/cancel - order-url: https://open-api.kakaopay.com/online/v1/payment/order - naverpay: - client-id: ${NAVER_PAY_CLIENT_ID:test_client_id} - client-secret: ${NAVER_PAY_CLIENT_SECRET:test_client_secret} - crypto: - secret-key: ${PAYMENT_CRYPTO_KEY:dGVzdC1wYXltZW50LWNyeXB0by1rZXktMzItYnl0ZXM=} - key-rotation-enabled: false \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9f966068..3d7808a0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,3 @@ spring: profiles: - active: prod + active: dev diff --git a/src/test/java/com/sudo/railo/RailoApplicationTests.java b/src/test/java/com/sudo/railo/RailoApplicationTests.java index 080a3c66..245cf641 100644 --- a/src/test/java/com/sudo/railo/RailoApplicationTests.java +++ b/src/test/java/com/sudo/railo/RailoApplicationTests.java @@ -2,12 +2,14 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class RailoApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/src/test/java/com/sudo/railo/auth/application/AuthServiceTest.java b/src/test/java/com/sudo/railo/auth/application/AuthServiceTest.java new file mode 100644 index 00000000..1c3e69db --- /dev/null +++ b/src/test/java/com/sudo/railo/auth/application/AuthServiceTest.java @@ -0,0 +1,224 @@ +package com.sudo.railo.auth.application; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import com.sudo.railo.auth.application.dto.request.LoginRequest; +import com.sudo.railo.auth.application.dto.request.SignUpRequest; +import com.sudo.railo.auth.application.dto.response.ReissueTokenResponse; +import com.sudo.railo.auth.application.dto.response.SignUpResponse; +import com.sudo.railo.auth.application.dto.response.TokenResponse; +import com.sudo.railo.auth.exception.TokenError; +import com.sudo.railo.auth.security.jwt.TokenGenerator; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.global.redis.AuthRedisRepository; +import com.sudo.railo.global.redis.RedisKeyGenerator; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.domain.MemberDetail; +import com.sudo.railo.member.exception.MemberError; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; + +@ServiceTest +class AuthServiceTest { + + @Autowired + private AuthService authService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BCryptPasswordEncoder passwordEncoder; + + @Autowired + private AuthRedisRepository authRedisRepository; + + @Autowired + private RedisTemplate objectRedisTemplate; + + @Autowired + private RedisKeyGenerator redisKeyGenerator; + + @Autowired + private TokenGenerator tokenGenerator; + + @Test + @DisplayName("회원가입에 성공한다.") + void signUp_success() { + //given + SignUpRequest request = new SignUpRequest("김이름", "01012341234", "testPwd", "test@example.com", "1990-01-01", + "M"); + + //when + SignUpResponse response = authService.signUp(request); + + //then + Member savedMember = memberRepository.findByMemberNo(response.memberNo()) + .orElseThrow(() -> new AssertionError("회원 정보가 DB에 저장되지 않았습니다.")); + MemberDetail savedMemberDetail = savedMember.getMemberDetail(); + + assertThat(savedMember.getName()).isEqualTo(request.name()); + assertThat(savedMember.getPhoneNumber()).isEqualTo(request.phoneNumber()); + assertThat(passwordEncoder.matches(request.password(), savedMember.getPassword())).isTrue(); + assertThat(response.memberNo()).isNotNull(); + assertThat(savedMemberDetail.getEmail()).isEqualTo(request.email()); + assertThat(savedMemberDetail.getBirthDate()).isEqualTo(request.birthDate()); + assertThat(savedMemberDetail.getGender()).isEqualTo(request.gender()); + } + + @Test + @DisplayName("중복된 이메일로 회원가입 시도 시 실패한다.") + void signUp_fail() { + //given + Member member = MemberFixture.createStandardMember(); + memberRepository.save(member); + + SignUpRequest request = new SignUpRequest("김이름", "01012341234", "testPwd", "test@example.com", "1990-01-01", + "M"); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> authService.signUp(request)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.DUPLICATE_EMAIL)); + } + + @Test + @DisplayName("회원이 로그인에 성공한다.") + void login_success() { + //given + Member member = createMemberWithEncryptedPassword(); + String memberNo = member.getMemberDetail().getMemberNo(); + + LoginRequest request = new LoginRequest(memberNo, "testPassword"); + + //when + TokenResponse response = authService.login(request); + + //then + assertThat(response.grantType()).isEqualTo("Bearer"); + assertThat(response.accessToken()).isNotEmpty(); + assertThat(response.refreshToken()).isNotEmpty(); + assertThat(response.accessTokenExpiresIn()).isNotNull(); + + String savedRefreshToken = authRedisRepository.getRefreshToken(member.getMemberDetail().getMemberNo()); + assertThat(savedRefreshToken).isEqualTo(response.refreshToken()); + } + + @Test + @DisplayName("회원이 로그아웃에 성공한다.") + void logout_success() { + //given + Member member = createMemberWithEncryptedPassword(); + String memberNo = member.getMemberDetail().getMemberNo(); + + LoginRequest request = new LoginRequest(memberNo, "testPassword"); + TokenResponse response = authService.login(request); + + String accessToken = response.accessToken(); + + String logoutTokenKey = redisKeyGenerator.generateLogoutTokenKey(accessToken); + + //when + authService.logout(accessToken, memberNo); + + //then + assertThat(authRedisRepository.getRefreshToken(memberNo)).isNull(); + assertThat(objectRedisTemplate.hasKey(logoutTokenKey)).isTrue(); + } + + @Test + @DisplayName("리프레시 토큰만 유효시간이 만료되어 레디스에 존재하지 않아도 로그아웃에 성공한다.") + void logout_success_when_refresh_token_is_expired() { + //given + Member member = createMemberWithEncryptedPassword(); + String memberNo = member.getMemberDetail().getMemberNo(); + + LoginRequest request = new LoginRequest(memberNo, "testPassword"); + TokenResponse response = authService.login(request); + + String accessToken = response.accessToken(); + String logoutTokenKey = redisKeyGenerator.generateLogoutTokenKey(accessToken); + + authRedisRepository.deleteRefreshToken(memberNo); // 리프레시 토큰을 삭제하여 만료된 상황 시뮬레이션 + + //when + authService.logout(accessToken, memberNo); + + //then + assertThat(authRedisRepository.getRefreshToken(memberNo)).isNull(); + assertThat(objectRedisTemplate.hasKey(logoutTokenKey)).isTrue(); + } + + @Test + @DisplayName("액세스 토큰 재발급에 성공한다.") + void reissueAccessToken_success() { + //given + Member member = createMemberWithEncryptedPassword(); + String memberNo = member.getMemberDetail().getMemberNo(); + + LoginRequest request = new LoginRequest(memberNo, "testPassword"); + TokenResponse tokenResponse = authService.login(request); + String refreshToken = tokenResponse.refreshToken(); + + //when + ReissueTokenResponse reissueTokenResponse = authService.reissueAccessToken(refreshToken); + + //then + assertThat(reissueTokenResponse.grantType()).isEqualTo("Bearer"); + assertThat(reissueTokenResponse.accessToken()).isNotEmpty(); + assertThat(reissueTokenResponse.accessTokenExpiresIn()).isNotNull(); + } + + @Test + @DisplayName("저장된 리프레시 토큰과 일치하지 않을 경우 액세스 토큰 재발급에 실패한다.") + void reissueAccessToken_fail() { + //given + Member member = MemberFixture.createStandardMember(); + memberRepository.save(member); + + // refreshToken 이 일치하지 않도록 테스트 인증 객체 따로 생성 후 토큰 생성 + List authorities = List.of(new SimpleGrantedAuthority(member.getRole().toString())); + UserDetails userDetails = new User(member.getMemberDetail().getMemberNo(), member.getPassword(), authorities); + Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, member.getPassword(), + authorities); + + TokenResponse tokenResponse = tokenGenerator.generateTokenDTO(authentication); + String refreshToken = tokenResponse.refreshToken(); + + String diffRefreshToken = refreshToken + "diff"; + authRedisRepository.saveRefreshToken(member.getMemberDetail().getMemberNo(), diffRefreshToken); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> authService.reissueAccessToken(refreshToken)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(TokenError.NOT_EQUALS_REFRESH_TOKEN)); + } + + private Member createMemberWithEncryptedPassword() { + Member member = MemberFixture.createStandardMember(); + String plainPwd = member.getPassword(); + String encodedPwd = passwordEncoder.encode(plainPwd); + + member.updatePassword(encodedPwd); + + return memberRepository.save(member); + } + +} diff --git a/src/test/java/com/sudo/railo/auth/application/EmailAuthServiceTest.java b/src/test/java/com/sudo/railo/auth/application/EmailAuthServiceTest.java new file mode 100644 index 00000000..f378cb02 --- /dev/null +++ b/src/test/java/com/sudo/railo/auth/application/EmailAuthServiceTest.java @@ -0,0 +1,117 @@ +package com.sudo.railo.auth.application; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.junit5.GreenMailExtension; +import com.icegreen.greenmail.util.GreenMailUtil; +import com.icegreen.greenmail.util.ServerSetup; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; +import com.sudo.railo.global.redis.AuthRedisRepository; +import com.sudo.railo.support.annotation.ServiceTest; + +@ServiceTest +class EmailAuthServiceTest { + + // GreenMail 확장 등록 + @RegisterExtension + static GreenMailExtension greenMail = new GreenMailExtension( + new ServerSetup(0, "127.0.0.1", ServerSetup.PROTOCOL_SMTP) // 포트 충돌이 있어 동적 포트 할당 + ) + .withConfiguration(GreenMailConfiguration.aConfig().withUser("testUser", "testPassword")) + .withPerMethodLifecycle(true); + + @Autowired + private EmailAuthService emailAuthService; + + @Autowired + private AuthRedisRepository authRedisRepository; + + @Autowired + private JavaMailSenderImpl mailSender; + + @BeforeEach + void setUp() { + greenMail.start(); + mailSender.setPort(greenMail.getSmtp().getPort()); + } + + @AfterEach + void tearDown() { + greenMail.stop(); + } + + @Test + @DisplayName("이메일 인증 코드 전송에 성공한다.") + void sendAuthCode_success() { + //given + String email = "test@example.com"; + + //when + SendCodeResponse response = emailAuthService.sendAuthCode(email); + + //then + assertThat(response).isNotNull(); + assertThat(response.email()).isEqualTo(email); + + assertThat(greenMail.waitForIncomingEmail(5000, 1)).isTrue(); // 이메일 전송 확인 + + // 레디스에 인증 코드 저장 확인 + String savedAuthCode = authRedisRepository.getAuthCode(email); + assertThat(savedAuthCode).isNotNull(); + + // 이메일 내용에 인증 코드를 포함하는지 확인 + String content = GreenMailUtil.getBody(greenMail.getReceivedMessages()[0]); + assertThat(content).contains(savedAuthCode); + } + + @Test + @DisplayName("올바른 인증 코드로 검증에 성공한다.") + void verifyAuthCode_success() { + //given + String email = "test@example.com"; + String authCode = "123456"; + + authRedisRepository.saveAuthCode(email, authCode); + + //when + boolean isVerified = emailAuthService.verifyAuthCode(email, authCode); + + //then + assertThat(isVerified).isTrue(); + + // 검증 완료 후 레디스 상에 인증 코드가 남아 있지 않은지 확인 + String redisAuthCode = authRedisRepository.getAuthCode(email); + assertThat(redisAuthCode).isNull(); + } + + @Test + @DisplayName("인증 코드가 일치하지 않으면 검증에 실패한다.") + void verifyAuthCode_fail() { + //given + String email = "test@example.com"; + String correctAuthCode = "123456"; + String wrongAuthCode = "111111"; + + authRedisRepository.saveAuthCode(email, correctAuthCode); + + //when + boolean isVerified = emailAuthService.verifyAuthCode(email, wrongAuthCode); + + //then + assertThat(isVerified).isFalse(); + + // 검증 실패 시에도 레디스에 인증 코드 유효 + String redisAuthCode = authRedisRepository.getAuthCode(email); + assertThat(redisAuthCode).isEqualTo(correctAuthCode); + } + +} diff --git a/src/test/java/com/sudo/railo/auth/security/jwt/TokenExtractorTest.java b/src/test/java/com/sudo/railo/auth/security/jwt/TokenExtractorTest.java new file mode 100644 index 00000000..6ffd1241 --- /dev/null +++ b/src/test/java/com/sudo/railo/auth/security/jwt/TokenExtractorTest.java @@ -0,0 +1,194 @@ +package com.sudo.railo.auth.security.jwt; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.Authentication; + +import com.sudo.railo.auth.exception.TokenError; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.support.annotation.ServiceTest; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; + +@ServiceTest +class TokenExtractorTest { + + private static final String TEST_SECRET_KEY = "crailotestjwtsecretkey2025fordevelopmentandtestingonlylonglonglonglonglonglonglonglonglong=="; + private static final String MEMBER_NO = "test"; + private static final String AUTHORITIES = "ROLE_MEMBER"; + + private TokenExtractor tokenExtractor; + private SecretKey testKey; + + @BeforeEach + void setUp() { + byte[] keyBytes = Decoders.BASE64.decode(TEST_SECRET_KEY); + testKey = Keys.hmacShaKeyFor(keyBytes); + tokenExtractor = new TokenExtractor(TEST_SECRET_KEY); + } + + @Test + @DisplayName("Authorization 헤더에서 Bearer 토큰을 추출한다.") + void resolveToken() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + String token = "test-token"; + request.addHeader("Authorization", "Bearer " + token); + + // when + String result = tokenExtractor.resolveToken(request); + + // then + assertThat(result).isEqualTo(token); + } + + @Test + @DisplayName("Authorization 헤더가 없으면 null을 반환한다.") + void resolveTokenWithoutHeader() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + + // when + String result = tokenExtractor.resolveToken(request); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Authorization 헤더가 Bearer로 시작하지 않으면 null을 반환한다.") + void resolveTokenWithoutBearerPrefix() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Basic test-token"); + + // when + String result = tokenExtractor.resolveToken(request); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("유효한 토큰에서 회원번호를 추출한다.") + void getMemberNo() { + // given + String token = createValidToken(); + + // when + String result = tokenExtractor.getMemberNo(token); + + // then + assertThat(result).isEqualTo(MEMBER_NO); + } + + @Test + @DisplayName("유효한 AccessToken 에서 남은 유효시간을 추출한다.") + void getAccessTokenExpiration() { + // given + long expirationTime = System.currentTimeMillis() + (30 * 60 * 1000); + String token = createTokenWithExpiration(expirationTime); + + // when + Duration result = tokenExtractor.getAccessTokenExpiration(token); + + // then + assertThat(result.toMillis()).isBetween(29 * 60 * 1000L, 30 * 60 * 1000L); + } + + @Test + @DisplayName("유효한 토큰에서 인증 정보를 추출한다.") + void getAuthentication() { + // given + String token = createValidToken(); + + // when + Authentication result = tokenExtractor.getAuthentication(token); + + // then + assertThat(result.getName()).isEqualTo(MEMBER_NO); + assertThat(result.getAuthorities()).hasSize(1); + assertThat(result.getAuthorities().iterator().next().getAuthority()).isEqualTo(AUTHORITIES); + } + + @Test + @DisplayName("권한 정보가 없는 토큰에서 인증 정보를 추출하려고 하면 예외가 발생한다.") + void getAuthenticationWithoutAuthorities() { + // given + String tokenWithoutAuth = Jwts.builder() + .setSubject(MEMBER_NO) + .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60)) + .signWith(testKey, SignatureAlgorithm.HS512) + .compact(); + + // when & then + assertThatThrownBy(() -> tokenExtractor.getAuthentication(tokenWithoutAuth)) + .isInstanceOf(BusinessException.class) + .hasMessage(TokenError.AUTHORITY_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("유효한 토큰에서 클레임을 추출한다.") + void parseClaims() { + // given + String token = createValidToken(); + + // when + Claims result = tokenExtractor.parseClaims(token); + + // then + assertThat(result.getSubject()).isEqualTo(MEMBER_NO); + assertThat(result.get(TokenExtractor.AUTHORITIES_KEY)).isEqualTo(AUTHORITIES); + } + + @Test + @DisplayName("만료된 토큰에서도 클레임을 추출한다.") + void parseClaimsFromExpiredToken() { + // given + String expiredToken = Jwts.builder() + .setSubject(MEMBER_NO) + .claim(TokenExtractor.AUTHORITIES_KEY, AUTHORITIES) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(testKey, SignatureAlgorithm.HS512) + .compact(); + + // when + Claims result = tokenExtractor.parseClaims(expiredToken); + + // then + assertThat(result.getSubject()).isEqualTo(MEMBER_NO); + assertThat(result.get(TokenExtractor.AUTHORITIES_KEY)).isEqualTo(AUTHORITIES); + } + + private String createValidToken() { + return Jwts.builder() + .setSubject(MEMBER_NO) + .claim(TokenExtractor.AUTHORITIES_KEY, AUTHORITIES) + .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60)) + .signWith(testKey, SignatureAlgorithm.HS512) + .compact(); + } + + private String createTokenWithExpiration(long expirationTime) { + return Jwts.builder() + .setSubject(MEMBER_NO) + .claim(TokenExtractor.AUTHORITIES_KEY, AUTHORITIES) + .setExpiration(new Date(expirationTime)) + .signWith(testKey, SignatureAlgorithm.HS512) + .compact(); + } +} diff --git a/src/test/java/com/sudo/railo/auth/security/jwt/TokenGeneratorTest.java b/src/test/java/com/sudo/railo/auth/security/jwt/TokenGeneratorTest.java new file mode 100644 index 00000000..fa75f9a5 --- /dev/null +++ b/src/test/java/com/sudo/railo/auth/security/jwt/TokenGeneratorTest.java @@ -0,0 +1,158 @@ +package com.sudo.railo.auth.security.jwt; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.security.core.Authentication; + +import com.sudo.railo.auth.exception.TokenError; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.support.annotation.ServiceTest; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; + +@ServiceTest +class TokenGeneratorTest { + + private static final String TEST_SECRET_KEY = "crailotestjwtsecretkey2025fordevelopmentandtestingonlylonglonglonglonglonglonglonglonglong=="; + + @Mock + private Authentication authentication; + + private TokenValidator tokenValidator; + + private TokenGenerator tokenGenerator; + + private SecretKey testKey; + + @BeforeEach + void setUp() { + byte[] keyBytes = Decoders.BASE64.decode(TEST_SECRET_KEY); + testKey = Keys.hmacShaKeyFor(keyBytes); + tokenValidator = new TokenValidator(TEST_SECRET_KEY); + TokenExtractor tokenExtractor = new TokenExtractor(TEST_SECRET_KEY); + tokenGenerator = new TokenGenerator(TEST_SECRET_KEY, tokenValidator, tokenExtractor); + } + + @Test + @DisplayName("Authentication에 있는 회원 정보로 토큰을 발급한다.") + void generateTokens() { + // given + String memberNo = "test"; + when(authentication.getName()).thenReturn(memberNo); + + // when + long beforeTime = System.currentTimeMillis(); + var result = tokenGenerator.generateTokenDTO(authentication); + long afterTime = System.currentTimeMillis(); + + // then + assertThat(result.grantType()).isEqualTo("Bearer"); + tokenValidator.validateToken(result.accessToken()); + tokenValidator.validateToken(result.refreshToken()); + assertThat(result.accessTokenExpiresIn()).isBetween(beforeTime + (30 * 60 * 1000), + afterTime + (30 * 60 * 1000)); + } + + @Test + @DisplayName("유효한 refreshToken 으로 accessToken을 재발급한다.") + void refreshAccessToken() { + // given + String memberNo = "test"; + when(authentication.getName()).thenReturn(memberNo); + var tokenResponse = tokenGenerator.generateTokenDTO(authentication); + + // when + long beforeTime = System.currentTimeMillis(); + var result = tokenGenerator.reissueAccessToken(tokenResponse.refreshToken()); + long afterTime = System.currentTimeMillis(); + + // then + tokenValidator.validateToken(result.accessToken()); + assertThat(result.accessTokenExpiresIn()).isBetween(beforeTime + (30 * 60 * 1000), + afterTime + (30 * 60 * 1000)); + } + + @Test + @DisplayName("회원 정보로 TemporaryToken을 발급한다.") + void generateTemporaryToken() { + // given + String memberNo = "test"; + + // when + String temporaryToken = tokenGenerator.generateTemporaryToken(memberNo); + + // then + tokenValidator.validateToken(temporaryToken); + } + + @Test + @DisplayName("만료된 refreshToken 으로 accessToken 재발급을 시도하면 예외가 발생한다.") + void shouldThrowExceptionWhenReissuingAccessTokenWithInvalidRefreshToken() { + // given + String memberNo = "test"; + String authorities = "ROLE_MEMBER"; + String invalidRefreshToken = Jwts.builder() + .setSubject(memberNo) + .claim("auth", authorities) + .claim("isRefreshToken", true) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(testKey, SignatureAlgorithm.HS512) + .compact(); + + // when & then + assertThatThrownBy(() -> tokenGenerator.reissueAccessToken(invalidRefreshToken)) + .isInstanceOf(BusinessException.class) + .hasMessage(TokenError.INVALID_REFRESH_TOKEN.getMessage()); + } + + @Test + @DisplayName("isRefreshToken 클레임이 없는 refreshToken 으로 accessToken 재발급을 시도하면 예외가 발생한다.") + void shouldThrowExceptionWhenReissuingAccessTokenWithoutRefreshTokenClaim() { + // given + String memberNo = "test"; + String authorities = "ROLE_MEMBER"; + String tokenWithoutClaim = Jwts.builder() + .setSubject(memberNo) + .claim("auth", authorities) + .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60)) + .signWith(testKey, SignatureAlgorithm.HS512) + .compact(); + + // when & then + assertThatThrownBy(() -> tokenGenerator.reissueAccessToken(tokenWithoutClaim)) + .isInstanceOf(BusinessException.class) + .hasMessage(TokenError.INVALID_REFRESH_TOKEN.getMessage()); + } + + @Test + @DisplayName("isRefreshToken 클레임이 false인 refreshToken 으로 accessToken 재발급을 시도하면 예외가 발생한다.") + void shouldThrowExceptionWhenReissuingAccessTokenWithFalseRefreshTokenClaim() { + // given + String memberNo = "test"; + String authorities = "ROLE_MEMBER"; + String tokenWithFalseClaim = Jwts.builder() + .setSubject(memberNo) + .claim("auth", authorities) + .claim("isRefreshToken", false) + .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60)) + .signWith(testKey, SignatureAlgorithm.HS512) + .compact(); + + // when & then + assertThatThrownBy(() -> tokenGenerator.reissueAccessToken(tokenWithFalseClaim)) + .isInstanceOf(BusinessException.class) + .hasMessage(TokenError.INVALID_REFRESH_TOKEN.getMessage()); + } +} diff --git a/src/test/java/com/sudo/railo/auth/security/jwt/TokenValidatorTest.java b/src/test/java/com/sudo/railo/auth/security/jwt/TokenValidatorTest.java new file mode 100644 index 00000000..e3050589 --- /dev/null +++ b/src/test/java/com/sudo/railo/auth/security/jwt/TokenValidatorTest.java @@ -0,0 +1,135 @@ +package com.sudo.railo.auth.security.jwt; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.sudo.railo.support.annotation.ServiceTest; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; + +@ServiceTest +class TokenValidatorTest { + + private static final String TEST_SECRET_KEY = "crailotestjwtsecretkey2025fordevelopmentandtestingonlylonglonglonglonglonglonglonglonglong=="; + private static final String MEMBER_NO = "test"; + + private TokenValidator tokenValidator; + private SecretKey testKey; + + @BeforeEach + void setUp() { + byte[] keyBytes = Decoders.BASE64.decode(TEST_SECRET_KEY); + testKey = Keys.hmacShaKeyFor(keyBytes); + tokenValidator = new TokenValidator(TEST_SECRET_KEY); + } + + @Test + @DisplayName("유효한 토큰은 검증을 통과한다.") + void validateValidToken() { + // given + String validToken = createValidToken(); + + // when + boolean result = tokenValidator.validateToken(validToken); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("잘못된 서명의 토큰은 검증에 실패한다.") + void validateTokenWithInvalidSignature() { + // given + String invalidKey = "invalidsecretkeyinvalidsecretkey2025fordevelopmentandtestingonlylonglonglonglonglonglonglonglonglong=="; + byte[] invalidKeyBytes = Decoders.BASE64.decode(invalidKey); + SecretKey invalidSecretKey = Keys.hmacShaKeyFor(invalidKeyBytes); + + String tokenWithInvalidSignature = Jwts.builder() + .setSubject(MEMBER_NO) + .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60)) + .signWith(invalidSecretKey, SignatureAlgorithm.HS512) + .compact(); + + // when + boolean result = tokenValidator.validateToken(tokenWithInvalidSignature); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("만료된 토큰은 검증에 실패한다.") + void validateExpiredToken() { + // given + String expiredToken = Jwts.builder() + .setSubject(MEMBER_NO) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(testKey, SignatureAlgorithm.HS512) + .compact(); + + // when + boolean result = tokenValidator.validateToken(expiredToken); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("형식이 잘못된 토큰은 검증에 실패한다.") + void validateMalformedToken() { + // given + String malformedToken = "malformed.token.string"; + + // when + boolean result = tokenValidator.validateToken(malformedToken); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("null 또는 빈 토큰은 검증에 실패한다.") + void validateNullOrEmptyToken() { + // when + boolean resultNull = tokenValidator.validateToken(null); + boolean resultEmpty = tokenValidator.validateToken(""); + + // then + assertThat(resultNull).isFalse(); + assertThat(resultEmpty).isFalse(); + } + + @Test + @DisplayName("지원되지 않는 JWT 토큰은 검증에 실패한다.") + void validateUnsupportedToken() { + // given + String unsignedToken = Jwts.builder() + .setSubject(MEMBER_NO) + .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60)) + .compact(); + + // when + boolean result = tokenValidator.validateToken(unsignedToken); + + // then + assertThat(result).isFalse(); + } + + private String createValidToken() { + return Jwts.builder() + .setSubject(MEMBER_NO) + .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60)) + .signWith(testKey, SignatureAlgorithm.HS512) + .compact(); + } +} diff --git a/src/test/java/com/sudo/railo/booking/application/CartReservationServiceTest.java b/src/test/java/com/sudo/railo/booking/application/CartReservationServiceTest.java new file mode 100644 index 00000000..84057235 --- /dev/null +++ b/src/test/java/com/sudo/railo/booking/application/CartReservationServiceTest.java @@ -0,0 +1,135 @@ +package com.sudo.railo.booking.application; + +import static com.sudo.railo.support.helper.TrainScheduleTestHelper.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.booking.application.dto.request.CartReservationCreateRequest; +import com.sudo.railo.booking.application.dto.response.ReservationDetail; +import com.sudo.railo.booking.domain.Reservation; +import com.sudo.railo.booking.exception.BookingError; +import com.sudo.railo.booking.infrastructure.CartReservationRepository; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.ReservationTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.domain.Train; + +@ServiceTest +class CartReservationServiceTest { + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private ReservationTestHelper reservationTestHelper; + + @Autowired + private CartReservationService cartReservationService; + + @Autowired + private CartReservationRepository cartReservationRepository; + + @Autowired + private MemberRepository memberRepository; + + private String memberNo; + private Reservation reservation; + + @BeforeEach + void setUp() { + Member member = MemberFixture.createStandardMember(); + memberNo = member.getMemberDetail().getMemberNo(); + memberRepository.save(member); + + Train train = trainTestHelper.createKTX(); + TrainScheduleWithStopStations scheduleWithStops = trainScheduleTestHelper.createSchedule(train); + reservation = reservationTestHelper.createReservation(member, scheduleWithStops); + } + + @Test + @DisplayName("장바구니에 예약을 등록하는데 성공한다") + void createCartReservation_success() { + // given + CartReservationCreateRequest request = new CartReservationCreateRequest(reservation.getId()); + + // when + cartReservationService.createCartReservation(memberNo, request); + + // then + boolean exists = cartReservationRepository.existsByReservation(reservation); + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("자신의 예약이 아니라면 예외가 발생한다") + void createCartReservation_accessDenied_throwsException() { + // given + Member otherMember = MemberFixture.createOtherMember(); + String otherMemberNo = otherMember.getMemberDetail().getMemberNo(); + memberRepository.save(otherMember); + + CartReservationCreateRequest request = new CartReservationCreateRequest(reservation.getId()); + + // when & then + assertThatThrownBy(() -> cartReservationService.createCartReservation(otherMemberNo, request)) + .isInstanceOf(BusinessException.class) + .hasMessage(BookingError.RESERVATION_ACCESS_DENIED.getMessage()); + } + + @Test + @DisplayName("장바구니에 이미 등록된 예약이라면 예외가 발생한다") + void createCartReservation_duplicate_throwsException() { + // given + CartReservationCreateRequest request = new CartReservationCreateRequest(reservation.getId()); + + // when & then + assertThatThrownBy(() -> { + cartReservationService.createCartReservation(memberNo, request); + cartReservationService.createCartReservation(memberNo, request); + }) + .isInstanceOf(BusinessException.class) + .hasMessage(BookingError.RESERVATION_ALREADY_RESERVED.getMessage()); + } + + @Test + @DisplayName("장바구니 조회에 성공한다") + void getCartReservations_success() { + // given + CartReservationCreateRequest request = new CartReservationCreateRequest(reservation.getId()); + cartReservationService.createCartReservation(memberNo, request); + + // when + List cart = cartReservationService.getCartReservations(memberNo); + + // then + assertThat(cart).hasSize(1); + + ReservationDetail detail = cart.get(0); + assertThat(detail.reservationId()).isEqualTo(reservation.getId()); + assertThat(detail.seats()).isNotEmpty(); + } + + @Test + @DisplayName("장바구니가 비어있다면 빈 응답을 반환한다") + void getCartReservations_empty_success() { + // when + List cart = cartReservationService.getCartReservations(memberNo); + + // then + assertThat(cart).isEmpty(); + } +} diff --git a/src/test/java/com/sudo/railo/booking/application/FareCalculationServiceTest.java b/src/test/java/com/sudo/railo/booking/application/FareCalculationServiceTest.java new file mode 100644 index 00000000..56e5627a --- /dev/null +++ b/src/test/java/com/sudo/railo/booking/application/FareCalculationServiceTest.java @@ -0,0 +1,74 @@ +package com.sudo.railo.booking.application; + +import static org.assertj.core.api.Assertions.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.booking.domain.type.PassengerSummary; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.type.CarType; + +@ServiceTest +class FareCalculationServiceTest { + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private FareCalculationService fareCalculationService; + + @DisplayName("운임 계산에 성공한다") + @ParameterizedTest(name = "{index}. {1} 계산 결과 = {2}") + @MethodSource("providePassengers") + void calculateFare_success( + List passengers, + CarType carType, + BigDecimal expectedFare + ) { + // given + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + trainScheduleTestHelper.createOrUpdateStationFare(seoul.getStationName(), busan.getStationName(), + 50000, 100000); + + // when + BigDecimal totalFare = fareCalculationService.calculateFare(seoul.getId(), busan.getId(), passengers, carType); + + // then + assertThat(totalFare).isEqualByComparingTo(expectedFare); + } + + private static Stream providePassengers() { + return Stream.of( + // 어른 2명 + 어린이 1명 + Arguments.of( + List.of( + new PassengerSummary(PassengerType.ADULT, 2), + new PassengerSummary(PassengerType.CHILD, 1) + ), + CarType.STANDARD, + BigDecimal.valueOf(130000) + ), + // 어른 1명 + Arguments.of( + List.of( + new PassengerSummary(PassengerType.ADULT, 1), + new PassengerSummary(PassengerType.CHILD, 0) + ), + CarType.FIRST_CLASS, + BigDecimal.valueOf(100000) + ) + ); + } +} diff --git a/src/test/java/com/sudo/railo/booking/application/ReservationApplicationServiceTest.java b/src/test/java/com/sudo/railo/booking/application/ReservationApplicationServiceTest.java new file mode 100644 index 00000000..df8abd1a --- /dev/null +++ b/src/test/java/com/sudo/railo/booking/application/ReservationApplicationServiceTest.java @@ -0,0 +1,197 @@ +package com.sudo.railo.booking.application; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.booking.application.dto.request.ReservationCreateRequest; +import com.sudo.railo.booking.domain.Reservation; +import com.sudo.railo.booking.domain.SeatReservation; +import com.sudo.railo.booking.domain.status.ReservationStatus; +import com.sudo.railo.booking.domain.type.PassengerSummary; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.booking.domain.type.TripType; +import com.sudo.railo.booking.exception.BookingError; +import com.sudo.railo.booking.infrastructure.SeatReservationRepository; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepository; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper.TrainScheduleWithStopStations; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.domain.ScheduleStop; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.type.CarType; + +@ServiceTest +class ReservationApplicationServiceTest { + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private SeatReservationRepository seatReservationRepository; + + @Autowired + private ReservationApplicationService reservationApplicationService; + + private Train train; + private TrainScheduleWithStopStations scheduleWithStops; + private String memberNo; + private List passengers; + private List standardSeatIds; + private ScheduleStop departureStop; + private ScheduleStop arrivalStop; + + @BeforeEach + void setUp() { + train = trainTestHelper.createCustomKTX(2, 2); + scheduleWithStops = trainScheduleTestHelper.createSchedule(train); + Member member = MemberFixture.createStandardMember(); + memberRepository.save(member); + memberNo = member.getMemberDetail().getMemberNo(); + passengers = List.of(new PassengerSummary(PassengerType.ADULT, 2)); + standardSeatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, 2); + departureStop = trainScheduleTestHelper.getScheduleStopByStationName(scheduleWithStops, "서울"); + arrivalStop = trainScheduleTestHelper.getScheduleStopByStationName(scheduleWithStops, "부산"); + } + + @Test + @DisplayName("예약 관련 정보를 받아 예약을 생성한다.") + void createReservation() { + // given + var request = createRequest(scheduleWithStops, departureStop, arrivalStop, passengers, standardSeatIds); + + // when + var response = reservationApplicationService.createReservation(request, memberNo); + + // then + Reservation savedReservation = reservationRepository.findById(response.reservationId()).orElseThrow(); + Member member = memberRepository.findById(savedReservation.getMember().getId()).orElseThrow(); + assertThat(member.getMemberDetail().getMemberNo()).isEqualTo(memberNo); + assertThat(savedReservation.getReservationStatus()).isEqualTo(ReservationStatus.RESERVED); + assertThat(savedReservation.getReservationCode()).isNotNull(); + } + + @Test + @DisplayName("예약이 성공하면 SeatReservation이 생성된다.") + void createSeatReservation() { + // given + var request = createRequest(scheduleWithStops, departureStop, arrivalStop, passengers, standardSeatIds); + + // when + var response = reservationApplicationService.createReservation(request, memberNo); + + // then + List savedSeatReservations = seatReservationRepository.findByReservationId(response.reservationId()); + assertThat(savedSeatReservations).hasSize(2); + savedSeatReservations.forEach(seatReservation -> { + assertThat(seatReservation.getReservation().getId()).isEqualTo(response.reservationId()); + assertThat(seatReservation.getSeat().getId()).isIn(standardSeatIds); + }); + } + + @Test + @DisplayName("예약이 생성될 때 좌석 정보는 오름차순으로 정렬된다.") + void createReservationWithSortedSeats() { + // given + passengers = List.of(new PassengerSummary(PassengerType.ADULT, 4)); + standardSeatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, 4); + var request = createRequest(scheduleWithStops, departureStop, arrivalStop, passengers, standardSeatIds); + + // when + var response = reservationApplicationService.createReservation(request, memberNo); + + // then + assertThat(response.seatReservationIds()).containsExactlyElementsOf(standardSeatIds); + } + + @ParameterizedTest + @ValueSource(ints = {1, 3}) + @DisplayName("승객 수와 좌석 수가 일치하지 않으면 예외가 발생한다.") + void shouldThrowsExceptionWhenPassengerCountMismatchesSeatCount(int count) { + // given + passengers = List.of(new PassengerSummary(PassengerType.ADULT, count)); + var request = createRequest(scheduleWithStops, departureStop, arrivalStop, passengers, standardSeatIds); + + // when & then + assertThatThrownBy(() -> reservationApplicationService.createReservation(request, memberNo)) + .isInstanceOf(BusinessException.class) + .hasMessage(BookingError.RESERVATION_CREATE_SEATS_INVALID.getMessage()); + } + + @Test + @DisplayName("존재하지 않은 좌석 ID로 예약하는 경우 예외가 발생한다.") + void shouldThrowsExceptionWhenSeatIdNotExists() { + // given + standardSeatIds = List.of(998L, 999L); + var request = createRequest(scheduleWithStops, departureStop, arrivalStop, passengers, standardSeatIds); + + // when & then + assertThatThrownBy(() -> reservationApplicationService.createReservation(request, memberNo)) + .isInstanceOf(BusinessException.class) + .hasMessage(BookingError.SEAT_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("이미 선점한 좌석을 예약하는 경우 예외가 발생한다.") + void shouldThrowsExceptionWhenSeatAlreadyReserved() { + // given + var request = createRequest(scheduleWithStops, departureStop, arrivalStop, passengers, standardSeatIds); + reservationApplicationService.createReservation(request, memberNo); + + // when & then + assertThatThrownBy(() -> reservationApplicationService.createReservation(request, memberNo)) + .isInstanceOf(BusinessException.class) + .hasMessage(BookingError.SEAT_ALREADY_RESERVED.getMessage()); + } + + @Test + @DisplayName("출발역과 도착역이 운행 스케줄 순서와 맞지 않으면 예외가 발생한다.") + void shouldThrowsExceptionWhenDepartureAndArrivalStopsAreNotInCorrectOrder() { + // given + var request = createRequest(scheduleWithStops, arrivalStop, departureStop, passengers, standardSeatIds); + trainScheduleTestHelper.createOrUpdateStationFare("부산", "서울", 50000, 10000); + + // when & then + assertThatThrownBy(() -> reservationApplicationService.createReservation(request, memberNo)) + .isInstanceOf(BusinessException.class) + .hasMessage(BookingError.TRAIN_NOT_OPERATIONAL.getMessage()); + } + + private static ReservationCreateRequest createRequest( + TrainScheduleWithStopStations scheduleWithStops, + ScheduleStop departureStop, + ScheduleStop arrivalStop, + List passengers, + List standardSeatIds + ) { + return new ReservationCreateRequest( + scheduleWithStops.trainSchedule().getId(), + departureStop.getStation().getId(), + arrivalStop.getStation().getId(), + passengers, + standardSeatIds, + TripType.OW + ); + } +} diff --git a/src/test/java/com/sudo/railo/booking/application/ReservationConcurrentConflictTest.java b/src/test/java/com/sudo/railo/booking/application/ReservationConcurrentConflictTest.java new file mode 100644 index 00000000..120a02bc --- /dev/null +++ b/src/test/java/com/sudo/railo/booking/application/ReservationConcurrentConflictTest.java @@ -0,0 +1,171 @@ +package com.sudo.railo.booking.application; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.booking.application.dto.request.ReservationCreateRequest; +import com.sudo.railo.booking.domain.type.PassengerSummary; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.booking.domain.type.TripType; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.domain.ScheduleStop; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.type.CarType; + +@ServiceTest +public class ReservationConcurrentConflictTest { + + private final int threadCount = 10; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ReservationApplicationService reservationApplicationService; + + private TrainScheduleTestHelper.TrainScheduleWithStopStations scheduleWithStops; + private String memberNo; + private List passengers; + private List standardSeatIds; + + @BeforeEach + void setUp() { + Train train = trainTestHelper.createKTX(); + scheduleWithStops = trainScheduleTestHelper.createCustomSchedule() + .scheduleName("test-schedule") + .operationDate(LocalDate.now()) + .train(train) + .addStop("1", null, LocalTime.of(9, 30)) + .addStop("2", LocalTime.of(10, 30), LocalTime.of(10, 30)) + .addStop("3", LocalTime.of(11, 0), LocalTime.of(11, 0)) + .addStop("4", LocalTime.of(11, 30), null) + .build(); + + Member member = MemberFixture.createStandardMember(); + memberRepository.save(member); + memberNo = member.getMemberDetail().getMemberNo(); + passengers = List.of(new PassengerSummary(PassengerType.ADULT, 1)); + standardSeatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, 1); + } + + @Test + @DisplayName("동시에 같은 좌석에 여러 예약이 발생하면 1개의 예약만 성공한다.") + void allowsOnlyOneReservationForConcurrentRequests() throws InterruptedException { + // given + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + ScheduleStop departureStop = trainScheduleTestHelper.getScheduleStopByStationName(scheduleWithStops, "1"); + ScheduleStop arrivalStop = trainScheduleTestHelper.getScheduleStopByStationName(scheduleWithStops, "3"); + trainScheduleTestHelper.createOrUpdateStationFare("1", "3", 50000, 10000); + var request = createRequest(scheduleWithStops, departureStop, arrivalStop, passengers, standardSeatIds); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + reservationApplicationService.createReservation(request, memberNo); + successCount.getAndIncrement(); + } catch (BusinessException e) { + failCount.getAndIncrement(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(threadCount - 1); + } + + @Test + @DisplayName("같은 좌석에 대해 겹치는 구간의 예약이 동시에 발생하면 1개의 예약만 성공한다.") + void allowsOnlyOneReservationForOverlappingRoutesWithConcurrentRequests() throws InterruptedException { + // given + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + ScheduleStop one = trainScheduleTestHelper.getScheduleStopByStationName(scheduleWithStops, "1"); + ScheduleStop three = trainScheduleTestHelper.getScheduleStopByStationName(scheduleWithStops, "3"); + trainScheduleTestHelper.createOrUpdateStationFare("1", "3", 50000, 10000); + var oneToThreeRequest = createRequest(scheduleWithStops, one, three, passengers, standardSeatIds); + + ScheduleStop two = trainScheduleTestHelper.getScheduleStopByStationName(scheduleWithStops, "2"); + ScheduleStop four = trainScheduleTestHelper.getScheduleStopByStationName(scheduleWithStops, "4"); + trainScheduleTestHelper.createOrUpdateStationFare("2", "4", 50000, 10000); + var twoToFourRequest = createRequest(scheduleWithStops, two, four, passengers, standardSeatIds); + + // when + // 절반은 1->3 구간, 절반은 2->4 구간 예약 시도 + for (int i = 0; i < threadCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + if (index % 2 == 0) { + reservationApplicationService.createReservation(oneToThreeRequest, memberNo); + } else { + reservationApplicationService.createReservation(twoToFourRequest, memberNo); + } + successCount.getAndIncrement(); + } catch (BusinessException e) { + failCount.getAndIncrement(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // then + assertThat(successCount.get()).isEqualTo(1); + assertThat(failCount.get()).isEqualTo(threadCount - 1); + } + + private static ReservationCreateRequest createRequest( + TrainScheduleTestHelper.TrainScheduleWithStopStations scheduleWithStops, + ScheduleStop departureStop, + ScheduleStop arrivalStop, + List passengers, + List standardSeatIds + ) { + return new ReservationCreateRequest( + scheduleWithStops.trainSchedule().getId(), + departureStop.getStation().getId(), + arrivalStop.getStation().getId(), + passengers, + standardSeatIds, + TripType.OW + ); + } +} diff --git a/src/test/java/com/sudo/railo/booking/application/ReservationServiceTest.java b/src/test/java/com/sudo/railo/booking/application/ReservationServiceTest.java new file mode 100644 index 00000000..055666ee --- /dev/null +++ b/src/test/java/com/sudo/railo/booking/application/ReservationServiceTest.java @@ -0,0 +1,321 @@ +package com.sudo.railo.booking.application; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.booking.application.dto.request.ReservationCreateRequest; +import com.sudo.railo.booking.application.dto.request.ReservationDeleteRequest; +import com.sudo.railo.booking.application.dto.response.ReservationDetail; +import com.sudo.railo.booking.domain.Reservation; +import com.sudo.railo.booking.domain.status.ReservationStatus; +import com.sudo.railo.booking.domain.type.PassengerSummary; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.booking.domain.type.TripType; +import com.sudo.railo.booking.exception.BookingError; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepository; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.ReservationTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper.TrainScheduleWithStopStations; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.type.CarType; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +class ReservationServiceTest { + + @Autowired + private ReservationService reservationService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private ReservationTestHelper reservationTestHelper; + + @Autowired + private ReservationRepository reservationRepository; + + private Member member; + private Train train; + private TrainScheduleWithStopStations schedule; + private List standardSeatIds; + + @BeforeEach + void setup() { + Member member = MemberFixture.createStandardMember(); + this.member = memberRepository.save(member); + train = trainTestHelper.createKTX(); + schedule = trainScheduleTestHelper.createSchedule(train); + standardSeatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, 2); + } + + @Test + @DisplayName("유효한 요청으로 예약이 성공한다") + void validRequest_createReservation_success() { + // given + ReservationCreateRequest request = new ReservationCreateRequest( + schedule.trainSchedule().getId(), + schedule.scheduleStops().get(0).getId(), + schedule.scheduleStops().get(1).getId(), + List.of(new PassengerSummary(PassengerType.ADULT, 1), new PassengerSummary(PassengerType.CHILD, 1)), + standardSeatIds, + TripType.OW + ); + + // when + Reservation reservation = reservationService.createReservation(request, member.getMemberDetail().getMemberNo()); + + // then + Reservation savedReservation = reservationRepository.findById(reservation.getId()) + .orElseThrow(() -> new AssertionError("예약이 DB에 저장되지 않았습니다")); + + assertThat(savedReservation.getMember().getId()).isEqualTo(member.getId()); + assertThat(savedReservation.getReservationStatus()).isEqualTo(ReservationStatus.RESERVED); + assertThat(savedReservation.getTotalPassengers()).isEqualTo(2); + assertThat(savedReservation.getFare()).isEqualTo(80000); + assertThat(savedReservation.getReservationCode()).isNotNull(); + } + + @Test + @DisplayName("멤버번호와 예약 ID로 특정 예약 조회에 성공한다") + void memberNoAndReservationId_getReservation_success() { + // given + String memberNo = member.getMemberDetail().getMemberNo(); + + Train train = trainTestHelper.createKTX(); + TrainScheduleWithStopStations schedule = trainScheduleTestHelper.createSchedule(train); + Reservation reservation = reservationTestHelper.createReservation(member, schedule); + Reservation entity = reservationRepository.save(reservation); + + // when + ReservationDetail result = reservationService.getReservation(memberNo, entity.getId()); + + // then + assertThat(result.reservationId()).isEqualTo(entity.getId()); + assertThat(result.reservationCode()).isEqualTo(reservation.getReservationCode()); + assertThat(result.departureStationName()).isEqualTo( + schedule.scheduleStops().get(0).getStation().getStationName()); + assertThat(result.arrivalStationName()).isEqualTo( + schedule.scheduleStops().get(1).getStation().getStationName()); + } + + @Test + @DisplayName("올바른 멤버번호와 잘못된 예약 ID로 특정 예약 조회 시 예외를 반환한다") + void memberNoAndInvalidReservationId_getReservation_throwException() { + // given + String memberNo = member.getMemberDetail().getMemberNo(); + + Train train = trainTestHelper.createKTX(); + TrainScheduleWithStopStations schedule = trainScheduleTestHelper.createSchedule(train); + Reservation reservation = reservationTestHelper.createReservation(member, schedule); + Reservation entity = reservationRepository.save(reservation); + + // when & then + assertThatThrownBy(() -> reservationService.getReservation(memberNo, 2L)) + .isInstanceOf(BusinessException.class); + + reservationRepository.save(reservation); + } + + @Test + @DisplayName("올바른 멤버번호와 만료된 예약 ID로 특정 예약 조회 시 예외를 반환한다") + void memberNoAndExpiredReservationId_getReservation_throwException() { + // given + String memberNo = member.getMemberDetail().getMemberNo(); + Reservation reservation = Reservation.builder() + .trainSchedule(schedule.trainSchedule()) + .member(member) + .reservationCode("20250806100001D49J") + .tripType(TripType.OW) + .totalPassengers(1) + .passengerSummary("[{\"passengerType\":\"ADULT\",\"count\":1}]") + .reservationStatus(ReservationStatus.RESERVED) + .expiresAt(LocalDateTime.now().minusMinutes(10)) + .fare(50000) + .departureStop(schedule.scheduleStops().get(0)) + .arrivalStop(schedule.scheduleStops().get(1)) + .build(); + Reservation entity = reservationRepository.save(reservation); + + // when & then + assertThatThrownBy(() -> reservationService.getReservation(memberNo, entity.getId())) + .isInstanceOf(BusinessException.class) + .hasMessage(BookingError.RESERVATION_EXPIRED.getMessage()); + } + + @Test + @DisplayName("멤버번호로 관련한 예약 목록 조회에 성공한다") + void memberNo_getReservations_success() { + // given + String memberNo = member.getMemberDetail().getMemberNo(); + + Train train = trainTestHelper.createKTX(); + TrainScheduleWithStopStations scheduleBusanToDongDaegu = trainScheduleTestHelper.createCustomSchedule() + .scheduleName("커스텀 노선 - 부산에서 동대구") + .operationDate(LocalDate.now()) + .train(train) + .addStop("부산", null, LocalTime.of(5, 0)) + .addStop("동대구", LocalTime.of(8, 0), null) + .build(); + + TrainScheduleWithStopStations scheduleDaejeonToSeoul = trainScheduleTestHelper.createCustomSchedule() + .scheduleName("커스텀 노선 - 대전에서 서울") + .operationDate(LocalDate.now()) + .train(train) + .addStop("대전", null, LocalTime.of(10, 0)) + .addStop("서울", LocalTime.of(12, 0), null) + .build(); + + Reservation reservation1 = reservationTestHelper.createReservation(member, scheduleBusanToDongDaegu); + Reservation reservation2 = reservationTestHelper.createReservation(member, scheduleDaejeonToSeoul); + Reservation entity1 = reservationRepository.save(reservation1); + Reservation entity2 = reservationRepository.save(reservation2); + + // when + List result = reservationService.getReservations(memberNo); + + // then + assertThat(result.size()).isEqualTo(2); + ReservationDetail result1 = result.get(0); + ReservationDetail result2 = result.get(1); + + assertThat(result1.reservationId()).isEqualTo(entity1.getId()); + assertThat(result1.reservationCode()).isEqualTo(reservation1.getReservationCode()); + assertThat(result1.departureStationName()).isEqualTo( + scheduleBusanToDongDaegu.scheduleStops().get(0).getStation().getStationName()); + assertThat(result1.arrivalStationName()).isEqualTo( + scheduleBusanToDongDaegu.scheduleStops().get(1).getStation().getStationName()); + + assertThat(result2.reservationId()).isEqualTo(entity2.getId()); + assertThat(result2.reservationCode()).isEqualTo(reservation2.getReservationCode()); + assertThat(result2.departureStationName()).isEqualTo( + scheduleDaejeonToSeoul.scheduleStops().get(0).getStation().getStationName()); + assertThat(result2.arrivalStationName()).isEqualTo( + scheduleDaejeonToSeoul.scheduleStops().get(1).getStation().getStationName()); + } + + @Test + @DisplayName("멤버번호로 예약 목록 조회 시 만료된 예약을 제외하고 조회에 성공한다") + void memberNoAndExpiredReservation_getReservations_success() { + // given + String memberNo = member.getMemberDetail().getMemberNo(); + + Train train = trainTestHelper.createKTX(); + TrainScheduleWithStopStations scheduleBusanToDongDaegu = trainScheduleTestHelper.createCustomSchedule() + .scheduleName("커스텀 노선 - 부산에서 동대구") + .operationDate(LocalDate.now()) + .train(train) + .addStop("부산", null, LocalTime.of(5, 0)) + .addStop("동대구", LocalTime.of(8, 0), null) + .build(); + + TrainScheduleWithStopStations scheduleDaejeonToSeoul = trainScheduleTestHelper.createCustomSchedule() + .scheduleName("커스텀 노선 - 대전에서 서울") + .operationDate(LocalDate.now()) + .train(train) + .addStop("대전", null, LocalTime.of(10, 0)) + .addStop("서울", LocalTime.of(12, 0), null) + .build(); + + Reservation reservation1 = Reservation.builder() + .trainSchedule(scheduleBusanToDongDaegu.trainSchedule()) + .member(member) + .reservationCode("20250806100001D49J") + .tripType(TripType.OW) + .totalPassengers(1) + .passengerSummary("[{\"passengerType\":\"ADULT\",\"count\":1}]") + .reservationStatus(ReservationStatus.RESERVED) + .expiresAt(LocalDateTime.now().minusMinutes(10)) + .fare(50000) + .departureStop(scheduleBusanToDongDaegu.scheduleStops().get(0)) + .arrivalStop(scheduleBusanToDongDaegu.scheduleStops().get(1)) + .build(); + Reservation reservation2 = reservationTestHelper.createReservation(member, scheduleDaejeonToSeoul); + Reservation entity1 = reservationRepository.save(reservation1); + Reservation entity2 = reservationRepository.save(reservation2); + + // when + List result = reservationService.getReservations(memberNo); + + // then + assertThat(result.size()).isEqualTo(1); + ReservationDetail result1 = result.get(0); + + assertThat(result1.reservationId()).isEqualTo(entity2.getId()); + assertThat(result1.reservationCode()).isEqualTo(reservation2.getReservationCode()); + assertThat(result1.departureStationName()).isEqualTo( + scheduleDaejeonToSeoul.scheduleStops().get(0).getStation().getStationName()); + assertThat(result1.arrivalStationName()).isEqualTo( + scheduleDaejeonToSeoul.scheduleStops().get(1).getStation().getStationName()); + } + + @Test + @DisplayName("올바른 예약 삭제 요청 DTO로 예약 삭제에 성공한다") + void validRequestDto_deleteReservation_success() { + // given + Train train = trainTestHelper.createKTX(); + TrainScheduleWithStopStations schedule = trainScheduleTestHelper.createSchedule(train); + Reservation reservation = reservationTestHelper.createReservation(member, schedule); + Reservation entity = reservationRepository.save(reservation); + ReservationDeleteRequest request = new ReservationDeleteRequest(entity.getId()); + + // when + reservationService.deleteReservation(request); + + // then + List result = reservationRepository.findAll(); + assertThat(result.size()).isEqualTo(0); + } + + @Test + @DisplayName("만료된 예약 일괄삭제에 성공한다") + void expireReservations_success() { + // given + Reservation reservation = Reservation.builder() + .trainSchedule(schedule.trainSchedule()) + .member(member) + .reservationCode("20250806100001D49J") + .tripType(TripType.OW) + .totalPassengers(1) + .passengerSummary("[{\"passengerType\":\"ADULT\",\"count\":1}]") + .reservationStatus(ReservationStatus.RESERVED) + .expiresAt(LocalDateTime.now().minusMinutes(10)) + .fare(50000) + .departureStop(schedule.scheduleStops().get(0)) + .arrivalStop(schedule.scheduleStops().get(1)) + .build(); + for (int i = 0; i < 3; i++) { + reservationRepository.save(reservation); + } + + // when + reservationService.expireReservations(); + + // then + List result = reservationRepository.findAll(); + assertThat(result.size()).isEqualTo(0); + } +} diff --git a/src/test/java/com/sudo/railo/booking/application/SeatReservationServiceTest.java b/src/test/java/com/sudo/railo/booking/application/SeatReservationServiceTest.java new file mode 100644 index 00000000..44ed259f --- /dev/null +++ b/src/test/java/com/sudo/railo/booking/application/SeatReservationServiceTest.java @@ -0,0 +1,159 @@ +package com.sudo.railo.booking.application; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.booking.domain.Reservation; +import com.sudo.railo.booking.domain.SeatReservation; +import com.sudo.railo.booking.domain.status.ReservationStatus; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.booking.domain.type.TripType; +import com.sudo.railo.booking.infrastructure.SeatReservationRepository; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepository; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.domain.Seat; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.TrainSchedule; +import com.sudo.railo.train.domain.type.CarType; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +class SeatReservationServiceTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private SeatReservationService seatReservationService; + + @Autowired + private SeatReservationRepository seatReservationRepository; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + private Reservation reservation; + private Seat seat1, seat2; + private PassengerType passengerType1, passengerType2; + + @BeforeEach + void setup() { + Member member = MemberFixture.createStandardMember(); + memberRepository.save(member); + Train train = trainTestHelper.createKTX(); + TrainScheduleTestHelper.TrainScheduleWithStopStations schedule = trainScheduleTestHelper.createSchedule(train); + Reservation reservation = Reservation.builder() + .trainSchedule(schedule.trainSchedule()) + .member(member) + .reservationCode("20250806100001D49J") + .tripType(TripType.OW) + .totalPassengers(1) + .passengerSummary("[{\"passengerType\":\"CHILD\",\"count\":1},{\"passengerType\":\"VETERAN\",\"count\":1}]") + .reservationStatus(ReservationStatus.RESERVED) + .expiresAt(LocalDateTime.now().plusMinutes(10)) + .fare(50000) + .departureStop(schedule.scheduleStops().get(0)) + .arrivalStop(schedule.scheduleStops().get(1)) + .build(); + this.reservation = reservationRepository.save(reservation); + List seats = trainTestHelper.getSeats(train, CarType.STANDARD, 2); + seat1 = seats.get(0); + seat2 = seats.get(1); + passengerType1 = PassengerType.CHILD; + passengerType2 = PassengerType.VETERAN; + } + + @Test + @DisplayName("예약, 좌석, 승객 유형으로 좌석 예약 생성에 성공한다") + void reservationAndSeatAndPassengerType_reserveNewSeat_success() { + // when + SeatReservation entity = seatReservationService.reserveNewSeat(reservation, seat1, passengerType1); + + // then + assertThat(entity.getReservation().getReservationCode()).isEqualTo(reservation.getReservationCode()); + assertThat(entity.getPassengerType()).isEqualTo(passengerType1); + } + + @Test + @DisplayName("좌석 예약 ID로 좌석 예약 삭제에 성공한다") + void seatReservationId_deleteSeatReservation_success() { + // given + Train train = trainTestHelper.createKTX(); + TrainSchedule trainSchedule = trainScheduleTestHelper.createSchedule(train).trainSchedule(); + + SeatReservation seatReservation1 = SeatReservation.builder() + .trainSchedule(trainSchedule) + .seat(seat1) + .reservation(reservation) + .passengerType(passengerType1) + .build(); + SeatReservation entity1 = seatReservationRepository.save(seatReservation1); + + SeatReservation seatReservation2 = SeatReservation.builder() + .trainSchedule(trainSchedule) + .seat(seat2) + .reservation(reservation) + .passengerType(passengerType2) + .build(); + SeatReservation entity2 = seatReservationRepository.save(seatReservation2); + + // when + seatReservationService.deleteSeatReservation(entity1.getId()); + + // then + List result = seatReservationRepository.findAll(); + assertThat(result.size()).isEqualTo(1); + assertThat(result.get(0).getPassengerType()).isEqualTo(passengerType2); + } + + @Test + @DisplayName("예약 ID로 좌석 예약 삭제에 성공한다") + void reservationId_deleteSeatReservation_success() { + // given + Train train = trainTestHelper.createKTX(); + TrainSchedule trainSchedule = trainScheduleTestHelper.createSchedule(train).trainSchedule(); + + SeatReservation seatReservation1 = SeatReservation.builder() + .trainSchedule(trainSchedule) + .seat(seat1) + .reservation(reservation) + .passengerType(passengerType1) + .build(); + SeatReservation entity1 = seatReservationRepository.save(seatReservation1); + + SeatReservation seatReservation2 = SeatReservation.builder() + .trainSchedule(trainSchedule) + .seat(seat2) + .reservation(reservation) + .passengerType(passengerType2) + .build(); + SeatReservation entity2 = seatReservationRepository.save(seatReservation2); + + // when + seatReservationService.deleteSeatReservationByReservationId(reservation.getId()); + + // then + List result = seatReservationRepository.findAll(); + assertThat(result.size()).isEqualTo(0); + } +} diff --git a/src/test/java/com/sudo/railo/booking/application/TicketServiceTest.java b/src/test/java/com/sudo/railo/booking/application/TicketServiceTest.java new file mode 100644 index 00000000..5e5c51b6 --- /dev/null +++ b/src/test/java/com/sudo/railo/booking/application/TicketServiceTest.java @@ -0,0 +1,206 @@ +package com.sudo.railo.booking.application; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.booking.application.dto.response.TicketReadResponse; +import com.sudo.railo.booking.domain.Qr; +import com.sudo.railo.booking.domain.Reservation; +import com.sudo.railo.booking.domain.Ticket; +import com.sudo.railo.booking.domain.status.ReservationStatus; +import com.sudo.railo.booking.domain.status.TicketStatus; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.booking.domain.type.TripType; +import com.sudo.railo.booking.infrastructure.QrRepository; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepository; +import com.sudo.railo.booking.infrastructure.ticket.TicketRepository; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.domain.Seat; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.type.CarType; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +class TicketServiceTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private TicketRepository ticketRepository; + + @Autowired + private QrRepository qrRepository; + + @Autowired + private TicketService ticketService; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + private Reservation reservation; + private Seat seat1, seat2; + private PassengerType passengerType1, passengerType2; + + @BeforeEach + void setup() { + Member member = MemberFixture.createStandardMember(); + memberRepository.save(member); + Train train = trainTestHelper.createKTX(); + TrainScheduleTestHelper.TrainScheduleWithStopStations schedule = trainScheduleTestHelper.createSchedule(train); + Reservation reservation = Reservation.builder() + .trainSchedule(schedule.trainSchedule()) + .member(member) + .reservationCode("20250806100001D49J") + .tripType(TripType.OW) + .totalPassengers(1) + .passengerSummary("[{\"passengerType\":\"CHILD\",\"count\":1},{\"passengerType\":\"VETERAN\",\"count\":1}]") + .reservationStatus(ReservationStatus.RESERVED) + .expiresAt(LocalDateTime.now().plusMinutes(10)) + .fare(50000) + .departureStop(schedule.scheduleStops().get(0)) + .arrivalStop(schedule.scheduleStops().get(1)) + .build(); + this.reservation = reservationRepository.save(reservation); + List seats = trainTestHelper.getSeats(train, CarType.STANDARD, 2); + seat1 = seats.get(0); + seat2 = seats.get(1); + + passengerType1 = PassengerType.CHILD; + passengerType2 = PassengerType.VETERAN; + } + + @Test + @DisplayName("예약, 좌석, 승객 유형으로 티켓 생성에 성공한다") + void reservationAndSeatAndPassengerType_createTicket_success() { + // when + ticketService.createTicket(reservation, seat1, passengerType1); + + // then + List result = ticketRepository.findAll(); + assertThat(result.size()).isEqualTo(1); + Ticket resultItem = result.get(0); + assertThat(resultItem.getTicketStatus()).isEqualTo(TicketStatus.ISSUED); + } + + @Test + @DisplayName("멤버번호로 가지고 있는 티켓 조회에 성공한다") + void memberNo_getMyTickets_success() { + // given + Qr qr1 = qrRepository.save(Qr.builder().isUsable(true).scanCount(0).build()); + Qr qr2 = qrRepository.save(Qr.builder().isUsable(true).scanCount(0).build()); + + Ticket ticket1 = Ticket.builder() + .seat(seat1) + .reservation(reservation) + .qr(qr1) + .ticketStatus(TicketStatus.ISSUED) + .passengerType(passengerType1) + .build(); + ticketRepository.save(ticket1); + + Ticket ticket2 = Ticket.builder() + .seat(seat2) + .reservation(reservation) + .qr(qr2) + .ticketStatus(TicketStatus.ISSUED) + .passengerType(passengerType2) + .build(); + ticketRepository.save(ticket2); + String memberNo = reservation.getMember().getMemberDetail().getMemberNo(); + + // when + List result = ticketService.getMyTickets(memberNo); + + // then + assertThat(result.size()).isEqualTo(2); + } + + @Test + @DisplayName("티켓 ID로 티켓 삭제에 성공한다") + void ticketId_deleteTicket_success() { + // given + Qr qr1 = qrRepository.save(Qr.builder().isUsable(true).scanCount(0).build()); + Qr qr2 = qrRepository.save(Qr.builder().isUsable(true).scanCount(0).build()); + + Ticket ticket1 = Ticket.builder() + .seat(seat1) + .reservation(reservation) + .qr(qr1) + .ticketStatus(TicketStatus.ISSUED) + .passengerType(passengerType1) + .build(); + Ticket entity1 = ticketRepository.save(ticket1); + + Ticket ticket2 = Ticket.builder() + .seat(seat2) + .reservation(reservation) + .qr(qr2) + .ticketStatus(TicketStatus.ISSUED) + .passengerType(passengerType2) + .build(); + Ticket entity2 = ticketRepository.save(ticket2); + + // when + ticketService.deleteTicketById(entity1.getId()); + + // then + List result = ticketRepository.findAll(); + assertThat(result.size()).isEqualTo(1); + Ticket resultItem = result.get(0); + assertThat(resultItem.getPassengerType()).isEqualTo(entity2.getPassengerType()); + } + + @Test + @DisplayName("예약 ID로 티켓 삭제에 성공한다") + void reservationId_deleteTicket_success() { + // given + Qr qr1 = qrRepository.save(Qr.builder().isUsable(true).scanCount(0).build()); + Qr qr2 = qrRepository.save(Qr.builder().isUsable(true).scanCount(0).build()); + + Ticket ticket1 = Ticket.builder() + .seat(seat1) + .reservation(reservation) + .qr(qr1) + .ticketStatus(TicketStatus.ISSUED) + .passengerType(passengerType1) + .build(); + ticketRepository.save(ticket1); + + Ticket ticket2 = Ticket.builder() + .seat(seat2) + .reservation(reservation) + .qr(qr2) + .ticketStatus(TicketStatus.ISSUED) + .passengerType(passengerType2) + .build(); + ticketRepository.save(ticket2); + + // when + ticketService.deleteTicketByReservationId(reservation.getId()); + + // then + List result = ticketRepository.findAll(); + assertThat(result.size()).isEqualTo(0); + } +} diff --git a/src/test/java/com/sudo/railo/member/application/MemberFindServiceTest.java b/src/test/java/com/sudo/railo/member/application/MemberFindServiceTest.java new file mode 100644 index 00000000..cf222dc9 --- /dev/null +++ b/src/test/java/com/sudo/railo/member/application/MemberFindServiceTest.java @@ -0,0 +1,255 @@ +package com.sudo.railo.member.application; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.junit5.GreenMailExtension; +import com.icegreen.greenmail.util.GreenMailUtil; +import com.icegreen.greenmail.util.ServerSetup; +import com.sudo.railo.auth.application.dto.request.VerifyCodeRequest; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; +import com.sudo.railo.auth.application.dto.response.TemporaryTokenResponse; +import com.sudo.railo.auth.exception.AuthError; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.global.redis.AuthRedisRepository; +import com.sudo.railo.global.redis.MemberRedisRepository; +import com.sudo.railo.member.application.dto.request.FindMemberNoRequest; +import com.sudo.railo.member.application.dto.request.FindPasswordRequest; +import com.sudo.railo.member.application.dto.response.VerifyMemberNoResponse; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.exception.MemberError; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; + +@ServiceTest +class MemberFindServiceTest { + + @RegisterExtension + static GreenMailExtension greenMail = new GreenMailExtension( + new ServerSetup(0, "127.0.0.1", ServerSetup.PROTOCOL_SMTP) // 포트 충돌이 있어 동적 포트 할당 + ) + .withConfiguration(GreenMailConfiguration.aConfig().withUser("testUser", "testPassword")) + .withPerMethodLifecycle(true); + + @Autowired + private JavaMailSenderImpl mailSender; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberFindService memberFindService; + + @Autowired + private MemberRedisRepository memberRedisRepository; + + @Autowired + private AuthRedisRepository authRedisRepository; + + private Member member; + + @BeforeEach + void setUp() { + greenMail.start(); + mailSender.setPort(greenMail.getSmtp().getPort()); + + member = MemberFixture.createStandardMember(); + memberRepository.save(member); + } + + @AfterEach + void tearDown() { + greenMail.stop(); + } + + @Test + @DisplayName("존재하는 회원 정보로 이메일 인증을 통한 회원번호 찾기 요청에 성공한다.") + void requestFindMemberNo_success() { + //given + String memberEmail = member.getMemberDetail().getEmail(); + String memberNo = member.getMemberDetail().getMemberNo(); + FindMemberNoRequest request = new FindMemberNoRequest(member.getName(), member.getPhoneNumber()); + + //when + SendCodeResponse response = memberFindService.requestFindMemberNo(request); + + //then + assertThat(response).isNotNull(); + assertThat(response.email()).isEqualTo(memberEmail); + + assertThat(greenMail.waitForIncomingEmail(5000, 1)).isTrue(); + + String savedMemberNo = memberRedisRepository.getMemberNo(memberEmail); + assertThat(savedMemberNo).isEqualTo(memberNo); + + String mailContent = GreenMailUtil.getBody(greenMail.getReceivedMessages()[0]); + String authCode = authRedisRepository.getAuthCode(memberEmail); + assertThat(mailContent).contains(authCode); + } + + @Test + @DisplayName("존재하지 않는 회원의 정보로 회원 번호를 찾으면 요청에 실패한다.") + void requestFindMemberNo_fail() { + //given + FindMemberNoRequest request = new FindMemberNoRequest("존재하지않는이름", "01099998888"); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberFindService.requestFindMemberNo(request)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.USER_NOT_FOUND)); + } + + @Test + @DisplayName("올바른 인증 코드로 회원 번호 검증 요청에 성공한다.") + void verifyAuthCode_success() { + //given + String memberEmail = member.getMemberDetail().getEmail(); + String memberNo = member.getMemberDetail().getMemberNo(); + String authCode = "123456"; + + memberRedisRepository.saveMemberNo(memberEmail, memberNo); + authRedisRepository.saveAuthCode(memberEmail, authCode); + + VerifyCodeRequest request = new VerifyCodeRequest(memberEmail, authCode); + + //when + VerifyMemberNoResponse response = memberFindService.verifyFindMemberNo(request); + + //then + assertThat(response).isNotNull(); + assertThat(response.memberNo()).isEqualTo(memberNo); + + // 검증 성공 후 레디스에 저장된 회원 번호와 인증 코드 삭제 되었는지 확인 + String savedMemberNo = memberRedisRepository.getMemberNo(memberEmail); + assertThat(savedMemberNo).isNull(); + String savedAuthCode = authRedisRepository.getAuthCode(memberEmail); + assertThat(savedAuthCode).isNull(); + } + + @Test + @DisplayName("인증 코드가 일치하지 않으면 회원번호 검증 요청에 실패한다.") + void verifyAuthCode_fail() { + //given + String memberEmail = member.getMemberDetail().getEmail(); + String memberNo = member.getMemberDetail().getMemberNo(); + String correctAuthCode = "123456"; + String wrongAuthCode = "111111"; + + memberRedisRepository.saveMemberNo(memberEmail, memberNo); + authRedisRepository.saveAuthCode(memberEmail, correctAuthCode); + + VerifyCodeRequest request = new VerifyCodeRequest(memberEmail, wrongAuthCode); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberFindService.verifyFindMemberNo(request)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(AuthError.INVALID_AUTH_CODE)); + } + + @Test + @DisplayName("존재하는 회원 정보로 이메일 인증을 통한 비밀번호 찾기 요청에 성공한다.") + void requestFindPassword_success() { + //given + FindPasswordRequest request = new FindPasswordRequest(member.getName(), member.getMemberDetail().getMemberNo()); + + //when + SendCodeResponse response = memberFindService.requestFindPassword(request); + + //then + assertThat(response).isNotNull(); + assertThat(response.email()).isEqualTo(member.getMemberDetail().getEmail()); + + assertThat(greenMail.waitForIncomingEmail(5000, 1)).isTrue(); + + String savedAuthCode = authRedisRepository.getAuthCode(member.getMemberDetail().getEmail()); + assertThat(savedAuthCode).isNotNull(); + + String mailContent = GreenMailUtil.getBody(greenMail.getReceivedMessages()[0]); + assertThat(mailContent).contains(savedAuthCode); + } + + @Test + @DisplayName("존재하지 않는 회원번호로 비밀번호 찾기 요청 시도 시 실패한다.") + void requestFindPassword_fail_when_wrong_member_no() { + //given + String wrongMemberNo = "202007070001"; + FindPasswordRequest request = new FindPasswordRequest(member.getName(), wrongMemberNo); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberFindService.requestFindPassword(request)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.USER_NOT_FOUND)); + } + + @Test + @DisplayName("회원 정보에 있는 이름과 일치하지 않는 요청일 경우 실패한다.") + void requestFindPassword_fail_when_miss_match_name() { + //given + String wrongName = "다른이름"; + FindPasswordRequest request = new FindPasswordRequest(wrongName, member.getMemberDetail().getMemberNo()); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberFindService.requestFindPassword(request)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.NAME_MISMATCH)); + } + + @Test + @DisplayName("올바른 인증 코드로 비밀번호 찾기 검증에 성공하면 임시 토큰이 발급된다.") + void verifyFindPassword_success() { + //given + String memberEmail = member.getMemberDetail().getEmail(); + String memberNo = member.getMemberDetail().getMemberNo(); + String authCode = "123456"; + + memberRedisRepository.saveMemberNo(memberEmail, memberNo); + authRedisRepository.saveAuthCode(memberEmail, authCode); + + VerifyCodeRequest request = new VerifyCodeRequest(memberEmail, authCode); + + //when + TemporaryTokenResponse response = memberFindService.verifyFindPassword(request); + + //then + assertThat(response).isNotNull(); + assertThat(response.temporaryToken()).isNotNull(); + + // 검증 후 레디스에 회원번호가 삭제되었는지 확인 + String savedAuthCode = authRedisRepository.getAuthCode(memberEmail); + assertThat(savedAuthCode).isNull(); + } + + @Test + @DisplayName("인증 코드가 일치하지 않으면 비밀번호 찾기 검증 요청에 실패한다.") + void verifyFindPassword_fail() { + //given + String memberEmail = member.getMemberDetail().getEmail(); + String memberNo = member.getMemberDetail().getMemberNo(); + String correctAuthCode = "123456"; + String wrongAuthCode = "111111"; + + memberRedisRepository.saveMemberNo(memberEmail, memberNo); + authRedisRepository.saveAuthCode(memberEmail, correctAuthCode); + + VerifyCodeRequest request = new VerifyCodeRequest(memberEmail, wrongAuthCode); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberFindService.verifyFindPassword(request)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(AuthError.INVALID_AUTH_CODE)); + } +} diff --git a/src/test/java/com/sudo/railo/member/application/MemberNoGeneratorTest.java b/src/test/java/com/sudo/railo/member/application/MemberNoGeneratorTest.java new file mode 100644 index 00000000..4ae6e5a2 --- /dev/null +++ b/src/test/java/com/sudo/railo/member/application/MemberNoGeneratorTest.java @@ -0,0 +1,43 @@ +package com.sudo.railo.member.application; + +import static org.assertj.core.api.AssertionsForClassTypes.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +import com.sudo.railo.support.annotation.ServiceTest; + +@ServiceTest +class MemberNoGeneratorTest { + + @Autowired + private MemberNoGenerator memberNoGenerator; + + @Autowired + private RedisTemplate stringRedisTemplate; + + @Test + @DisplayName("회원번호 생성에 성공한다.") + void generateMemberNo_success() { + //given + String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String redisKey = "todayKey:" + today; + + //when + String memberNo1 = memberNoGenerator.generateMemberNo(); + String memberNo2 = memberNoGenerator.generateMemberNo(); + + //then + assertThat(memberNo1).isEqualTo(today + "0001"); + assertThat(memberNo2).isEqualTo(today + "0002"); + + String counterValue = stringRedisTemplate.opsForValue().get(redisKey); + assertThat(counterValue).isEqualTo("2"); + } + +} diff --git a/src/test/java/com/sudo/railo/member/application/MemberServiceImplTest.java b/src/test/java/com/sudo/railo/member/application/MemberServiceImplTest.java deleted file mode 100644 index 50ef0f6b..00000000 --- a/src/test/java/com/sudo/railo/member/application/MemberServiceImplTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.sudo.railo.member.application; - -import static org.assertj.core.api.Assertions.*; - -import java.time.LocalDate; -import java.util.List; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; - -import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.member.application.dto.request.UpdatePasswordRequest; -import com.sudo.railo.member.application.dto.request.UpdatePhoneNumberRequest; -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.member.domain.MemberDetail; -import com.sudo.railo.member.domain.Membership; -import com.sudo.railo.member.domain.Role; -import com.sudo.railo.member.exception.MemberError; -import com.sudo.railo.member.infra.MemberRepository; - -@SpringBootTest -class MemberServiceImplTest { - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private BCryptPasswordEncoder passwordEncoder; - - @Autowired - private MemberService memberService; - - private Member testMember; - - @BeforeEach - void setUp() { - MemberDetail memberDetail = MemberDetail.create( - "202507020001", - Membership.BUSINESS, - "test01@email.com", - LocalDate.of(1990, 1, 1), - "M" - ); - - testMember = Member.create( - "홍길동", - "01012341234", - passwordEncoder.encode("testPwd"), - Role.MEMBER, - memberDetail - ); - memberRepository.save(testMember); - - MemberDetail anotherMemberDetail = MemberDetail.create( - "202507020002", - Membership.BUSINESS, - "test02@email.com", - LocalDate.of(1990, 2, 2), - "M" - ); - - Member anotherMember = Member.create( - "유관순", - "01012345678", - passwordEncoder.encode("anotherPwd"), - Role.MEMBER, - anotherMemberDetail - ); - memberRepository.save(anotherMember); - - // 서비스 계층에서 SecurityUtil 을 사용하고 있기 때문에 직접 SecurityContext 를 set - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken("202507020001", "testPwd", List.of(() -> "MEMBER")); - SecurityContextHolder.getContext().setAuthentication(authentication); - - } - - @AfterEach - void tearDown() { - memberRepository.deleteAll(); - SecurityContextHolder.clearContext(); - } - - // @DisplayName("로그인 된 사용자의 이메일 변경 성공") - // @Test - // void updateEmailSuccess() { - // - // //given - // String newEmail = "updateEmail@email.com"; - // - // //when - // memberService.updateEmail(newEmail); - // - // //then - // Member result = memberRepository.findByMemberNo(testMember.getMemberDetail().getMemberNo()) - // .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - // assertThat(result.getMemberDetail().getEmail()).isEqualTo(newEmail); - // } - - @DisplayName("로그인 된 사용자의 휴대폰 번호 변경 성공") - @Test - void updatePhoneNumberSuccess() { - - //given - UpdatePhoneNumberRequest request = new UpdatePhoneNumberRequest("01012341111"); - - //when - memberService.updatePhoneNumber(request); - - //then - Member result = memberRepository.findByMemberNo(testMember.getMemberDetail().getMemberNo()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - assertThat(result.getPhoneNumber()).isEqualTo(request.newPhoneNumber()); - } - - @DisplayName("로그인 된 사용자의 비밀번호 변경 성공") - @Test - void updatePasswordSuccess() { - - //given - UpdatePasswordRequest request = new UpdatePasswordRequest("updatePwd"); - - //when - memberService.updatePassword(request); - - //then - Member result = memberRepository.findByMemberNo(testMember.getMemberDetail().getMemberNo()) - .orElseThrow(() -> new BusinessException(MemberError.USER_NOT_FOUND)); - assertThat(passwordEncoder.matches(request.newPassword(), result.getPassword())).isTrue(); - } - -} diff --git a/src/test/java/com/sudo/railo/member/application/MemberServiceTest.java b/src/test/java/com/sudo/railo/member/application/MemberServiceTest.java new file mode 100644 index 00000000..18ae87aa --- /dev/null +++ b/src/test/java/com/sudo/railo/member/application/MemberServiceTest.java @@ -0,0 +1,207 @@ +package com.sudo.railo.member.application; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import com.sudo.railo.auth.application.AuthService; +import com.sudo.railo.auth.application.dto.request.LoginRequest; +import com.sudo.railo.auth.application.dto.response.TokenResponse; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.member.application.dto.request.GuestRegisterRequest; +import com.sudo.railo.member.application.dto.response.GuestRegisterResponse; +import com.sudo.railo.member.application.dto.response.MemberInfoResponse; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.domain.Role; +import com.sudo.railo.member.exception.MemberError; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; + +@ServiceTest +class MemberServiceTest { + + @Autowired + private MemberService memberService; + + @Autowired + private AuthService authService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private BCryptPasswordEncoder passwordEncoder; + + @Test + @DisplayName("비회원 등록에 성공한다.") + void guestRegister_success() { + //given + GuestRegisterRequest request = new GuestRegisterRequest("김이름", "01012341234", "testPwd"); + + //when + GuestRegisterResponse response = memberService.guestRegister(request); + + //then + List members = memberRepository.findByNameAndPhoneNumber(request.name(), request.phoneNumber()); + + assertThat(members).isNotEmpty(); + + Member savedGuestMember = members.stream() + .filter(member -> passwordEncoder.matches(request.password(), member.getPassword())) + .findFirst() + .orElseThrow(() -> new AssertionError("등록된 회원을 찾을 수 없습니다.")); + + assertThat(response.name()).isEqualTo(request.name()); + assertThat(response.role()).isEqualTo(Role.GUEST); + + assertThat(savedGuestMember.getName()).isEqualTo(request.name()); + assertThat(savedGuestMember.getPhoneNumber()).isEqualTo(request.phoneNumber()); + assertThat(passwordEncoder.matches(request.password(), savedGuestMember.getPassword())).isTrue(); + assertThat(savedGuestMember.getRole()).isEqualTo(Role.GUEST); + } + + @Test + @DisplayName("중복된 비회원 정보로 비회원 등록에 실패한다.") + void guestRegister_fail() { + //given + GuestRegisterRequest request = new GuestRegisterRequest("김이름", "01012341234", "testPwd"); + memberService.guestRegister(request); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberService.guestRegister(request)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.DUPLICATE_GUEST_INFO)); + } + + @Test + @DisplayName("회원 삭제에 성공한다.") + void deleteMember_success() { + //given + Member member = createMemberWithEncryptedPassword(); + String memberNo = member.getMemberDetail().getMemberNo(); + + LoginRequest request = new LoginRequest(memberNo, "testPassword"); + TokenResponse response = authService.login(request); + + String accessToken = response.accessToken(); + + //when + memberService.memberDelete(accessToken, memberNo); + + //then + Member deletedMember = memberRepository.findByMemberNoIgnoreIsDeleted(memberNo) + .orElseThrow(() -> new AssertionError("회원을 찾을 수 없습니다.")); + assertThat(deletedMember.isDeleted()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 회원 삭제 시 예외가 발생해 실패한다.") + void deleteMember_fail_when_user_not_found() { + //given + Member member = createMemberWithEncryptedPassword(); + String memberNo = member.getMemberDetail().getMemberNo(); + + LoginRequest request = new LoginRequest(memberNo, "testPassword"); + TokenResponse response = authService.login(request); + + String accessToken = response.accessToken(); + String nonExistMemberNo = "202507309999"; + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberService.memberDelete(accessToken, nonExistMemberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.USER_NOT_FOUND)); + } + + @Test + @DisplayName("회원의 정보 조회에 성공한다.") + void getMemberInfo_success() { + //given + Member member = MemberFixture.createStandardMember(); + memberRepository.save(member); + + String memberNo = "202507300001"; + + //when + MemberInfoResponse response = memberService.getMemberInfo(memberNo); + + //then + assertThat(response.name()).isEqualTo(member.getName()); + assertThat(response.phoneNumber()).isEqualTo(member.getPhoneNumber()); + assertThat(response.role()).isEqualTo(member.getRole()); + + MemberInfoResponse.MemberDetailInfo detailInfo = response.memberDetailInfo(); + assertThat(detailInfo.memberNo()).isEqualTo(member.getMemberDetail().getMemberNo()); + assertThat(detailInfo.membership()).isEqualTo(member.getMemberDetail().getMembership()); + assertThat(detailInfo.email()).isEqualTo(member.getMemberDetail().getEmail()); + assertThat(detailInfo.birthDate()).isEqualTo(member.getMemberDetail().getBirthDate().toString()); + assertThat(detailInfo.gender()).isEqualTo(member.getMemberDetail().getGender()); + assertThat(detailInfo.totalMileage()).isEqualTo(member.getMemberDetail().getTotalMileage()); + } + + @Test + @DisplayName("회원을 찾을 수 없어 회원 정보 조회에 실패한다.") + void getMemberInfo_fail() { + //given + String memberNo = "202507300001"; + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberService.getMemberInfo(memberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.USER_NOT_FOUND)); + } + + @Test + @DisplayName("회원 이메일 조회에 성공한다.") + void getMemberEmail_success() { + //given + Member member = MemberFixture.createStandardMember(); + memberRepository.save(member); + + String memberNo = "202507300001"; + + //when + String memberEmail = memberService.getMemberEmail(memberNo); + + //then + assertThat(memberEmail).isEqualTo(member.getMemberDetail().getEmail()); + } + + @Test + @DisplayName("회원을 찾을 수 없어 이메일 조회에 실패한다.") + void getMemberEmail_fail() { + //given + String memberNo = "202507300001"; + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberService.getMemberEmail(memberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.USER_NOT_FOUND)); + } + + private Member createMemberWithEncryptedPassword() { + Member member = MemberFixture.createStandardMember(); + String plainPwd = member.getPassword(); + String encodedPwd = passwordEncoder.encode(plainPwd); + + Member saveMember = Member.create( + member.getName(), + member.getPhoneNumber(), + encodedPwd, + member.getRole(), + member.getMemberDetail() + ); + + return memberRepository.save(saveMember); + } +} diff --git a/src/test/java/com/sudo/railo/member/application/MemberServiceUnitTest.java b/src/test/java/com/sudo/railo/member/application/MemberServiceUnitTest.java deleted file mode 100644 index 8a412cff..00000000 --- a/src/test/java/com/sudo/railo/member/application/MemberServiceUnitTest.java +++ /dev/null @@ -1,163 +0,0 @@ -package com.sudo.railo.member.application; - -import static org.assertj.core.api.Assertions.*; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; - -import com.sudo.railo.global.exception.error.BusinessException; -import com.sudo.railo.member.application.dto.request.UpdatePasswordRequest; -import com.sudo.railo.member.application.dto.request.UpdatePhoneNumberRequest; -import com.sudo.railo.member.domain.Member; -import com.sudo.railo.member.domain.MemberDetail; -import com.sudo.railo.member.domain.Membership; -import com.sudo.railo.member.domain.Role; -import com.sudo.railo.member.exception.MemberError; -import com.sudo.railo.member.infra.MemberRepository; - -@ExtendWith(MockitoExtension.class) -class MemberServiceUnitTest { - - @Mock - private MemberRepository memberRepository; - - @Mock - private BCryptPasswordEncoder passwordEncoder; - - @InjectMocks - private MemberServiceImpl memberService; - - private Member testMember; - - @BeforeEach - void setUp() { - MemberDetail memberDetail = MemberDetail.create( - "202507020001", - Membership.BUSINESS, - "test01@email.com", - LocalDate.of(1990, 1, 1), - "M" - ); - - testMember = Member.create( - "홍길동", - "01012341234", - passwordEncoder.encode("testPwd"), - Role.MEMBER, - memberDetail - ); - - // 서비스 계층에서 SecurityUtil 을 사용하고 있기 때문에 직접 SecurityContext 를 set - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken("202507020001", "testPwd", List.of(() -> "MEMBER")); - SecurityContextHolder.getContext().setAuthentication(authentication); - - } - - @AfterEach - void tearDown() { - Mockito.reset(memberRepository); - SecurityContextHolder.clearContext(); - } - - // @DisplayName("이메일 변경 실패 - 현재 사용하는 이메일과 동일") - // @Test - // void updateEmailWithSameEmail() { - // - // // given - // String sameEmail = "test01@email.com"; - // SendCodeRequest request = new SendCodeRequest(sameEmail); - // - // Mockito.when(memberRepository.findByMemberNo(Mockito.anyString())).thenReturn(Optional.of(testMember)); - // - // // when & then - // assertThatThrownBy(() -> memberService.requestUpdateEmail(request)) - // .isInstanceOf(BusinessException.class) - // .hasMessage(MemberError.SAME_EMAIL.getMessage()); - // - // } - // - // @DisplayName("이메일 변경 실패 - 이미 사용중인 이메일") - // @Test - // void updateEmailWithDuplicateEmail() { - // - // // given - // String duplicateEmail = "test02@email.com"; - // SendCodeRequest request = new SendCodeRequest(duplicateEmail); - // - // Mockito.when(memberRepository.findByMemberNo(Mockito.anyString())).thenReturn(Optional.of(testMember)); - // Mockito.when(memberRepository.existsByMemberDetailEmail(duplicateEmail)).thenReturn(true); - // - // // when & then - // assertThatThrownBy(() -> memberService.requestUpdateEmail(request)) - // .isInstanceOf(BusinessException.class) - // .hasMessage(MemberError.DUPLICATE_EMAIL.getMessage()); - // } - - @DisplayName("휴대폰 번호 변경 실패 - 현재 사용하는 휴대폰 번호와 동일") - @Test - void updatePhoneNumberWithSamePhoneNumber() { - - // given - String samePhoneNumber = "01012341234"; - UpdatePhoneNumberRequest request = new UpdatePhoneNumberRequest(samePhoneNumber); - - Mockito.when(memberRepository.findByMemberNo(Mockito.anyString())).thenReturn(Optional.of(testMember)); - - // when & then - assertThatThrownBy(() -> memberService.updatePhoneNumber(request)) - .isInstanceOf(BusinessException.class) - .hasMessage(MemberError.SAME_PHONE_NUMBER.getMessage()); - - } - - @DisplayName("휴대폰 번호 변경 실패 - 이미 사용중인 휴대폰 번호") - @Test - void updatePhoneNumberWithDuplicatePhoneNumber() { - - // given - String duplicatePhoneNumber = "01012345678"; - UpdatePhoneNumberRequest request = new UpdatePhoneNumberRequest(duplicatePhoneNumber); - - Mockito.when(memberRepository.findByMemberNo(Mockito.anyString())).thenReturn(Optional.of(testMember)); - Mockito.when(memberRepository.existsByPhoneNumber(duplicatePhoneNumber)).thenReturn(true); - - // when & then - assertThatThrownBy(() -> memberService.updatePhoneNumber(request)) - .isInstanceOf(BusinessException.class) - .hasMessage(MemberError.DUPLICATE_PHONE_NUMBER.getMessage()); - } - - @DisplayName("비밀번호 변경 실패 - 현재 사용하는 비밀번호와 동일") - @Test - void updatePasswordWithSamePassword() { - - // given - String samePassword = "testPwd"; - UpdatePasswordRequest request = new UpdatePasswordRequest(samePassword); - - Mockito.when(memberRepository.findByMemberNo(Mockito.anyString())).thenReturn(Optional.of(testMember)); - Mockito.when(passwordEncoder.matches(samePassword, testMember.getPassword())).thenReturn(true); - - // when & then - assertThatThrownBy(() -> memberService.updatePassword(request)) - .isInstanceOf(BusinessException.class) - .hasMessage(MemberError.SAME_PASSWORD.getMessage()); - - } - -} diff --git a/src/test/java/com/sudo/railo/member/application/MemberUpdateServiceTest.java b/src/test/java/com/sudo/railo/member/application/MemberUpdateServiceTest.java new file mode 100644 index 00000000..0a78de23 --- /dev/null +++ b/src/test/java/com/sudo/railo/member/application/MemberUpdateServiceTest.java @@ -0,0 +1,369 @@ +package com.sudo.railo.member.application; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.junit5.GreenMailExtension; +import com.icegreen.greenmail.util.GreenMailUtil; +import com.icegreen.greenmail.util.ServerSetup; +import com.sudo.railo.auth.application.dto.request.SendCodeRequest; +import com.sudo.railo.auth.application.dto.response.SendCodeResponse; +import com.sudo.railo.auth.exception.AuthError; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.global.redis.AuthRedisRepository; +import com.sudo.railo.global.redis.MemberRedisRepository; +import com.sudo.railo.member.application.dto.request.UpdateEmailRequest; +import com.sudo.railo.member.application.dto.request.UpdatePasswordRequest; +import com.sudo.railo.member.application.dto.request.UpdatePhoneNumberRequest; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.domain.MemberDetail; +import com.sudo.railo.member.domain.Membership; +import com.sudo.railo.member.domain.Role; +import com.sudo.railo.member.exception.MemberError; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; + +@ServiceTest +class MemberUpdateServiceTest { + + @RegisterExtension + static GreenMailExtension greenMail = new GreenMailExtension( + new ServerSetup(0, "127.0.0.1", ServerSetup.PROTOCOL_SMTP) // 포트 충돌이 있어 동적 포트 할당 + ) + .withConfiguration(GreenMailConfiguration.aConfig().withUser("testUser", "testPassword")) + .withPerMethodLifecycle(true); + + @Autowired + private JavaMailSenderImpl mailSender; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MemberUpdateService memberUpdateService; + + @Autowired + private BCryptPasswordEncoder passwordEncoder; + + @Autowired + private AuthRedisRepository authRedisRepository; + + @Autowired + private MemberRedisRepository memberRedisRepository; + + private Member member; + private Member otherMember; + + @BeforeEach + void setUp() { + greenMail.start(); + mailSender.setPort(greenMail.getSmtp().getPort()); + + member = MemberFixture.createStandardMember(); + String plainPwd = member.getPassword(); + String encodedPwd = passwordEncoder.encode(plainPwd); + + Member saveMember = Member.create( + member.getName(), + member.getPhoneNumber(), + encodedPwd, + member.getRole(), + member.getMemberDetail() + ); + memberRepository.save(saveMember); + + MemberDetail otherMemberDetail = MemberDetail.create("202507300002", Membership.BUSINESS, "test2@example.com", + LocalDate.of(2000, 2, 2), "W"); + otherMember = Member.create("김구름", "01088889999", "testPwd", Role.MEMBER, otherMemberDetail); + memberRepository.save(otherMember); + + } + + @AfterEach + void tearDown() { + greenMail.stop(); + } + + @Test + @DisplayName("이메일 인증을 통한 이메일 변경 요청에 성공한다.") + void requestUpdateEmail_success() { + //given + String memberNo = member.getMemberDetail().getMemberNo(); + String newEmail = "newEmail@example.com"; + SendCodeRequest request = new SendCodeRequest(newEmail); + + //when + SendCodeResponse response = memberUpdateService.requestUpdateEmail(request, memberNo); + + //then + assertThat(response).isNotNull(); + assertThat(response.email()).isEqualTo(newEmail); + + assertThat(greenMail.waitForIncomingEmail(5000, 1)).isTrue(); // 이메일 전송 확인 + + // 레디스에 인증 코드 저장 확인 + String savedAuthCode = authRedisRepository.getAuthCode(newEmail); + assertThat(savedAuthCode).isNotNull(); + + // 레디스에 요청이 저장되었는지 확인 + boolean result = memberRedisRepository.handleUpdateEmailRequest(newEmail); + assertThat(result).isFalse(); // 요청이 이미 저장되어 있기 떄문에 false + + // 이메일 내용에 인증 코드를 포함하는지 확인 + String content = GreenMailUtil.getBody(greenMail.getReceivedMessages()[0]); + assertThat(content).contains(savedAuthCode); + } + + @Test + @DisplayName("존재하지 않는 회원으로 이메일 변경 요청 시 요청에 실패한다.") + void requestUpdateEmail_fail_when_user_not_found() { + //given + String wrongMemberNo = "202007079999"; + String newEmail = "newEmail@example.com"; + SendCodeRequest request = new SendCodeRequest(newEmail); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.requestUpdateEmail(request, wrongMemberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.USER_NOT_FOUND)); + } + + @Test + @DisplayName("기존 이메일과 동일한 이메일로 변경 요청 시 변경에 실패한다.") + void requestUpdateEmail_fail_when_same_email() { + //given + String memberNo = member.getMemberDetail().getMemberNo(); + String sameEmail = member.getMemberDetail().getEmail(); + SendCodeRequest request = new SendCodeRequest(sameEmail); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.requestUpdateEmail(request, memberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.SAME_EMAIL)); + } + + @Test + @DisplayName("다른 회원이 사용 중인 이메일로 변경 요청 시 변경에 실패한다.") + void requestUpdateEmail_fail_when_duplicate_email() { + //given + String memberNo = member.getMemberDetail().getMemberNo(); + String duplicateEmail = otherMember.getMemberDetail().getEmail(); + SendCodeRequest request = new SendCodeRequest(duplicateEmail); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.requestUpdateEmail(request, memberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.DUPLICATE_EMAIL)); + } + + @Test + @DisplayName("동일 이메일에 대한 변경 요청이 이미 있는 경우 이메일 변경에 실패한다.") + void requestUpdateEmail_fail_when_already_requested() { + //given + String memberNo = member.getMemberDetail().getMemberNo(); + String newEmail = "newEmail@example.com"; + SendCodeRequest request = new SendCodeRequest(newEmail); + + // 레디스에 이미 요청이 있는 것으로 가정 후 미리 저장 + memberRedisRepository.handleUpdateEmailRequest(newEmail); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.requestUpdateEmail(request, memberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.EMAIL_UPDATE_ALREADY_REQUESTED)); + } + + @Test + @DisplayName("이메일 인증을 통한 이메일 변경 요청 검증에 성공한다.") + void verifyUpdateEmail_success() { + //given + String memberNo = member.getMemberDetail().getMemberNo(); + String newEmail = "newEmail@example.com"; + String authCode = "123456"; + + authRedisRepository.saveAuthCode(newEmail, authCode); + memberRedisRepository.handleUpdateEmailRequest(newEmail); + + UpdateEmailRequest request = new UpdateEmailRequest(newEmail, authCode); + + //when + memberUpdateService.verifyUpdateEmail(request, memberNo); + + //then + Member updatedMember = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new AssertionError("DB에 저장된 회원을 찾을 수 없습니다.")); + assertThat(updatedMember.getMemberDetail().getEmail()).isEqualTo(newEmail); + + // 레디스에 인증 코드 삭제 되었는지 확인 + String redisAuthCode = authRedisRepository.getAuthCode(newEmail); + assertThat(redisAuthCode).isNull(); + + // 레디스에 이메일 변경 요청 삭제 확인 + boolean result = memberRedisRepository.handleUpdateEmailRequest(newEmail); + assertThat(result).isTrue(); // 삭제되면 다시 요청이 가능하여 true + } + + @Test + @DisplayName("존재하지 않는 회원으로 이메일 변경 검증 시 요청에 실패한다.") + void verifyUpdateEmail_fail_when_user_not_found() { + //given + String wrongMemberNo = "202007079999"; + String newEmail = "newEmail@example.com"; + String authCode = "123456"; + + UpdateEmailRequest request = new UpdateEmailRequest(newEmail, authCode); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.verifyUpdateEmail(request, wrongMemberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.USER_NOT_FOUND)); + } + + @Test + @DisplayName("잘못된 인증 코드로 이메일 변경 검증 시 요청에 실패한다.") + void verifyUpdateEmail_fail_when_wrong_auth_code() { + //given + String correctAuthCode = "123456"; + String wrongAuthCode = "999999"; + String newEmail = "newEmail@example.com"; + String memberNo = member.getMemberDetail().getMemberNo(); + + authRedisRepository.saveAuthCode(newEmail, correctAuthCode); + + UpdateEmailRequest request = new UpdateEmailRequest(newEmail, wrongAuthCode); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.verifyUpdateEmail(request, memberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(AuthError.INVALID_AUTH_CODE)); + } + + @Test + @DisplayName("유효한 휴대폰 번호로 변경 요청 시 성공한다.") + void updatePhoneNumber_success() { + //given + String memberNo = member.getMemberDetail().getMemberNo(); + String newPhoneNumber = "01022222222"; + UpdatePhoneNumberRequest request = new UpdatePhoneNumberRequest(newPhoneNumber); + + //when + memberUpdateService.updatePhoneNumber(request, memberNo); + + //then + Member updatedMember = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new AssertionError("DB에 저장된 회원을 찾을 수 없습니다.")); + assertThat(updatedMember.getPhoneNumber()).isEqualTo(newPhoneNumber); + } + + @Test + @DisplayName("회원번호로 일치하는 회원을 찾을 수 없으면 휴대폰 번호 변경 요청에 실패한다.") + void updatePhoneNumber_fail_when_wrong_member_no() { + //given + String wrongMemberNo = "202007070001"; + String newPhoneNumber = "01022222222"; + + UpdatePhoneNumberRequest request = new UpdatePhoneNumberRequest(newPhoneNumber); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.updatePhoneNumber(request, wrongMemberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.USER_NOT_FOUND)); + } + + @Test + @DisplayName("이미 본인과 같은 휴대폰 번호로 변경 요청 시 요청이 실패한다.") + void updatePhoneNumber_fail_when_same_phone_number() { + //given + String memberNo = member.getMemberDetail().getMemberNo(); + String samePhoneNumber = member.getPhoneNumber(); + + UpdatePhoneNumberRequest request = new UpdatePhoneNumberRequest(samePhoneNumber); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.updatePhoneNumber(request, memberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.SAME_PHONE_NUMBER)); + } + + @Test + @DisplayName("다른 회원이 사용하고 있는 휴대폰 번호로 변경 요청 시 요청이 실패한다.") + void updatePhoneNumber_fail_when_duplicate_phone_number() { + //given + String memberNo = member.getMemberDetail().getMemberNo(); + String duplicatePhoneNumber = otherMember.getPhoneNumber(); + UpdatePhoneNumberRequest request = new UpdatePhoneNumberRequest(duplicatePhoneNumber); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.updatePhoneNumber(request, memberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.DUPLICATE_PHONE_NUMBER)); + } + + @Test + @DisplayName("유효한 비밀번호로 변경 요청 시 변경에 성공한다.") + void updatePassword_success() { + //given + String newPassword = "newPassword"; + String memberNo = member.getMemberDetail().getMemberNo(); + UpdatePasswordRequest request = new UpdatePasswordRequest(newPassword); + + //when + memberUpdateService.updatePassword(request, memberNo); + + //then + Member updatedMember = memberRepository.findByMemberNo(memberNo) + .orElseThrow(() -> new AssertionError("DB에 저장된 회원을 찾을 수 없습니다.")); + assertThat(passwordEncoder.matches(newPassword, updatedMember.getPassword())).isTrue(); + } + + @Test + @DisplayName("회원번호로 회원을 찾을 수 없으면 비밀번호 변경에 실패한다.") + void updatePassword_fail_when_wrong_member_no() { + //given + String wrongMemberNo = "202507300009"; + String newPassword = "newPassword"; + UpdatePasswordRequest request = new UpdatePasswordRequest(newPassword); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.updatePassword(request, wrongMemberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.USER_NOT_FOUND)); + } + + @Test + @DisplayName("이미 동일한 비밀번호로 변경 요청 시 비밀번호 변경에 실패한다.") + void updatePassword_fail_when_same_password() { + //given + String memberNo = member.getMemberDetail().getMemberNo(); + String samePassword = member.getPassword(); + UpdatePasswordRequest request = new UpdatePasswordRequest(samePassword); + + //when & then + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> memberUpdateService.updatePassword(request, memberNo)) + .satisfies(exception -> + assertThat(exception.getErrorCode()).isEqualTo(MemberError.SAME_PASSWORD)); + } +} diff --git a/src/test/java/com/sudo/railo/payment/application/PaymentServiceTest.java b/src/test/java/com/sudo/railo/payment/application/PaymentServiceTest.java new file mode 100644 index 00000000..a834d32f --- /dev/null +++ b/src/test/java/com/sudo/railo/payment/application/PaymentServiceTest.java @@ -0,0 +1,272 @@ +package com.sudo.railo.payment.application; + +import static org.assertj.core.api.Assertions.*; + +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.booking.domain.Reservation; +import com.sudo.railo.booking.domain.status.ReservationStatus; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepository; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.payment.application.dto.request.PaymentProcessAccountRequest; +import com.sudo.railo.payment.application.dto.request.PaymentProcessCardRequest; +import com.sudo.railo.payment.application.dto.response.PaymentCancelResponse; +import com.sudo.railo.payment.application.dto.response.PaymentHistoryResponse; +import com.sudo.railo.payment.application.dto.response.PaymentProcessResponse; +import com.sudo.railo.payment.domain.Payment; +import com.sudo.railo.payment.domain.status.PaymentStatus; +import com.sudo.railo.payment.domain.type.PaymentMethod; +import com.sudo.railo.payment.exception.PaymentError; +import com.sudo.railo.payment.infrastructure.PaymentRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.fixture.PaymentFixture; +import com.sudo.railo.support.helper.ReservationTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper.TrainScheduleWithStopStations; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.domain.Train; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +class PaymentServiceTest { + + @Autowired + private PaymentService paymentService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private PaymentRepository paymentRepository; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private ReservationTestHelper reservationTestHelper; + + private Member member; + + private Reservation reservation; + + @BeforeEach + void beforeEach() { + member = MemberFixture.createStandardMember(); + memberRepository.save(member); + + Train train = trainTestHelper.createKTX(); + TrainScheduleWithStopStations scheduleWithStops = trainScheduleTestHelper.createSchedule(train); + reservation = reservationTestHelper.createReservation(member, scheduleWithStops); + } + + @Test + @DisplayName("카드 결제가 성공한다") + void processPaymentViaCard_success() { + // given + PaymentProcessCardRequest request = PaymentFixture.createCardPaymentRequest( + reservation.getId(), BigDecimal.valueOf(reservation.getFare())); + + // when + PaymentProcessResponse response = paymentService + .processPaymentViaCard(member.getMemberDetail().getMemberNo(), request); + + // then + assertThat(response).isNotNull(); + assertThat(response.paymentKey()).isNotNull(); + assertThat(response.amount()).isEqualTo(BigDecimal.valueOf(reservation.getFare())); + assertThat(response.paymentMethod()).isEqualTo(PaymentMethod.CARD); + assertThat(response.paymentStatus()).isEqualTo(PaymentStatus.PAID); + + // 결제 엔티티 확인 + Payment savedPayment = paymentRepository.findByPaymentKey(response.paymentKey()) + .orElseThrow(() -> new AssertionError("결제가 DB에 저장되지 않았습니다")); + assertThat(savedPayment.getPaymentStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(savedPayment.getPaidAt()).isNotNull(); + + // 예약 상태 확인 + Reservation updatedReservation = reservationRepository.findById(reservation.getId()) + .orElseThrow(() -> new AssertionError("예약을 찾을 수 없습니다")); + assertThat(updatedReservation.getReservationStatus()).isEqualTo(ReservationStatus.PAID); + } + + @Test + @DisplayName("계좌이체 결제가 성공한다") + void processPaymentViaBankAccount_success() { + // given + PaymentProcessAccountRequest request = PaymentFixture.createAccountPaymentRequest( + reservation.getId(), BigDecimal.valueOf(reservation.getFare())); + + // when + PaymentProcessResponse response = paymentService + .processPaymentViaBankAccount(member.getMemberDetail().getMemberNo(), request); + + // then + assertThat(response).isNotNull(); + assertThat(response.paymentKey()).isNotNull(); + assertThat(response.amount()).isEqualTo(BigDecimal.valueOf(reservation.getFare())); + assertThat(response.paymentMethod()).isEqualTo(PaymentMethod.TRANSFER); + assertThat(response.paymentStatus()).isEqualTo(PaymentStatus.PAID); + + // 결제 엔티티 확인 + Payment savedPayment = paymentRepository.findByPaymentKey(response.paymentKey()) + .orElseThrow(() -> new AssertionError("결제가 DB에 저장되지 않았습니다")); + assertThat(savedPayment.getPaymentStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(savedPayment.getPaidAt()).isNotNull(); + + // 예약 상태 확인 + Reservation updatedReservation = reservationRepository.findById(reservation.getId()) + .orElseThrow(() -> new AssertionError("예약을 찾을 수 없습니다")); + assertThat(updatedReservation.getReservationStatus()).isEqualTo(ReservationStatus.PAID); + } + + @Test + @DisplayName("금액이 일치하지 않으면 결제가 실패한다") + void processPayment_fail_whenAmountMismatch() { + // given + PaymentProcessCardRequest request = PaymentFixture + .createCardPaymentRequest(reservation.getId(), BigDecimal.valueOf(999999)); + + // when & then + assertThatThrownBy(() -> paymentService + .processPaymentViaCard(member.getMemberDetail().getMemberNo(), request)) + .isInstanceOf(BusinessException.class) + .hasMessage(PaymentError.PAYMENT_AMOUNT_MISMATCH.getMessage()); + } + + @Test + @DisplayName("다른 사용자의 예약으로는 결제할 수 없다") + void processPayment_fail_whenNotOwner() { + // given + Member other = MemberFixture.createOtherMember(); + memberRepository.save(other); + + PaymentProcessCardRequest request = PaymentFixture.createCardPaymentRequest( + reservation.getId(), BigDecimal.valueOf(reservation.getFare())); + + // when & then + assertThatThrownBy(() -> paymentService + .processPaymentViaCard(other.getMemberDetail().getMemberNo(), request)) + .isInstanceOf(BusinessException.class) + .hasMessage(PaymentError.RESERVATION_ACCESS_DENIED.getMessage()); + } + + @Test + @DisplayName("이미 결제된 예약은 중복 결제할 수 없다") + void processPayment_fail_whenAlreadyPaid() { + // given + // 첫 번째 결제 + PaymentProcessCardRequest firstRequest = PaymentFixture.createCardPaymentRequest( + reservation.getId(), BigDecimal.valueOf(reservation.getFare())); + paymentService.processPaymentViaCard(member.getMemberDetail().getMemberNo(), firstRequest); + + // 두 번째 결제 시도 + PaymentProcessCardRequest secondRequest = PaymentFixture.createCardPaymentRequest( + reservation.getId(), BigDecimal.valueOf(reservation.getFare())); + + // when & then + // 첫 번째 결제 후 예약 상태가 PAID로 변경되어 결제할 수 없는 상태가 됨 + assertThatThrownBy(() -> paymentService + .processPaymentViaCard(member.getMemberDetail().getMemberNo(), secondRequest)) + .isInstanceOf(BusinessException.class) + .hasMessage(PaymentError.RESERVATION_NOT_PAYABLE.getMessage()); + } + + @Test + @DisplayName("결제 취소가 성공한다") + void cancelPayment_success() { + // given + PaymentProcessCardRequest request = PaymentFixture.createCardPaymentRequest( + reservation.getId(), BigDecimal.valueOf(reservation.getFare())); + PaymentProcessResponse paymentResponse = paymentService + .processPaymentViaCard(member.getMemberDetail().getMemberNo(), request); + + // when + PaymentCancelResponse cancelResponse = paymentService + .cancelPayment(member.getMemberDetail().getMemberNo(), paymentResponse.paymentKey()); + + // then + assertThat(cancelResponse).isNotNull(); + assertThat(cancelResponse.paymentKey()).isEqualTo(paymentResponse.paymentKey()); + assertThat(cancelResponse.paymentStatus()).isEqualTo(PaymentStatus.REFUNDED); + assertThat(cancelResponse.cancelledAt()).isNotNull(); + + // 결제 엔티티 확인 + Payment cancelledPayment = paymentRepository.findByPaymentKey(paymentResponse.paymentKey()) + .orElseThrow(() -> new AssertionError("결제를 찾을 수 없습니다")); + assertThat(cancelledPayment.getPaymentStatus()).isEqualTo(PaymentStatus.REFUNDED); + + // 예약 상태 확인 + Reservation cancelledReservation = reservationRepository.findById(reservation.getId()) + .orElseThrow(() -> new AssertionError("예약을 찾을 수 없습니다")); + assertThat(cancelledReservation.getReservationStatus()).isEqualTo(ReservationStatus.REFUNDED); + } + + @Test + @DisplayName("다른 사용자의 결제는 취소할 수 없다") + void cancelPayment_fail_whenNotOwner() { + // given + Member other = MemberFixture.createOtherMember(); + memberRepository.save(other); + + PaymentProcessCardRequest request = PaymentFixture.createCardPaymentRequest( + reservation.getId(), BigDecimal.valueOf(reservation.getFare())); + PaymentProcessResponse paymentResponse = paymentService + .processPaymentViaCard(member.getMemberDetail().getMemberNo(), request); + + // when & then + assertThatThrownBy(() -> paymentService.cancelPayment( + other.getMemberDetail().getMemberNo(), paymentResponse.paymentKey())) + .isInstanceOf(BusinessException.class) + .hasMessage(PaymentError.PAYMENT_ACCESS_DENIED.getMessage()); + } + + @Test + @DisplayName("결제 내역 조회가 성공한다") + void getPaymentHistory_success() { + // given + // 카드 결제만 진행 (StationFare 중복 생성 문제 회피) + PaymentProcessCardRequest cardRequest = PaymentFixture.createCardPaymentRequest( + reservation.getId(), BigDecimal.valueOf(reservation.getFare())); + paymentService.processPaymentViaCard(member.getMemberDetail().getMemberNo(), cardRequest); + + // when + List paymentHistory = + paymentService.getPaymentHistory(member.getMemberDetail().getMemberNo()); + + // then + assertThat(paymentHistory).hasSize(1); + + PaymentHistoryResponse cardPayment = paymentHistory.get(0); + assertThat(cardPayment.paymentMethod()).isEqualTo(PaymentMethod.CARD); + assertThat(cardPayment.paymentStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(cardPayment.amount()).isEqualByComparingTo(BigDecimal.valueOf(reservation.getFare())); + assertThat(cardPayment.paymentKey()).isNotNull(); + assertThat(cardPayment.reservationCode()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 회원의 결제 내역 조회 시 예외가 발생한다") + void getPaymentHistory_fail_whenMemberNotFound() { + // when & then + assertThatThrownBy(() -> paymentService.getPaymentHistory("nonexistent")) + .isInstanceOf(BusinessException.class) + .hasMessage("사용자를 찾을 수 없습니다."); + } +} diff --git a/src/test/java/com/sudo/railo/support/annotation/ServiceTest.java b/src/test/java/com/sudo/railo/support/annotation/ServiceTest.java new file mode 100644 index 00000000..42f4af61 --- /dev/null +++ b/src/test/java/com/sudo/railo/support/annotation/ServiceTest.java @@ -0,0 +1,19 @@ +package com.sudo.railo.support.annotation; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.sudo.railo.support.extension.DatabaseCleanupExtension; +import com.sudo.railo.support.extension.RedisCleanupExtension; +import com.sudo.railo.support.extension.RedisServerExtension; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith({RedisServerExtension.class, DatabaseCleanupExtension.class, RedisCleanupExtension.class}) +public @interface ServiceTest { +} diff --git a/src/test/java/com/sudo/railo/support/extension/DatabaseCleanupExtension.java b/src/test/java/com/sudo/railo/support/extension/DatabaseCleanupExtension.java new file mode 100644 index 00000000..b8f5daa6 --- /dev/null +++ b/src/test/java/com/sudo/railo/support/extension/DatabaseCleanupExtension.java @@ -0,0 +1,46 @@ +package com.sudo.railo.support.extension; + +import java.util.List; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class DatabaseCleanupExtension implements AfterEachCallback { + + @Override + public void afterEach(ExtensionContext context) { + JdbcTemplate jdbcTemplate = getJdbcTemplate(context); + final List truncateQueries = getTruncateQueries(jdbcTemplate); + truncateTables(jdbcTemplate, truncateQueries); + } + + private JdbcTemplate getJdbcTemplate(ExtensionContext context) { + return SpringExtension.getApplicationContext(context).getBean(JdbcTemplate.class); + } + + private List getTruncateQueries(JdbcTemplate jdbcTemplate) { + List tableNames = jdbcTemplate.query( + "SELECT TABLE_SCHEMA, TABLE_NAME " + "FROM INFORMATION_SCHEMA.TABLES ", + (rs, rowNum) -> rs.getString("TABLE_SCHEMA") + "." + rs.getString("TABLE_NAME")); + + return tableNames.stream() + .filter(tableNameWithSchema -> tableNameWithSchema.startsWith("PUBLIC.")) + .map(tableNameWithSchema -> "TRUNCATE TABLE " + tableNameWithSchema) + .collect(java.util.stream.Collectors.toList()); + } + + private void truncateTables(JdbcTemplate jdbcTemplate, List truncateQueries) { + try { + execute(jdbcTemplate, "SET FOREIGN_KEY_CHECKS = FALSE"); + truncateQueries.forEach(query -> execute(jdbcTemplate, query)); + } finally { + execute(jdbcTemplate, "SET FOREIGN_KEY_CHECKS = TRUE"); + } + } + + private void execute(JdbcTemplate jdbcTemplate, String query) { + jdbcTemplate.execute(query); + } +} diff --git a/src/test/java/com/sudo/railo/support/extension/RedisCleanupExtension.java b/src/test/java/com/sudo/railo/support/extension/RedisCleanupExtension.java new file mode 100644 index 00000000..3c086597 --- /dev/null +++ b/src/test/java/com/sudo/railo/support/extension/RedisCleanupExtension.java @@ -0,0 +1,25 @@ +package com.sudo.railo.support.extension; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class RedisCleanupExtension implements AfterEachCallback { + + @Override + public void afterEach(ExtensionContext context) { + RedisTemplate redisTemplate = getRedisTemplate(context); + assert redisTemplate.getConnectionFactory() != null; + redisTemplate.getConnectionFactory() + .getConnection() + .serverCommands() + .flushDb(); + } + + @SuppressWarnings("unchecked") + private RedisTemplate getRedisTemplate(ExtensionContext context) { + return (RedisTemplate)SpringExtension.getApplicationContext(context) + .getBean("redisTemplate"); + } +} diff --git a/src/test/java/com/sudo/railo/support/extension/RedisServerExtension.java b/src/test/java/com/sudo/railo/support/extension/RedisServerExtension.java new file mode 100644 index 00000000..2f5f3116 --- /dev/null +++ b/src/test/java/com/sudo/railo/support/extension/RedisServerExtension.java @@ -0,0 +1,29 @@ +package com.sudo.railo.support.extension; + +import java.io.IOException; + +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import redis.embedded.RedisServer; + +public class RedisServerExtension implements BeforeAllCallback, AfterAllCallback { + private static RedisServer redisServer; + + @Override + public void beforeAll(ExtensionContext context) throws IOException { + if (redisServer == null) { + redisServer = new RedisServer(63790); + redisServer.start(); + } + } + + @Override + public void afterAll(ExtensionContext context) throws IOException { + if (redisServer != null) { + redisServer.stop(); + redisServer = null; + } + } +} diff --git a/src/test/java/com/sudo/railo/support/fixture/MemberFixture.java b/src/test/java/com/sudo/railo/support/fixture/MemberFixture.java new file mode 100644 index 00000000..3bbcaab6 --- /dev/null +++ b/src/test/java/com/sudo/railo/support/fixture/MemberFixture.java @@ -0,0 +1,73 @@ +package com.sudo.railo.support.fixture; + +import java.time.LocalDate; + +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.domain.MemberDetail; +import com.sudo.railo.member.domain.Membership; +import com.sudo.railo.member.domain.Role; + +import lombok.Getter; + +@Getter +public enum MemberFixture { + + MEMBER( + "202507300001", + Membership.FAMILY, + "test@example.com", + LocalDate.of(2000, 1, 1), + "M", + "member", + "010-1111-1111", + "testPassword", + Role.MEMBER + ), + OTHER_MEMBER( + "202507300002", + Membership.FAMILY, + "other@example.com", + LocalDate.of(2000, 1, 1), + "W", + "other", + "010-2222-2222", + "otherPassword", + Role.MEMBER + ); + + private final String memberNo; + private final Membership membership; + private final String email; + private final LocalDate birthDate; + private final String gender; + private final String name; + private final String phoneNumber; + private final String password; + private final Role role; + + MemberFixture(String memberNo, Membership membership, String email, LocalDate birthDate, String gender, + String name, String phoneNumber, String password, Role role) { + this.memberNo = memberNo; + this.membership = membership; + this.email = email; + this.birthDate = birthDate; + this.gender = gender; + this.name = name; + this.phoneNumber = phoneNumber; + this.password = password; + this.role = role; + } + + public static Member createStandardMember() { + MemberDetail memberDetail = MemberDetail.create(MEMBER.memberNo, MEMBER.membership, MEMBER.email, + MEMBER.birthDate, MEMBER.gender); + return Member.create(MEMBER.name, MEMBER.phoneNumber, MEMBER.password, MEMBER.role, memberDetail); + } + + public static Member createOtherMember() { + MemberDetail memberDetail = MemberDetail.create(OTHER_MEMBER.memberNo, OTHER_MEMBER.membership, + OTHER_MEMBER.email, OTHER_MEMBER.birthDate, OTHER_MEMBER.gender); + return Member.create(OTHER_MEMBER.name, OTHER_MEMBER.phoneNumber, OTHER_MEMBER.password, OTHER_MEMBER.role, + memberDetail); + } +} diff --git a/src/test/java/com/sudo/railo/support/fixture/PaymentFixture.java b/src/test/java/com/sudo/railo/support/fixture/PaymentFixture.java new file mode 100644 index 00000000..042ddbb6 --- /dev/null +++ b/src/test/java/com/sudo/railo/support/fixture/PaymentFixture.java @@ -0,0 +1,86 @@ +package com.sudo.railo.support.fixture; + +import java.math.BigDecimal; + +import com.sudo.railo.payment.application.dto.request.PaymentProcessAccountRequest; +import com.sudo.railo.payment.application.dto.request.PaymentProcessCardRequest; + +import lombok.Getter; + +@Getter +public enum PaymentFixture { + + CARD_PAYMENT("1234-5678-9012-3456", "1225", "000505", 0, 12), + + ACCOUNT_PAYMENT("088", "123456789012", "홍길동", "000505", "75"); + + // 카드 결제 관련 필드 + private final String cardNumber; + private final String validThru; + private final String rrn; + private final Integer installmentMonths; + private final Integer cardPassword; + + // 계좌이체 관련 필드 + private final String bankCode; + private final String accountNumber; + private final String accountHolderName; + private final String identificationNumber; + private final String accountPassword; + + PaymentFixture(String cardNumber, String validThru, String rrn, Integer installmentMonths, + Integer cardPassword) { + this.cardNumber = cardNumber; + this.validThru = validThru; + this.rrn = rrn; + this.installmentMonths = installmentMonths; + this.cardPassword = cardPassword; + // 계좌이체 필드는 null로 초기화 + this.bankCode = null; + this.accountNumber = null; + this.accountHolderName = null; + this.identificationNumber = null; + this.accountPassword = null; + } + + PaymentFixture(String bankCode, String accountNumber, String accountHolderName, + String identificationNumber, String accountPassword) { + this.bankCode = bankCode; + this.accountNumber = accountNumber; + this.accountHolderName = accountHolderName; + this.identificationNumber = identificationNumber; + this.accountPassword = accountPassword; + // 카드 결제 필드는 null로 초기화 + this.cardNumber = null; + this.validThru = null; + this.rrn = null; + this.installmentMonths = null; + this.cardPassword = null; + } + + public static PaymentProcessCardRequest createCardPaymentRequest(Long reservationId, + BigDecimal amount) { + PaymentProcessCardRequest request = new PaymentProcessCardRequest(); + request.setReservationId(reservationId); + request.setAmount(amount); + request.setCardNumber(CARD_PAYMENT.cardNumber); + request.setValidThru(CARD_PAYMENT.validThru); + request.setRrn(CARD_PAYMENT.rrn); + request.setInstallmentMonths(CARD_PAYMENT.installmentMonths); + request.setCardPassword(CARD_PAYMENT.cardPassword); + return request; + } + + public static PaymentProcessAccountRequest createAccountPaymentRequest(Long reservationId, + BigDecimal amount) { + PaymentProcessAccountRequest request = new PaymentProcessAccountRequest(); + request.setReservationId(reservationId); + request.setAmount(amount); + request.setBankCode(ACCOUNT_PAYMENT.bankCode); + request.setAccountNumber(ACCOUNT_PAYMENT.accountNumber); + request.setAccountHolderName(ACCOUNT_PAYMENT.accountHolderName); + request.setIdentificationNumber(ACCOUNT_PAYMENT.identificationNumber); + request.setAccountPassword(ACCOUNT_PAYMENT.accountPassword); + return request; + } +} diff --git a/src/test/java/com/sudo/railo/support/helper/ReservationTestHelper.java b/src/test/java/com/sudo/railo/support/helper/ReservationTestHelper.java new file mode 100644 index 00000000..e96c362a --- /dev/null +++ b/src/test/java/com/sudo/railo/support/helper/ReservationTestHelper.java @@ -0,0 +1,198 @@ +package com.sudo.railo.support.helper; + +import static com.sudo.railo.support.helper.TrainScheduleTestHelper.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.sudo.railo.booking.domain.Reservation; +import com.sudo.railo.booking.domain.SeatReservation; +import com.sudo.railo.booking.domain.status.ReservationStatus; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.booking.domain.type.TripType; +import com.sudo.railo.booking.infrastructure.SeatReservationRepository; +import com.sudo.railo.booking.infrastructure.reservation.ReservationRepository; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.train.domain.ScheduleStop; +import com.sudo.railo.train.domain.Seat; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.type.CarType; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Transactional +public class ReservationTestHelper { + + private final TrainTestHelper trainTestHelper; + private final ReservationRepository reservationRepository; + private final SeatReservationRepository seatReservationRepository; + + /** + * 기본 예약 생성 메서드 + */ + public Reservation createReservation(Member member, + TrainScheduleWithStopStations scheduleWithStops) { + Reservation reservation = Reservation.builder() + .trainSchedule(scheduleWithStops.trainSchedule()) + .member(member) + .reservationCode("20250806100001D49J") + .tripType(TripType.OW) + .totalPassengers(1) + .passengerSummary("[{\"passengerType\":\"ADULT\",\"count\":1}]") + .reservationStatus(ReservationStatus.RESERVED) + .expiresAt(LocalDateTime.now().plusMinutes(10)) + .fare(50000) + .departureStop(getDepartureStop(scheduleWithStops.scheduleStops())) + .arrivalStop(getArrivalStop(scheduleWithStops.scheduleStops())) + .build(); + + reservationRepository.save(reservation); + createSeatReservation(reservation); + return reservation; + } + + /** + * 특정 좌석 ID들에 대한 좌석 예약 생성 (출발, 도착 정차역 직접 지정) + * + * @param member 예약할 회원 + * @param scheduleWithStops 열차 스케줄 및 정차역 정보 + * @param departureStop 출발 정차역 + * @param arrivalStop 도착 정차역 + * @param seatIds 예약할 좌석 ID 목록 + * @param passengerType 승객 유형 (성인, 어린이 등) + * @return 생성된 Reservation 객체 + */ + public Reservation createReservationWithSeatIds(Member member, + TrainScheduleWithStopStations scheduleWithStops, + ScheduleStop departureStop, + ScheduleStop arrivalStop, + List seatIds, + PassengerType passengerType) { + + Reservation reservation = Reservation.builder() + .trainSchedule(scheduleWithStops.trainSchedule()) + .member(member) + .reservationCode("SEAT-" + System.currentTimeMillis()) + .tripType(TripType.OW) + .totalPassengers(seatIds.size()) + .passengerSummary("[{\"passengerType\":\"" + passengerType.name() + "\",\"count\":" + seatIds.size() + "}]") + .reservationStatus(ReservationStatus.RESERVED) + .expiresAt(LocalDateTime.now().plusMinutes(10)) + .fare(50000 * seatIds.size()) + .departureStop(departureStop) + .arrivalStop(arrivalStop) + .build(); + + reservationRepository.save(reservation); + createSeatReservations(reservation, seatIds, passengerType); + return reservation; + } + + /** + * 지정한 인원 수만큼 입석 예약 생성 (출발, 도착 정차역 직접 지정, seat 없이) + * + * @param member 예약할 회원 + * @param scheduleWithStops 열차 스케줄 및 정차역 정보 + * @param departureStop 출발 정차역 + * @param arrivalStop 도착 정차역 + * @param standingCount 입석 인원 수 + * @return 생성된 Reservation 객체 + */ + public Reservation createStandingReservation(Member member, + TrainScheduleWithStopStations scheduleWithStops, + ScheduleStop departureStop, + ScheduleStop arrivalStop, + int standingCount) { + + Reservation reservation = Reservation.builder() + .trainSchedule(scheduleWithStops.trainSchedule()) + .member(member) + .reservationCode("STANDING-" + System.currentTimeMillis()) + .tripType(TripType.OW) + .totalPassengers(standingCount) + .passengerSummary("[{\"passengerType\":\"ADULT\",\"count\":" + standingCount + "}]") + .reservationStatus(ReservationStatus.RESERVED) + .expiresAt(LocalDateTime.now().plusMinutes(10)) + .fare((int)(50000 * 0.85) * standingCount) + .departureStop(departureStop) + .arrivalStop(arrivalStop) + .build(); + + reservationRepository.save(reservation); + createStandingSeatReservations(reservation, standingCount); + return reservation; + } + + /** + * 좌석 예약 생성 메서드 + */ + private void createSeatReservation(Reservation reservation) { + Train train = reservation.getTrainSchedule().getTrain(); + List seats = trainTestHelper.getSeats(train, CarType.STANDARD, 1); + + List seatReservations = seats.stream() + .map(seat -> SeatReservation.builder() + .trainSchedule(reservation.getTrainSchedule()) + .seat(seat) + .reservation(reservation) + .passengerType(PassengerType.ADULT) + .build()) + .toList(); + + seatReservationRepository.saveAll(seatReservations); + } + + /** + * 주어진 좌석 ID들로 SeatReservation 생성 + */ + private void createSeatReservations(Reservation reservation, List seatIds, PassengerType passengerType) { + List seats = trainTestHelper.getSeatsByIds(seatIds); + + List seatReservations = seats.stream() + .map(seat -> SeatReservation.builder() + .trainSchedule(reservation.getTrainSchedule()) + .seat(seat) + .reservation(reservation) + .passengerType(passengerType) + .build()) + .toList(); + + seatReservationRepository.saveAll(seatReservations); + } + + /** + * Seat=null로 승객수만큼 입석 SeatReservations 생성 + */ + private void createStandingSeatReservations(Reservation reservation, int count) { + List seatReservations = + java.util.stream.IntStream.range(0, count) + .mapToObj(i -> SeatReservation.builder() + .trainSchedule(reservation.getTrainSchedule()) + .seat(null) // 입석 처리 + .reservation(reservation) + .passengerType(PassengerType.ADULT) + .build()) + .toList(); + + seatReservationRepository.saveAll(seatReservations); + } + + private ScheduleStop getDepartureStop(List scheduleStops) { + if (scheduleStops.isEmpty()) { + throw new IllegalArgumentException("출발역을 찾을 수 없습니다."); + } + return scheduleStops.get(0); + } + + private ScheduleStop getArrivalStop(List scheduleStops) { + if (scheduleStops.isEmpty()) { + throw new IllegalArgumentException("도착역을 찾을 수 없습니다."); + } + return scheduleStops.get(scheduleStops.size() - 1); + } +} diff --git a/src/test/java/com/sudo/railo/support/helper/TrainScheduleTestHelper.java b/src/test/java/com/sudo/railo/support/helper/TrainScheduleTestHelper.java new file mode 100644 index 00000000..a2d18a4b --- /dev/null +++ b/src/test/java/com/sudo/railo/support/helper/TrainScheduleTestHelper.java @@ -0,0 +1,223 @@ +package com.sudo.railo.support.helper; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.sudo.railo.train.domain.ScheduleStop; +import com.sudo.railo.train.domain.ScheduleStopTemplate; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.StationFare; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.TrainSchedule; +import com.sudo.railo.train.domain.TrainScheduleTemplate; +import com.sudo.railo.train.infrastructure.ScheduleStopRepository; +import com.sudo.railo.train.infrastructure.StationFareRepository; +import com.sudo.railo.train.infrastructure.StationRepository; +import com.sudo.railo.train.infrastructure.TrainScheduleRepository; +import com.sudo.railo.train.infrastructure.TrainScheduleTemplateRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@Transactional +public class TrainScheduleTestHelper { + + public static final int EVERYDAY = 127; + + private final TrainScheduleRepository trainScheduleRepository; + private final ScheduleStopRepository scheduleStopRepository; + private final StationRepository stationRepository; + private final StationFareRepository stationFareRepository; + private final TrainScheduleTemplateRepository trainScheduleTemplateRepository; + + /** + * 기본 스케줄 생성 메서드 + * 서울 -> 부산 (출발: 5:00 -> 도착: 8:00) + * standardFare: 50,000원, firstClassFare: 100,000원 + */ + public TrainScheduleWithStopStations createSchedule(Train train) { + createOrUpdateStationFare("서울", "부산", 50000, 100000); + return createCustomSchedule() + .scheduleName("KTX 001 경부선") + .operationDate(LocalDate.now()) + .train(train) + .addStop("서울", null, LocalTime.of(5, 0)) + .addStop("부산", LocalTime.of(8, 0), null) + .build(); + } + + /** + * 커스텀 스케줄 빌더 + */ + public TrainScheduleBuilder createCustomSchedule() { + return new TrainScheduleBuilder(); + } + + /** + * 역 + 구간요금 생성 (없으면 생성, 있으면 갱신) + */ + public void createOrUpdateStationFare(String from, String to, int standardFare, int firstClassFare) { + Station departure = getOrCreateStation(from); + Station arrival = getOrCreateStation(to); + + StationFare fare = StationFare.create(departure, arrival, standardFare, firstClassFare); + stationFareRepository.save(fare); + } + + /** + * 특정 역의 정차 정보 조회 + */ + public ScheduleStop getScheduleStopByStationName(TrainScheduleWithStopStations schedule, String stationName) { + return schedule.scheduleStops().stream() + .filter(s -> s.getStation().getStationName().equals(stationName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("정차역을 찾을 수 없습니다: " + stationName)); + } + + /** + * 역 생성 또는 조회 + */ + public Station getOrCreateStation(String stationName) { + return stationRepository.findByStationName(stationName) + .orElseGet(() -> stationRepository.save(Station.create(stationName))); + } + + /** + * TrainSchedule 생성용 Builder + */ + public class TrainScheduleBuilder { + private final List stops = new ArrayList<>(); + private String scheduleName; + private LocalDate operationDate; + private LocalTime departureTime; + private LocalTime arrivalTime; + private Train train; + private int operatingDays = EVERYDAY; + + public TrainScheduleBuilder scheduleName(String name) { + this.scheduleName = name; + return this; + } + + public TrainScheduleBuilder operationDate(LocalDate date) { + this.operationDate = date; + return this; + } + + public TrainScheduleBuilder train(Train train) { + this.train = train; + return this; + } + + public TrainScheduleBuilder operatingDays(int days) { + this.operatingDays = days; + return this; + } + + public TrainScheduleBuilder addStop(String stationName, LocalTime arrival, LocalTime departure) { + stops.add(new StopInfo(stationName, arrival, departure, stops.size())); + return this; + } + + @Transactional + public TrainScheduleWithStopStations build() { + validateStops(); + setDepartureAndArrivalTime(); + Map stationMap = resolveStations(); + List stopTemplates = buildStopTemplates(stationMap); + TrainScheduleTemplate template = saveTemplate(stationMap, stopTemplates); + TrainSchedule schedule = saveSchedule(template); + List savedStops = saveScheduleStops(template, schedule); + return new TrainScheduleWithStopStations(schedule, savedStops); + } + + private void validateStops() { + if (stops.size() < 2) { + throw new IllegalArgumentException("스케줄은 최소 2개 이상의 정차역이 필요합니다. 현재 정차역 수: " + stops.size()); + } + } + + private void setDepartureAndArrivalTime() { + if (departureTime == null) { + StopInfo firstStop = stops.get(0); + this.departureTime = firstStop.departureTime(); + if (this.departureTime == null) { + throw new IllegalArgumentException("첫 번째 정차역의 출발시간이 설정되어야 합니다."); + } + } + + if (arrivalTime == null) { + StopInfo lastStop = stops.get(stops.size() - 1); + this.arrivalTime = lastStop.arrivalTime(); + if (this.arrivalTime == null) { + throw new IllegalArgumentException("마지막 정차역의 도착시간이 설정되어야 합니다."); + } + } + } + + private Map resolveStations() { + Map map = new HashMap<>(); + stops.forEach(stop -> map.putIfAbsent(stop.stationName(), getOrCreateStation(stop.stationName()))); + return map; + } + + private List buildStopTemplates(Map stationMap) { + return stops.stream() + .map(s -> ScheduleStopTemplate.create( + s.stopOrder(), + s.arrivalTime(), + s.departureTime(), + stationMap.get(s.stationName()) + )).toList(); + } + + private TrainScheduleTemplate saveTemplate(Map stationMap, + List stopTemplates) { + Station departure = stationMap.get(stops.get(0).stationName()); + Station arrival = stationMap.get(stops.get(stops.size() - 1).stationName()); + + TrainScheduleTemplate template = TrainScheduleTemplate.create( + scheduleName, + operatingDays, + departureTime, + arrivalTime, + train, + departure, + arrival, + stopTemplates + ); + return trainScheduleTemplateRepository.save(template); + } + + private TrainSchedule saveSchedule(TrainScheduleTemplate template) { + return trainScheduleRepository.save(TrainSchedule.create(operationDate, template)); + } + + private List saveScheduleStops(TrainScheduleTemplate template, TrainSchedule schedule) { + List stops = template.getScheduleStops().stream() + .map(t -> ScheduleStop.create(t, schedule)) + .toList(); + return scheduleStopRepository.saveAll(stops); + } + } + + /** + * 생성 결과 객체 (스케줄 + 정차역들) + */ + public record TrainScheduleWithStopStations(TrainSchedule trainSchedule, List scheduleStops) { + } + + /** + * 정차역 임시 정보 (Builder 내부용) + */ + private record StopInfo(String stationName, LocalTime arrivalTime, LocalTime departureTime, int stopOrder) { + } +} diff --git a/src/test/java/com/sudo/railo/support/helper/TrainTestHelper.java b/src/test/java/com/sudo/railo/support/helper/TrainTestHelper.java new file mode 100644 index 00000000..7422bef7 --- /dev/null +++ b/src/test/java/com/sudo/railo/support/helper/TrainTestHelper.java @@ -0,0 +1,277 @@ +package com.sudo.railo.support.helper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.sudo.railo.support.repository.TestSeatRepository; +import com.sudo.railo.train.config.TrainTemplateProperties.CarSpec; +import com.sudo.railo.train.config.TrainTemplateProperties.SeatColumn; +import com.sudo.railo.train.config.TrainTemplateProperties.SeatLayout; +import com.sudo.railo.train.config.TrainTemplateProperties.TrainTemplate; +import com.sudo.railo.train.domain.Seat; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.TrainCar; +import com.sudo.railo.train.domain.type.CarType; +import com.sudo.railo.train.domain.type.SeatType; +import com.sudo.railo.train.domain.type.TrainType; +import com.sudo.railo.train.infrastructure.TrainCarRepository; +import com.sudo.railo.train.infrastructure.TrainRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class TrainTestHelper { + + private final TrainRepository trainRepository; + private final TrainCarRepository trainCarRepository; + private final TestSeatRepository testSeatRepository; + + /** + * 기본 기차 생성 메서드 + * 객차 총 2개 (standard 1개 + firstClass 1개) + * 좌석 총 4개 (standard 2개 + firstClass 2개) + */ + @Transactional + public Train createKTX() { + return createCustomKTX(1, 1); + } + + /** + * 테스트용 소형 열차 생성 + * 객차 총 2개 (standard 1개 + firstClass 1개) + * 좌석 총 4개 (standard 24개 + firstClass 12개) + */ + @Transactional + public Train createSmallTestTrain() { + return createRealisticTrain(1, 1, 6, 4); + } + + /** + * 테스트용 중형 열차 생성 + * 객차 총 5개 (standard 3개 + firstClass 2개) + * 좌석 총 192개 (standard 144개 + firstClass 48개) + */ + @Transactional + public Train createMediumTestTrain() { + return createRealisticTrain(3, 2, 12, 8); + } + + /** + * 커스텀 기차 생성 메서드 + * 객차 총 2개 (standard 1개 + firstClass 1개) + * 좌석 = (standardRows * 2)개 + (firstRows * 2)개 + */ + @Transactional + public Train createCustomKTX(int standardRows, int firstRows) { + Train train = createKTXTrain(); + Train savedTrain = trainRepository.save(train); + + List carSpecs = List.of( + new CarSpec(CarType.STANDARD, standardRows), + new CarSpec(CarType.FIRST_CLASS, firstRows) + ); + + return saveTrainWithCarsAndSeats(savedTrain, carSpecs); + } + + /** + * 고급 열차 생성 메서드 - 객차 개수와 각 객차별 행 수를 모두 설정 가능 + * + * @param standardCarCount 일반실 객차 수 + * @param firstClassCarCount 특실 객차 수 + * @param standardRowsPerCar 일반실 객차당 행 수 (각 행은 4석 - 2+2 배치) + * @param firstClassRowsPerCar 특실 객차당 행 수 (각 행은 3석 - 2+1 배치) + * @return 생성된 열차 + * + * 예시: + * - createAdvancedTrain(2, 1, 10, 6) → 일반실 2개(각 40석), 특실 1개(18석) = 총 98석 + * - createAdvancedTrain(5, 3, 15, 10) → 일반실 5개(각 60석), 특실 3개(각 30석) = 총 390석 + */ + @Transactional + public Train createRealisticTrain(int standardCarCount, int firstClassCarCount, + int standardRowsPerCar, int firstClassRowsPerCar) { + + // 입력값 검증 + validateTrainConfiguration(standardCarCount, firstClassCarCount, standardRowsPerCar, firstClassRowsPerCar); + + Train train = createKTXTrain(standardCarCount + firstClassCarCount); + Train savedTrain = trainRepository.save(train); + + List carSpecs = new ArrayList<>(); + + // 일반실 객차들 추가 + for (int i = 0; i < standardCarCount; i++) { + carSpecs.add(new CarSpec(CarType.STANDARD, standardRowsPerCar)); + } + + // 특실 객차들 추가 + for (int i = 0; i < firstClassCarCount; i++) { + carSpecs.add(new CarSpec(CarType.FIRST_CLASS, firstClassRowsPerCar)); + } + + return saveTrainWithCarsAndSeats(savedTrain, carSpecs, createRealisticSeatLayouts()); + } + + /** + * 좌석 조회 메서드 + * count만큼 carType에 해당하는 좌석 조회 + */ + public List getSeats(Train train, CarType carType, int count) { + Pageable limit = PageRequest.of(0, count); + return testSeatRepository.findByTrainIdAndCarTypeWithTrainCarLimited(train.getId(), carType, limit) + .stream() + .toList(); + } + + /** + * 주어진 seatId 목록에 해당하는 Seat 목록을 조회 + */ + public List getSeatsByIds(List seatIds) { + return testSeatRepository.findAllById(seatIds); + } + + /** + * 좌석 조회 메서드 + * count만큼 carType에 해당하는 좌석 ID 조회 + */ + public List getSeatIds(Train train, CarType carType, int count) { + return getSeats(train, carType, count) + .stream() + .map(Seat::getId) + .toList(); + } + + /** + * 기차에 존재하는 모든 좌석 조회 메서드 + */ + public List getAllSeatIds(Train train) { + return testSeatRepository.findByTrainIdWithTrainCar(train.getId()) + .stream() + .map(Seat::getId) + .toList(); + } + + private Train createKTXTrain() { + return Train.create(1, TrainType.KTX, "KTX", 2); + } + + private Train createKTXTrain(int totalCars) { + return Train.create(1, TrainType.KTX, "KTX", totalCars); + } + + private Train saveTrainWithCarsAndSeats(Train savedTrain, List carSpecs) { + TrainTemplate trainTemplate = new TrainTemplate(carSpecs); + Map seatLayouts = createSeatLayouts(); + + List trainCars = savedTrain.generateTrainCars(seatLayouts, trainTemplate); + List savedTrainCars = trainCarRepository.saveAll(trainCars); + + savedTrainCars.forEach(trainCar -> { + CarSpec carSpec = getCarSpecByCarNumber(carSpecs, trainCar.getCarNumber()); + SeatLayout seatLayout = seatLayouts.get(trainCar.getCarType()); + List seats = trainCar.generateSeats(carSpec, seatLayout); + testSeatRepository.saveAll(seats); + }); + + return savedTrain; + } + + private Train saveTrainWithCarsAndSeats(Train savedTrain, List carSpecs, + Map seatLayouts) { + TrainTemplate trainTemplate = new TrainTemplate(carSpecs); + + List trainCars = savedTrain.generateTrainCars(seatLayouts, trainTemplate); + List savedTrainCars = trainCarRepository.saveAll(trainCars); + + savedTrainCars.forEach(trainCar -> { + CarSpec carSpec = getCarSpecByCarNumber(carSpecs, trainCar.getCarNumber()); + SeatLayout seatLayout = seatLayouts.get(trainCar.getCarType()); + List seats = trainCar.generateSeats(carSpec, seatLayout); + testSeatRepository.saveAll(seats); + }); + + return savedTrain; + } + + private CarSpec getCarSpecByCarNumber(List carSpecs, int carNumber) { + return carSpecs.get(carNumber - 1); + } + + private Map createSeatLayouts() { + Map layouts = new HashMap<>(); + + List standardColumns = List.of( + new SeatColumn("A", SeatType.WINDOW), + new SeatColumn("B", SeatType.AISLE) + ); + layouts.put(CarType.STANDARD, new SeatLayout("2+2", standardColumns)); + + List firstClassColumns = List.of( + new SeatColumn("A", SeatType.WINDOW), + new SeatColumn("B", SeatType.AISLE) + ); + layouts.put(CarType.FIRST_CLASS, new SeatLayout("2+1", firstClassColumns)); + + return layouts; + } + + /** + * 현실적인 좌석 배치 (실제 KTX와 동일) + * - 일반실: 4석/행 (2+2 배치) + * - 특실: 3석/행 (2+1 배치) + */ + private Map createRealisticSeatLayouts() { + Map layouts = new HashMap<>(); + + // 일반실: 2+2 배치 (AB 통로 CD) - 4석/행 + List standardColumns = List.of( + new SeatColumn("A", SeatType.WINDOW), + new SeatColumn("B", SeatType.AISLE), + new SeatColumn("C", SeatType.AISLE), + new SeatColumn("D", SeatType.WINDOW) + ); + layouts.put(CarType.STANDARD, new SeatLayout("2+2", standardColumns)); + + // 특실: 2+1 배치 (AB 통로 C) - 3석/행 + List firstClassColumns = List.of( + new SeatColumn("A", SeatType.WINDOW), + new SeatColumn("B", SeatType.AISLE), + new SeatColumn("C", SeatType.WINDOW) + ); + layouts.put(CarType.FIRST_CLASS, new SeatLayout("2+1", firstClassColumns)); + + return layouts; + } + + private void validateTrainConfiguration(int standardCarCount, int firstClassCarCount, + int standardRowsPerCar, int firstClassRowsPerCar) { + if (standardCarCount < 0 || firstClassCarCount < 0) { + throw new IllegalArgumentException("객차 수는 0 이상이어야 합니다."); + } + if (standardCarCount == 0 && firstClassCarCount == 0) { + throw new IllegalArgumentException("최소 하나의 객차는 있어야 합니다."); + } + if (standardRowsPerCar < 1 || firstClassRowsPerCar < 1) { + throw new IllegalArgumentException("객차당 행 수는 1 이상이어야 합니다."); + } + if (standardCarCount + firstClassCarCount > 20) { + throw new IllegalArgumentException("총 객차 수는 20개를 초과할 수 없습니다."); + } + + // 총 좌석 수 제한 (너무 큰 테스트 데이터 방지) + int totalSeats = (standardCarCount * standardRowsPerCar * 4) + (firstClassCarCount * firstClassRowsPerCar * 3); + if (totalSeats > 1000) { + log.warn("총 좌석 수가 {}석으로 너무 큽니다. 테스트 성능에 영향을 줄 수 있습니다.", totalSeats); + } + } +} diff --git a/src/test/java/com/sudo/railo/support/repository/TestSeatRepository.java b/src/test/java/com/sudo/railo/support/repository/TestSeatRepository.java new file mode 100644 index 00000000..6e475a81 --- /dev/null +++ b/src/test/java/com/sudo/railo/support/repository/TestSeatRepository.java @@ -0,0 +1,23 @@ +package com.sudo.railo.support.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import com.sudo.railo.train.domain.Seat; +import com.sudo.railo.train.domain.type.CarType; + +/** + * 테스트에서만 사용하는 Seat 조회 Repository + */ +public interface TestSeatRepository extends JpaRepository { + + @Query("SELECT s FROM Seat s JOIN FETCH s.trainCar tc JOIN FETCH tc.train t " + + "WHERE t.id = :trainId AND tc.carType = :carType") + List findByTrainIdAndCarTypeWithTrainCarLimited(Long trainId, CarType carType, Pageable pageable); + + @Query("SELECT s FROM Seat s JOIN FETCH s.trainCar tc JOIN FETCH tc.train t WHERE t.id = :trainId") + List findByTrainIdWithTrainCar(Long trainId); +} diff --git a/src/test/java/com/sudo/railo/train/application/TrainSearchApplicationServiceTest.java b/src/test/java/com/sudo/railo/train/application/TrainSearchApplicationServiceTest.java new file mode 100644 index 00000000..97fcaaff --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSearchApplicationServiceTest.java @@ -0,0 +1,591 @@ +package com.sudo.railo.train.application; + +import static com.sudo.railo.support.helper.TrainScheduleTestHelper.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Collection; +import java.util.List; +import java.util.function.Predicate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainCarListRequest; +import com.sudo.railo.train.application.dto.request.TrainCarSeatDetailRequest; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.OperationCalendarItem; +import com.sudo.railo.train.application.dto.response.TrainCarInfo; +import com.sudo.railo.train.application.dto.response.TrainCarListResponse; +import com.sudo.railo.train.application.dto.response.TrainCarSeatDetailResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.TrainCar; +import com.sudo.railo.train.domain.type.CarType; +import com.sudo.railo.train.infrastructure.TrainCarRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ServiceTest +class TrainSearchApplicationServiceTest { + + @Autowired + private TrainSearchApplicationService trainSearchApplicationService; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private TrainCarRepository trainCarRepository; + + @DisplayName("금일로부터 한달간의 운행 스케줄 캘린더를 조회한다.") + @Test + void getOperationCalendar() { + // given + Train train1 = trainTestHelper.createKTX(); + Train train2 = trainTestHelper.createCustomKTX(2, 1); + + LocalDate today = LocalDate.now(); + LocalDate tomorrow = today.plusDays(1); + LocalDate dayAfterTomorrow = today.plusDays(2); + LocalDate nextWeek = today.plusWeeks(1); + + createTrainSchedule(train1, today, "KTX 001", LocalTime.of(8, 0), LocalTime.of(11, 0)); + createTrainSchedule(train2, tomorrow, "KTX 003", LocalTime.of(14, 0), LocalTime.of(17, 0)); + createTrainSchedule(train1, nextWeek, "KTX 005", LocalTime.of(10, 0), LocalTime.of(13, 0)); + + // when + List operationCalendar = trainSearchApplicationService.getOperationCalendar(); + + // then + // 1. 캘린더가 한 달치 날짜를 포함하는지 확인 (약 30일) + assertThat(operationCalendar).hasSizeGreaterThanOrEqualTo(28).hasSizeLessThanOrEqualTo(32); + + // 2. 운행하는 날짜들이 isBookingAvailable = "Y"로 표시되는지 확인 + assertThat(operationCalendar).anyMatch(item -> + item.operationDate().equals(today) && item.isBookingAvailable().equals("Y")); + assertThat(operationCalendar).anyMatch(item -> + item.operationDate().equals(tomorrow) && item.isBookingAvailable().equals("Y")); + assertThat(operationCalendar).anyMatch(item -> + item.operationDate().equals(nextWeek) && item.isBookingAvailable().equals("Y")); + + // 3. 운행하지 않는 날짜가 isBookingAvailable = "N"으로 표시되는지 확인 + assertThat(operationCalendar).anyMatch(item -> + item.operationDate().equals(dayAfterTomorrow) && item.isBookingAvailable().equals("N")); + + // 4. 전체 캘린더에서 운행일과 비운행일이 모두 존재하는지 확인 + long operatingDays = operationCalendar.stream() + .mapToLong(item -> item.isBookingAvailable().equals("Y") ? 1 : 0) + .sum(); + long nonOperatingDays = operationCalendar.stream() + .mapToLong(item -> item.isBookingAvailable().equals("N") ? 1 : 0) + .sum(); + + assertThat(operatingDays).isEqualTo(3); // today, tomorrow, nextWeek + assertThat(nonOperatingDays).isGreaterThanOrEqualTo(0); + assertThat(operatingDays + nonOperatingDays).isEqualTo(operationCalendar.size()); // 전체 합계 일치 + + log.info("운행 캘린더 검증 완료: 전체 {} 일, 운행일 {} 일, 비운행일 {} 일", + operationCalendar.size(), operatingDays, nonOperatingDays); + } + + @DisplayName("검색 조건에 따른 열차를 조회한다.") + @TestFactory + List searchTrains() { + // given + Train train1 = trainTestHelper.createKTX(); + Train train2 = trainTestHelper.createCustomKTX(2, 1); + Train train3 = trainTestHelper.createCustomKTX(3, 1); + + LocalDate futureDate = LocalDate.now().plusDays(1); + + // 오전, 오후, 저녁 시간대 열차 생성 + createTrainSchedule(train1, futureDate, "KTX 001", LocalTime.of(8, 0), LocalTime.of(11, 0)); // 오전 + createTrainSchedule(train2, futureDate, "KTX 003", LocalTime.of(14, 0), LocalTime.of(17, 0)); // 오후 + createTrainSchedule(train3, futureDate, "KTX 005", LocalTime.of(19, 0), LocalTime.of(22, 0)); // 저녁 + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 100000); + + // 검색 시나리오 정의 + record SearchScenario( + String description, + String departureHour, + int expectedCount, + java.util.function.Predicate> validator + ) { + } + + List scenarios = List.of( + new SearchScenario( + "전체 열차 조회 (06시 이후)", + "06", + 3, + trains -> trains.size() == 3 && + trains.stream().allMatch(train -> train.departureTime().isAfter(LocalTime.of(6, 0))) + ), + new SearchScenario( + "오후 이후 열차 조회 (13시 이후)", + "13", + 2, + trains -> trains.size() == 2 && + trains.stream().allMatch(train -> train.departureTime().isAfter(LocalTime.of(13, 0))) + ), + new SearchScenario( + "저녁 이후 열차 조회 (18시 이후)", + "18", + 1, + trains -> trains.size() == 1 && + trains.get(0).departureTime().isAfter(LocalTime.of(18, 0)) + ), + new SearchScenario( + "심야 시간 조회 (23시 이후)", + "23", + 0, + trains -> trains.isEmpty() + ) + ); + + // DynamicTest 생성 + return scenarios.stream() + .map(scenario -> DynamicTest.dynamicTest( + scenario.description, + () -> { + // given + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), futureDate, 2, scenario.departureHour + ); + Pageable pageable = PageRequest.of(0, 20); + + // when + TrainSearchSlicePageResponse response = trainSearchApplicationService.searchTrains(request, + pageable); + + // then + assertThat(response.content()).hasSize(scenario.expectedCount); + assertThat(scenario.validator.test(response.content())).isTrue(); + + // 페이징 정보 기본 검증 + assertThat(response.currentPage()).isEqualTo(0); + assertThat(response.first()).isTrue(); + assertThat(response.numberOfElements()).isEqualTo(scenario.expectedCount); + + log.info("검색 시나리오 완료 - {}: {}시 이후 → {}건 조회", + scenario.description, scenario.departureHour, response.content().size()); + } + )) + .toList(); + } + + @DisplayName("잔여 좌석이 있는 객차 목록을 조회할 수 있다.") + @Test + void getAvailableTrainCars() { + // given + Train train = trainTestHelper.createCustomKTX(3, 2); + TrainScheduleWithStopStations scheduleWithStop = trainScheduleTestHelper.createSchedule(train); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + TrainCarListRequest request = new TrainCarListRequest( + scheduleWithStop.trainSchedule().getId(), + seoul.getId(), + busan.getId(), + 2 + ); + + // when + TrainCarListResponse response = trainSearchApplicationService.getAvailableTrainCars(request); + + // then + assertThat(response.trainClassificationCode()).isEqualTo("KTX"); + assertThat(response.trainNumber()).isNotBlank(); + assertThat(response.totalCarCount()).isGreaterThan(0); + assertThat(response.totalCarCount()).isEqualTo(response.carInfos().size()); + + List carNumbers = response.carInfos().stream() + .map(car -> car.carNumber()) + .toList(); + assertThat(carNumbers).contains(response.recommendedCarNumber()); + + log.info("객차 목록 조회 완료: 열차 = {}-{}, 추천객차 = {}", + response.trainClassificationCode(), response.trainNumber(), response.recommendedCarNumber()); + } + + // TODO : 객차 타입별 객차수 다채롭게 두어 테스트 필요 + @DisplayName("승객 수에 따라 추천에 적합한 객차(잔여 좌석수 > 승객 수)가 있다면 중간 객차를, 없으면 첫 번째 객차를 추천한다.") + @TestFactory + Collection getAvailableTrainCars_recommendationLogicScenarios() { + // given + Train train = trainTestHelper.createCustomKTX(6, 2); + TrainScheduleWithStopStations scheduleWithStops = trainScheduleTestHelper.createSchedule(train); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + record RecommendationScenario( + String description, + int passengerCount, + String expectedBehavior, + Predicate validator + ) { + } + + List scenarios = List.of( + new RecommendationScenario( + "일반적인 승객 수 - 수용 가능한 객차 추천", + 2, + "승객 수를 수용할 수 있는 객차 중에서 선택", + response -> { + // 추천 객차가 승객 수를 수용할 수 있는지 + TrainCarInfo recommendedCar = response.carInfos().stream() + .filter(car -> car.carNumber().equals(response.recommendedCarNumber())) + .findFirst() + .orElse(null); + + return recommendedCar != null && recommendedCar.remainingSeats() >= 2; + } + ), + /*new RecommendationScenario( + "많은 승객 수 - 최대 수용 가능한 객차 우선", + 9, + "가장 많은 좌석을 가진 객차 선택 (일반실 우선)", + response -> { + TrainCarInfo recommendedCar = response.carInfos().stream() + .filter(car -> car.carNumber().equals(response.recommendedCarNumber())) + .findFirst() + .orElse(null); + + if (recommendedCar == null) + return false; + + // 추천 객차가 요청 승객 수를 수용할 수 있거나, 수용 불가능한 경우 가장 큰 객차여야 함 + boolean canAccommodate = recommendedCar.remainingSeats() >= 9; + boolean isLargestCar = response.carInfos().stream() + .allMatch(car -> car.remainingSeats() <= recommendedCar.remainingSeats()); + + return canAccommodate || isLargestCar; + } + ),*/ + new RecommendationScenario( + "수용 불가능한 승객 수 - fallback 로직", + 20, + "모든 객차가 수용 불가능할 때 첫 번째 객차 또는 가장 큰 객차 선택", + response -> { + TrainCarInfo recommendedCar = response.carInfos().stream() + .filter(car -> car.carNumber().equals(response.recommendedCarNumber())) + .findFirst() + .orElse(null); + + if (recommendedCar == null) + return false; + + // 모든 객차가 20명을 수용할 수 없으므로 fallback 로직 적용 + boolean isFirstCar = response.carInfos().get(0).carNumber().equals(response.recommendedCarNumber()); + boolean isLargestCar = response.carInfos().stream() + .allMatch(car -> car.remainingSeats() <= recommendedCar.remainingSeats()); + + return isFirstCar || isLargestCar; + } + ) + ); + + return scenarios.stream() + .map(scenario -> DynamicTest.dynamicTest( + scenario.description + " (승객 " + scenario.passengerCount + "명)", + () -> { + // given + TrainCarListRequest request = new TrainCarListRequest( + scheduleWithStops.trainSchedule().getId(), + seoul.getId(), + busan.getId(), + scenario.passengerCount + ); + + // when + TrainCarListResponse response = trainSearchApplicationService.getAvailableTrainCars(request); + + // then + assertThat(response.carInfos()).isNotEmpty(); + assertThat(response.recommendedCarNumber()).isNotBlank(); + assertThat(response.totalCarCount()).isEqualTo(2); // 일반실 1개 + 특실 1개 + + // 추천 객차가 실제 객차 목록에 포함되어 있는지 + List availableCarNumbers = response.carInfos().stream() + .map(TrainCarInfo::carNumber) + .toList(); + assertThat(availableCarNumbers).contains(response.recommendedCarNumber()); + + // 각 객차 정보 상세 검증 + response.carInfos().forEach(carInfo -> { + assertThat(carInfo.carNumber()).isNotBlank(); + assertThat(carInfo.carType()).isIn(CarType.STANDARD, CarType.FIRST_CLASS); + assertThat(carInfo.totalSeats()).isGreaterThan(0); + assertThat(carInfo.remainingSeats()).isGreaterThanOrEqualTo(0); + assertThat(carInfo.remainingSeats()).isLessThanOrEqualTo(carInfo.totalSeats()); + }); + + // 객차 타입별 분포 검증 + // TODO : 객차 타입별 객차수 다채롭게 두어 테스트 필요 + long standardCars = response.carInfos().stream() + .mapToLong(car -> car.carType() == CarType.STANDARD ? 1 : 0) + .sum(); + long firstClassCars = response.carInfos().stream() + .mapToLong(car -> car.carType() == CarType.FIRST_CLASS ? 1 : 0) + .sum(); + + assertThat(standardCars + firstClassCars).isEqualTo(response.totalCarCount()); + assertThat(standardCars + firstClassCars).isEqualTo(response.carInfos().size()); + + // 시나리오별 비즈니스 로직 검증 + assertThat(scenario.validator.test(response)) + .as("시나리오 '%s'의 추천 로직이 올바르게 작동해야 합니다. 추천객차: %s, 기대동작: %s", + scenario.description, response.recommendedCarNumber(), scenario.expectedBehavior) + .isTrue(); + + TrainCarInfo recommendedCar = response.carInfos().stream() + .filter(car -> car.carNumber().equals(response.recommendedCarNumber())) + .findFirst() + .orElseThrow(); + + log.info("추천 로직 검증 완료 - {}: 승객{}명 → 객차{} (잔여{}석), 총 {}개 객차 중", + scenario.description, scenario.passengerCount, + response.recommendedCarNumber(), recommendedCar.remainingSeats(), + response.carInfos().size()); + } + )) + .toList(); + } + + @DisplayName("좌석 상세 조회") + @Test + void getTrainCarSeatDetail_delegatesToSeatQueryService() { + // given + Train train = trainTestHelper.createKTX(); + TrainScheduleWithStopStations scheduleWithStops = trainScheduleTestHelper.createSchedule(train); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + List trainCars = trainCarRepository.findAllByTrainId(train.getId()); + TrainCar firstCar = trainCars.get(0); + + TrainCarSeatDetailRequest request = new TrainCarSeatDetailRequest( + firstCar.getId(), scheduleWithStops.trainSchedule().getId(), + seoul.getId(), busan.getId() + ); + + // when + TrainCarSeatDetailResponse response = trainSearchApplicationService.getTrainCarSeatDetail(request); + + // then + assertThat(response.carNumber()).isEqualTo(Integer.valueOf(firstCar.getCarNumber()).toString()); + assertThat(response.carType()).isEqualTo(firstCar.getCarType()); + assertThat(response.totalSeatCount()).isEqualTo(firstCar.getTotalSeats()); + assertThat(response.remainingSeatCount()).isGreaterThanOrEqualTo(0) + .isLessThanOrEqualTo(firstCar.getTotalSeats()); + + log.info("좌석 상세 조회 완료: 객차={}, 좌석타입={}", + response.carNumber(), response.carType()); + } + + @DisplayName("전체 플로우 테스트 - 검색 -> 객차선택 -> 좌석조회 연동") + @Test + void fullSearchFlow_integrationTest() { + // given + Train train = trainTestHelper.createCustomKTX(2, 1); + LocalDate futureDate = LocalDate.now().plusDays(1); + createTrainSchedule(train, futureDate, "KTX 001", LocalTime.of(8, 0), LocalTime.of(11, 0)); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 100000); + + // 1: 열차 검색 + TrainSearchRequest searchRequest = new TrainSearchRequest( + seoul.getId(), busan.getId(), futureDate, 2, "00" + ); + Pageable pageable = PageRequest.of(0, 20); + + TrainSearchSlicePageResponse searchResponse = trainSearchApplicationService.searchTrains(searchRequest, + pageable); + + // 2: 검색된 열차의 객차 조회 + TrainCarListRequest carRequest = new TrainCarListRequest( + searchResponse.content().get(0).trainScheduleId(), + seoul.getId(), busan.getId(), 2 + ); + + TrainCarListResponse carResponse = trainSearchApplicationService.getAvailableTrainCars(carRequest); + + // 3: 선택된 객차의 좌석 상세 조회 + String selectedCarNumber = carResponse.recommendedCarNumber(); + List trainCars = trainCarRepository.findAllByTrainId(train.getId()); + TrainCar selectedCar = trainCars.stream() + .filter(car -> String.format("%04d", car.getCarNumber()).equals(selectedCarNumber)) + .findFirst() + .orElseThrow(); + + TrainCarSeatDetailRequest seatRequest = new TrainCarSeatDetailRequest( + selectedCar.getId(), searchResponse.content().get(0).trainScheduleId(), + seoul.getId(), busan.getId() + ); + + TrainCarSeatDetailResponse seatResponse = trainSearchApplicationService.getTrainCarSeatDetail(seatRequest); + + // then + // === Step 1: 열차 검색 결과 검증 === + assertThat(searchResponse.content()).hasSize(1); + TrainSearchResponse searchResult = searchResponse.content().get(0); + + // 기본 열차 정보 검증 + assertThat(searchResult.trainScheduleId()).isNotNull(); + assertThat(searchResult.trainNumber()).isNotBlank(); + assertThat(searchResult.trainName()).isEqualTo("KTX"); + assertThat(searchResult.departureStationName()).isEqualTo("서울"); + assertThat(searchResult.arrivalStationName()).isEqualTo("부산"); + assertThat(searchResult.departureTime()).isEqualTo(LocalTime.of(8, 0)); + assertThat(searchResult.arrivalTime()).isEqualTo(LocalTime.of(11, 0)); + assertThat(searchResult.travelTime()).isEqualTo(Duration.ofHours(3)); + + // 좌석 정보 검증 + assertThat(searchResult.standardSeat()).isNotNull(); + assertThat(searchResult.firstClassSeat()).isNotNull(); + assertThat(searchResult.standardSeat().fare()).isEqualTo(50000); // 일반실 요금 + assertThat(searchResult.firstClassSeat().fare()).isEqualTo(100000); // 특실 요금 + assertThat(searchResult.standardSeat().remainingSeats()).isGreaterThanOrEqualTo(0); + assertThat(searchResult.firstClassSeat().remainingSeats()).isGreaterThanOrEqualTo(0); + assertThat(searchResult.standardSeat().canReserve()).isNotNull(); + assertThat(searchResult.firstClassSeat().canReserve()).isNotNull(); + + // === Step 2: 객차 조회 결과 검증 === + assertThat(carResponse.trainScheduleId()).isEqualTo(searchResult.trainScheduleId()); + assertThat(carResponse.trainClassificationCode()).isEqualTo("KTX"); + assertThat(carResponse.trainNumber()).isNotBlank(); + assertThat(carResponse.recommendedCarNumber()).isNotBlank(); + assertThat(carResponse.totalCarCount()).isEqualTo(2); // 일반실 1개 + 특실 1개 + assertThat(carResponse.carInfos()).hasSize(2); + + // 추천 객차가 실제 객차 목록에 존재하는지 검증 + List availableCarNumbers = carResponse.carInfos().stream() + .map(TrainCarInfo::carNumber) + .toList(); + assertThat(availableCarNumbers).contains(carResponse.recommendedCarNumber()); + + // 각 객차 정보 검증 + carResponse.carInfos().forEach(carInfo -> { + assertThat(carInfo.carNumber()).isNotBlank(); + assertThat(carInfo.carType()).isIn(CarType.STANDARD, CarType.FIRST_CLASS); + assertThat(carInfo.totalSeats()).isGreaterThan(0); + assertThat(carInfo.remainingSeats()).isGreaterThanOrEqualTo(0); + assertThat(carInfo.remainingSeats()).isLessThanOrEqualTo(carInfo.totalSeats()); + }); + + // === Step 3: 좌석 상세 조회 결과 검증 === + assertThat(String.format("%04d", Integer.parseInt(seatResponse.carNumber()))).isEqualTo(selectedCarNumber); + assertThat(seatResponse.carType()).isIn(CarType.STANDARD, CarType.FIRST_CLASS); + assertThat(seatResponse.totalSeatCount()).isGreaterThan(0); + assertThat(seatResponse.remainingSeatCount()).isGreaterThanOrEqualTo(0); + assertThat(seatResponse.remainingSeatCount()).isLessThanOrEqualTo(seatResponse.totalSeatCount()); + assertThat(seatResponse.layoutType()).isIn(2, 3); // 2+2 또는 2+1 배치 + assertThat(seatResponse.seatList()).isNotEmpty(); + + // 좌석 상세 정보 검증 + seatResponse.seatList().forEach(seat -> { + assertThat(seat.seatId()).isNotNull(); + assertThat(seat.seatNumber()).isNotBlank(); + assertThat(seat.seatDirection()).isNotNull(); + assertThat(seat.seatType()).isNotNull(); + // available은 true/false 모두 가능 + }); + + // 좌석 수 일관성 검증 + int totalSeatsInList = seatResponse.seatList().size(); + int availableSeatsInList = (int)seatResponse.seatList().stream() + .mapToLong(seat -> seat.isAvailable() ? 1 : 0) + .sum(); + + assertThat(totalSeatsInList).isEqualTo(seatResponse.totalSeatCount()); + assertThat(availableSeatsInList).isEqualTo(seatResponse.remainingSeatCount()); + + // === 플로우 간 데이터 일관성 검증 === + + // 1. 검색 결과의 trainScheduleId가 모든 단계에서 일관되게 사용되는지 + assertThat(carRequest.trainScheduleId()).isEqualTo(searchResult.trainScheduleId()); + assertThat(seatRequest.trainScheduleId()).isEqualTo(searchResult.trainScheduleId()); + + // 2. 선택된 객차가 실제 검색된 열차의 객차인지 + assertThat(selectedCar.getTrain().getId()).isEqualTo(train.getId()); + + // 3. 객차 조회와 좌석 조회 간 데이터 일관성 + TrainCarInfo selectedCarInfo = carResponse.carInfos().stream() + .filter(car -> car.carNumber().equals(selectedCarNumber) || + String.format("%04d", Integer.parseInt(car.carNumber())).equals(selectedCarNumber)) + .findFirst() + .orElseThrow(); + + assertThat(seatResponse.carType()).isEqualTo(selectedCarInfo.carType()); + assertThat(seatResponse.totalSeatCount()).isEqualTo(selectedCarInfo.totalSeats()); + assertThat(seatResponse.remainingSeatCount()).isEqualTo(selectedCarInfo.remainingSeats()); + + // 4. 요청한 승객 수가 추천 객차에서 예약 가능한지 검증 + assertThat(selectedCarInfo.remainingSeats()).isGreaterThanOrEqualTo(2); // 요청한 승객 수 + + // === 비즈니스 로직 검증 === + + // 1. 추천 객차가 승객 수를 수용할 수 있는지 + assertThat(carResponse.carInfos().stream() + .anyMatch(car -> car.carNumber().equals(carResponse.recommendedCarNumber()) && + car.remainingSeats() >= 2)) + .as("추천 객차는 요청한 승객 수(%d명)를 수용할 수 있어야 합니다", 2) + .isTrue(); + + // 2. 요금 정보가 올바르게 설정되었는지 + long standardSeatCars = carResponse.carInfos().stream() + .mapToLong(car -> car.carType() == CarType.STANDARD ? 1 : 0) + .sum(); + long firstClassCars = carResponse.carInfos().stream() + .mapToLong(car -> car.carType() == CarType.FIRST_CLASS ? 1 : 0) + .sum(); + + assertThat(standardSeatCars).isEqualTo(1); // 일반실 1개 + assertThat(firstClassCars).isEqualTo(1); // 특실 1개 + + log.info("전체 플로우 테스트 완료: 검색{}건 → 객차{}개 → 좌석{}개, 추천객차={}, 잔여좌석={}", + searchResponse.content().size(), + carResponse.carInfos().size(), + seatResponse.seatList().size(), + carResponse.recommendedCarNumber(), + seatResponse.remainingSeatCount()); + } + + private void createTrainSchedule(Train train, LocalDate operationDate, String scheduleName, + LocalTime departureTime, LocalTime arrivalTime) { + trainScheduleTestHelper.createCustomSchedule() + .scheduleName(scheduleName) + .operationDate(operationDate) + .train(train) + .addStop("서울", null, departureTime) + .addStop("부산", arrivalTime, null) + .build(); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/TrainSearchServiceOverlapReservationTest.java b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceOverlapReservationTest.java new file mode 100644 index 00000000..54efaf4b --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceOverlapReservationTest.java @@ -0,0 +1,176 @@ +package com.sudo.railo.train.application; + +import static com.sudo.railo.support.helper.TrainScheduleTestHelper.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; + +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.ReservationTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.TrainSearchResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; +import com.sudo.railo.train.domain.ScheduleStop; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.type.CarType; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +@DisplayName("다구간 경로에서 예약 겹침(overlap) 로직 검증") +public class TrainSearchServiceOverlapReservationTest { + + @Autowired + private TrainSearchService trainSearchService; + + @Autowired + private ReservationTestHelper reservationTestHelper; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private MemberRepository memberRepository; + + record OverlapScenario( + String description, + String existingReservationRoute, // ex. "서울-대전" + String searchRoute, // ex. "대전-부산" + List reservedSeatsPerSegment, // ex. 15 + int expectedRemainingSeats // ex. 120 + ) { + @Override + public String toString() { + return description; + } + } + + static Stream overlapScenarios() { + return Stream.of( + new OverlapScenario( + "기존 예약 : 서울→대전(15) / 검색 : 대전→부산 (비겹침)", + "서울-대전", "대전-부산", + List.of(15), 120 + ), + new OverlapScenario( + "기존 예약 : 서울→부산(20) / 검색 : 대전→대구 (완전 겹침)", + "서울-부산", "대전-대구", + List.of(20), 100 + ), + new OverlapScenario( + "기존 예약 : 대전→부산(25) / 검색 : 서울→대구 (부분 겹침)", + "대전-부산", "서울-대구", + List.of(25), 95 + ), + new OverlapScenario( + "기존 예약 : 서울→대전(10) + 대구→부산(20) / 검색 : 대전→대구 (교집합 없음=비겹침)", + "서울-대전+대구-부산", "대전-대구", + List.of(15, 20), 120 + ) + ); + } + + @DisplayName("열차 조회 시 다구간 경로에서 기존의 ‘겹침 구간’ 예약만 잔여 좌석에서 차감된다.") + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("overlapScenarios") + void shouldCountOnlyOverlappingReservations(OverlapScenario s) { + // given + Train train = trainTestHelper.createRealisticTrain(3, 2, 10, 6); // 일반실 : 120석, 특실 : 36석 + LocalDate searchDate = LocalDate.now().plusDays(1); + + // 요금 설정 + trainScheduleTestHelper.createOrUpdateStationFare("서울", "대전", 25000, 40000); + trainScheduleTestHelper.createOrUpdateStationFare("대전", "대구", 20000, 32000); + trainScheduleTestHelper.createOrUpdateStationFare("대구", "부산", 15000, 24000); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "대구", 40000, 64000); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + trainScheduleTestHelper.createOrUpdateStationFare("대전", "부산", 30000, 48000); + + TrainScheduleWithStopStations schedule = trainScheduleTestHelper.createCustomSchedule() + .scheduleName("KTX 복합구간") + .operationDate(searchDate) + .train(train) + .addStop("서울", null, LocalTime.of(8, 0)) + .addStop("대전", LocalTime.of(9, 0), LocalTime.of(9, 5)) + .addStop("대구", LocalTime.of(10, 30), LocalTime.of(10, 35)) + .addStop("부산", LocalTime.of(12, 0), null) + .build(); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station daejeon = trainScheduleTestHelper.getOrCreateStation("대전"); + Station daegu = trainScheduleTestHelper.getOrCreateStation("대구"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + ScheduleStop seoulStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "서울"); + ScheduleStop daejeonStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "대전"); + ScheduleStop daeguStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "대구"); + ScheduleStop busanStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "부산"); + + Member member = memberRepository.save(MemberFixture.createStandardMember()); + + // 예약 생성 + String[] segments = s.existingReservationRoute().split("\\+"); + for (int i = 0; i < segments.length; i++) { + String segment = segments[i]; + String[] stops = segment.split("-"); + ScheduleStop departureStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, stops[0]); + ScheduleStop arrivalStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, stops[1]); + + int seatsToReserve = s.reservedSeatsPerSegment().get(i); + List seatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, seatsToReserve); + + reservationTestHelper.createReservationWithSeatIds( + member, schedule, departureStop, arrivalStop, seatIds, PassengerType.ADULT + ); + } + + // when + String[] searchNodes = s.searchRoute().split("-"); + Station searchDepartureStation = switch (searchNodes[0]) { + case "서울" -> seoul; + case "대전" -> daejeon; + case "대구" -> daegu; + default -> busan; + }; + Station searchArrivalStation = switch (searchNodes[1]) { + case "대전" -> daejeon; + case "대구" -> daegu; + case "부산" -> busan; + default -> seoul; + }; + + TrainSearchRequest request = new TrainSearchRequest( + searchDepartureStation.getId(), + searchArrivalStation.getId(), + searchDate, + 10, + "00" + ); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + assertThat(response.content()).hasSize(1); + TrainSearchResponse result = response.content().get(0); + assertThat(result.standardSeat().remainingSeats()).isEqualTo(s.expectedRemainingSeats()); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/TrainSearchServiceSeatStatusTest.java b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceSeatStatusTest.java new file mode 100644 index 00000000..e81d0d54 --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceSeatStatusTest.java @@ -0,0 +1,398 @@ +package com.sudo.railo.train.application; + +import static com.sudo.railo.support.helper.TrainScheduleTestHelper.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.ReservationTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.TrainSearchResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; +import com.sudo.railo.train.domain.ScheduleStop; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.type.CarType; +import com.sudo.railo.train.domain.type.SeatAvailabilityStatus; +import com.sudo.railo.train.infrastructure.SeatRepository; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +public class TrainSearchServiceSeatStatusTest { + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TrainSearchService trainSearchService; + + @Autowired + private ReservationTestHelper reservationTestHelper; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private SeatRepository seatRepository; + + record SeatStatusScenario( + String description, + int standardCars, int firstClassCars, int standardRows, int firstClassRows, + int reservedStandardSeats, int reservedFirstClassSeats, int reservedStandingSeats, + int passengerCount, + SeatAvailabilityStatus expectedStandardStatus, + SeatAvailabilityStatus expectedFirstClassStatus, + boolean expectedStandardCanReserve, + boolean expectedFirstClassCanReserve, + boolean expectedHasStanding + ) { + @Override + public String toString() { + return description; + } + } + + static Stream seatStatusScenarios() { + return Stream.of( + new SeatStatusScenario( + "1. 여유 상황 - 일반실/특실 모두 충분", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 + 5, 2, 0, // 일반실 5석, 특실 2석 예약 → 75석, 22석 잔여 + 4, // 4명 요청 + // 일반실: 75/80 = 93.7% > 25% → AVAILABLE + // 특실: 22/24 = 91.6% > 25% → AVAILABLE + SeatAvailabilityStatus.AVAILABLE, SeatAvailabilityStatus.AVAILABLE, + true, true, false + ), + new SeatStatusScenario( + "2. 제한적 상황 - 일반실 여유 부족하지만 예약 가능", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 + 65, 5, 0, // 일반실 65석, 특실 5석 예약 → 15석, 19석 잔여 + 4, // 4명 요청 + // 일반실: 15/80 = 18.75% < 25% → LIMITED + // 특실: 19/24 = 79.1% > 25% → AVAILABLE + SeatAvailabilityStatus.LIMITED, SeatAvailabilityStatus.AVAILABLE, + true, true, false + ), + new SeatStatusScenario( + "3. 일반실 부족하지만 입석 가능 상황", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 → 총 104석 → 입석 15석(104*0.15) + 78, 0, 0, // 일반실 78석 예약 → 2석 잔여, 입석 0석 예약 → 15석 잔여 + 5, // 5명 요청 (일반실 2석 < 5명이므로 예약 불가하지만 입석 15석으로 수용 가능) + // 일반실: 2 < 5명 요청 → STANDING_ONLY (입석 가능하므로) + // 특실: 24/24 = 100% > 25% → AVAILABLE + SeatAvailabilityStatus.STANDING_ONLY, SeatAvailabilityStatus.AVAILABLE, + false, true, true + ), + new SeatStatusScenario( + "4. 일반실 매진하지만 입석 가능 상황", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 → 총 104석 → 입석 15석 + 80, 0, 0, // 일반실 모두 예약, 입석 0석 예약 → 15석 잔여 + 3, // 3명 요청 (입석 15석으로 수용 가능) + // 일반실: 0석 잔여, 입석 가능 → STANDING_ONLY + // 특실: 24/24 = 100% > 25% → AVAILABLE + SeatAvailabilityStatus.STANDING_ONLY, SeatAvailabilityStatus.AVAILABLE, + false, true, true + ), + new SeatStatusScenario( + "5. 일반실 매진 + 입석 부족 상황", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 → 총 104석 → 입석 15석 + 80, 0, 13, // 일반실 매진, 입석 13석 예약 → 입석 2석 잔여 + 5, // 5명 요청 (입석 2석으로 수용 불가) + // 일반실: 0석 잔여, 입석으로도 수용 불가 → SOLD_OUT + // 특실: 24/24 = 100% > 25% → AVAILABLE + SeatAvailabilityStatus.SOLD_OUT, SeatAvailabilityStatus.AVAILABLE, + false, true, false + ), + new SeatStatusScenario( + "6. 완전 매진 상황 - 모든 좌석 + 입석 매진", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 → 총 104석 → 입석 15석 + 80, 20, 15, // 일반실 매진, 특실 20석 예약 → 4석 잔여, 입석 매진 + 6, // 6명 요청 (특실 4석으로 수용 불가, 입석 매진) + // 일반실: 0석 잔여, 입석 매진 → SOLD_OUT + // 특실: 4 < 6명 요청 → INSUFFICIENT (입석 불가하므로) + SeatAvailabilityStatus.SOLD_OUT, SeatAvailabilityStatus.INSUFFICIENT, + false, false, false + ) + ); + } + + @DisplayName("다양한 기존 예약 상황에 따라 적절한 좌석 상태와 입석 정보를 표시한다.") + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("seatStatusScenarios") + void shouldDisplayCorrectSeatAndStandingInfoForAllScenarios(SeatStatusScenario scenario) { + // given + LocalDate searchDate = LocalDate.now().plusDays(1); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + Member member = memberRepository.save(MemberFixture.createStandardMember()); + + Train train = trainTestHelper.createRealisticTrain( + scenario.standardCars, scenario.firstClassCars, + scenario.standardRows, scenario.firstClassRows); + + TrainScheduleWithStopStations schedule = trainScheduleTestHelper.createCustomSchedule() + .scheduleName("KTX TEST") + .operationDate(searchDate) + .train(train) + .addStop("서울", null, LocalTime.of(10, 0)) + .addStop("부산", LocalTime.of(13, 0), null) + .build(); + + ScheduleStop departureStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "서울"); + ScheduleStop arrivalStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "부산"); + + if (scenario.reservedStandardSeats > 0) { + List seatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, scenario.reservedStandardSeats); + reservationTestHelper.createReservationWithSeatIds(member, schedule, departureStop, arrivalStop, seatIds, + PassengerType.ADULT); + } + if (scenario.reservedFirstClassSeats > 0) { + List seatIds = trainTestHelper.getSeatIds(train, CarType.FIRST_CLASS, + scenario.reservedFirstClassSeats); + reservationTestHelper.createReservationWithSeatIds(member, schedule, departureStop, arrivalStop, seatIds, + PassengerType.ADULT); + } + if (scenario.reservedStandingSeats > 0) { + reservationTestHelper.createStandingReservation(member, schedule, departureStop, arrivalStop, + scenario.reservedStandingSeats); + } + + // when + TrainSearchRequest request = new TrainSearchRequest(seoul.getId(), busan.getId(), searchDate, + scenario.passengerCount, "00"); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + assertThat(response.content()).hasSize(1); + TrainSearchResponse trainResult = response.content().get(0); + + assertThat(trainResult.standardSeat().status()).isEqualTo(scenario.expectedStandardStatus); + assertThat(trainResult.standardSeat().canReserve()).isEqualTo(scenario.expectedStandardCanReserve); + assertThat(trainResult.firstClassSeat().status()).isEqualTo(scenario.expectedFirstClassStatus); + assertThat(trainResult.firstClassSeat().canReserve()).isEqualTo(scenario.expectedFirstClassCanReserve); + assertThat(trainResult.hasStandingInfo()).isEqualTo(scenario.expectedHasStanding); + + if (scenario.expectedHasStanding) { + assertThat(trainResult.standing()).isNotNull(); + assertThat(trainResult.standing().remainingStanding()).isGreaterThan(0); + assertThat(trainResult.standing().fare()).isEqualTo((int)(50000 * 0.85)); + assertThat(trainResult.standardSeat().status()).isEqualTo(SeatAvailabilityStatus.STANDING_ONLY); + assertThat(trainResult.standardSeat().displayText()).contains("입석"); + } else { + assertThat(trainResult.standing()).isNull(); + } + + int expectedStandardTotal = scenario.standardCars * scenario.standardRows * 4; + int expectedFirstClassTotal = scenario.firstClassCars * scenario.firstClassRows * 3; + int expectedStandardRemaining = expectedStandardTotal - scenario.reservedStandardSeats; + int expectedFirstClassRemaining = expectedFirstClassTotal - scenario.reservedFirstClassSeats; + + assertThat(trainResult.standardSeat().totalSeats()).isEqualTo(expectedStandardTotal); + assertThat(trainResult.standardSeat().remainingSeats()).isEqualTo(expectedStandardRemaining); + assertThat(trainResult.firstClassSeat().totalSeats()).isEqualTo(expectedFirstClassTotal); + assertThat(trainResult.firstClassSeat().remainingSeats()).isEqualTo(expectedFirstClassRemaining); + } + + /** + * 입석 테스트용 공통 데이터 + */ + private void setupStandingTestData() { + LocalDate searchDate = LocalDate.now().plusDays(1); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + Member member = memberRepository.save(MemberFixture.createStandardMember()); + + // 여유 열차와 매진 열차 생성 + Train availableTrain = trainTestHelper.createRealisticTrain(1, 1, 8, 6); // 일반실 32석, 특실 18석 + Train soldOutTrain = trainTestHelper.createRealisticTrain(1, 1, 8, 6); // 일반실 32석, 특실 18석 + + TrainScheduleWithStopStations availableSchedule = createTrainSchedule(availableTrain, searchDate, + "KTX 201", LocalTime.of(10, 0), LocalTime.of(13, 0), "서울", "부산"); + TrainScheduleWithStopStations soldOutSchedule = createTrainSchedule(soldOutTrain, searchDate, + "KTX 203", LocalTime.of(10, 10), LocalTime.of(13, 10), "서울", "부산"); + + ScheduleStop departureStop = trainScheduleTestHelper.getScheduleStopByStationName(soldOutSchedule, "서울"); + ScheduleStop arrivalStop = trainScheduleTestHelper.getScheduleStopByStationName(soldOutSchedule, "부산"); + + // 매진 열차의 일반실 모두 예약 (32석 모두) + List allStandardSeats = trainTestHelper.getSeatIds(soldOutTrain, CarType.STANDARD, 32); + reservationTestHelper.createReservationWithSeatIds(member, soldOutSchedule, departureStop, arrivalStop, + allStandardSeats, PassengerType.ADULT); + } + + @DisplayName("입석 정보 자동 제공: 일반실 여유 열차는 입석 정보를 제공하지 않고, 매진/부족 열차는 입석 정보를 제공한다.") + @Test + void shouldAutoProvideStandingInfoWhenStandardSoldOutOrInsufficient() { + // given + setupStandingTestData(); + LocalDate searchDate = LocalDate.now().plusDays(1); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + // when + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 2, "00"); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + assertThat(response.content()).hasSize(2); + + TrainSearchResponse availableTrain = findTrainByTime(response.content(), LocalTime.of(10, 0)); + TrainSearchResponse soldOutTrain = findTrainByTime(response.content(), LocalTime.of(10, 10)); + + assertThat(availableTrain.hasStandingInfo()).isFalse(); + assertThat(soldOutTrain.hasStandingInfo()).isTrue(); + + log.info("여유 열차 - {}: 일반실 {}석 잔여, 입석 정보 없음", + availableTrain.trainNumber(), availableTrain.standardSeat().remainingSeats()); + log.info("매진 열차 - {}: 일반실 매진, 입석 정보 제공", + soldOutTrain.trainNumber()); + } + + @DisplayName("입석 요금 할인: 입석 요금은 일반실 대비 15% 할인을 적용한다. (85% 요금)") + @Test + void shouldApply15PercentDiscountOnStandingFare() { + // given + setupStandingTestData(); + LocalDate searchDate = LocalDate.now().plusDays(1); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + // when + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 3, "00"); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + List standingTrains = response.content().stream() + .filter(TrainSearchResponse::hasStandingInfo) + .toList(); + + assertThat(standingTrains).hasSize(1); + + TrainSearchResponse standingTrain = standingTrains.get(0); + int standingFare = standingTrain.standing().fare(); + int standardFare = standingTrain.standardSeat().fare(); + int expectedFare = (int)(standardFare * 0.85); + double actualDiscount = (1.0 - (double)standingFare / standardFare) * 100; + + assertThat(standingFare).isEqualTo(expectedFare); + assertThat(actualDiscount).isEqualTo(15.0, within(0.1)); // ±0.1 범위 오차 허용 + + log.info("입석 요금 할인 검증 - 일반실: {}원, 입석: {}원 ({}% 할인)", + standardFare, standingFare, String.format("%.1f", actualDiscount)); + } + + @DisplayName("입석 수용력: 열차는 총 좌석의 15%를 입석 인원으로 수용할 수 있다. (32+18=50석 → 7석)") + @Test + void shouldCalculateStandingCapacityAsFifteenPercent() { + // given + setupStandingTestData(); + LocalDate searchDate = LocalDate.now().plusDays(1); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + // when + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 4, "00"); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + List standingTrains = response.content().stream() + .filter(TrainSearchResponse::hasStandingInfo) + .toList(); + + assertThat(standingTrains).hasSize(1); + + TrainSearchResponse standingTrain = standingTrains.get(0); + int totalSeats = standingTrain.standardSeat().totalSeats() + standingTrain.firstClassSeat().totalSeats(); + int expectedCapacity = (int)(totalSeats * 0.15); // 50 * 0.15 = 7.5 → 7석 + + assertThat(totalSeats).isEqualTo(50); // 32 + 18 + assertThat(expectedCapacity).isEqualTo(7); + assertThat(standingTrain.standing().maxStanding()).isEqualTo(expectedCapacity); + assertThat(standingTrain.standing().remainingStanding()).isLessThanOrEqualTo(expectedCapacity); + + log.info("입석 수용력 검증 - 총 좌석: {}석, 입석 용량: {}석, 잔여 입석: {}석", + totalSeats, expectedCapacity, standingTrain.standing().remainingStanding()); + } + + @DisplayName("입석 상태 표시: 일반실 매진, 입석 예약 가능 시 좌석 상태는 STANDING_ONLY로 표시되고, 입석 정보가 노출된다.") + @Test + void shouldDisplayStandingOnlyStatusAndStandingInfoWhenStandardSoldOutAndCanReserveStanding() { + // given + setupStandingTestData(); + LocalDate searchDate = LocalDate.now().plusDays(1); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + // when + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 2, "00"); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + TrainSearchResponse soldOutTrain = findTrainByTime(response.content(), LocalTime.of(10, 10)); + + assertThat(soldOutTrain.hasStandingInfo()).isTrue(); + assertThat(soldOutTrain.standardSeat().status()).isEqualTo(SeatAvailabilityStatus.STANDING_ONLY); + assertThat(soldOutTrain.standardSeat().canReserve()).isFalse(); + assertThat(soldOutTrain.standardSeat().displayText()).contains("일반실(입석)"); + assertThat(soldOutTrain.standing().remainingStanding()).isGreaterThan(0); + + log.info("입석 상태 표시 검증 - 일반실 상태: {}, 표시 텍스트: {}", + soldOutTrain.standardSeat().status(), soldOutTrain.standardSeat().displayText()); + } + + /** + * 열차 스케줄 생성 헬퍼 + */ + private TrainScheduleWithStopStations createTrainSchedule(Train train, + LocalDate operationDate, + String scheduleName, LocalTime departureTime, LocalTime arrivalTime, + String departureStation, String arrivalStation) { + return trainScheduleTestHelper.createCustomSchedule() + .scheduleName(scheduleName) + .operationDate(operationDate) + .train(train) + .addStop(departureStation, null, departureTime) + .addStop(arrivalStation, arrivalTime, null) + .build(); + } + + private TrainSearchResponse findTrainByTime(List trains, LocalTime time) { + return trains.stream() + .filter(train -> train.departureTime().equals(time)) + .findFirst() + .orElseThrow(() -> new AssertionError("시간 " + time + "에 해당하는 열차를 찾을 수 없습니다")); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/TrainSearchServiceTest.java b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceTest.java new file mode 100644 index 00000000..a843d69d --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceTest.java @@ -0,0 +1,319 @@ +package com.sudo.railo.train.application; + +import static com.sudo.railo.support.helper.TrainScheduleTestHelper.*; +import static com.sudo.railo.train.exception.TrainErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.helper.ReservationTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.TrainSearchResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.exception.TrainErrorCode; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +class TrainSearchServiceTest { + + @Autowired + private TrainSearchService trainSearchService; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private ReservationTestHelper reservationTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @DisplayName("열차 조회 시 지정한 출발 시간 이후에만 필터링하고, 결과를 시간순으로 정렬해서 반환한다") + @TestFactory + Collection shouldFilterByDepartureHourAndSortChronologically() { + // given + Train train1 = trainTestHelper.createRealisticTrain(1, 1, 8, 4); // 일반실 32석, 특실 12석 + Train train2 = trainTestHelper.createRealisticTrain(1, 1, 8, 4); + Train train3 = trainTestHelper.createRealisticTrain(1, 1, 8, 4); + + LocalDate searchDate = LocalDate.now().plusDays(1); + + // 요금 정보 생성 + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + + // 다양한 시간대의 열차 생성 (시간순 정렬 테스트를 위해) + createTrainSchedule(train1, searchDate, "KTX 001", LocalTime.of(6, 0), LocalTime.of(9, 15), "서울", "부산"); + createTrainSchedule(train2, searchDate, "KTX 003", LocalTime.of(12, 30), LocalTime.of(15, 45), "서울", "부산"); + createTrainSchedule(train3, searchDate, "KTX 005", LocalTime.of(18, 0), LocalTime.of(21, 15), "서울", "부산"); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + record TimeFilterScenario( + String description, + String departureHour, + int expectedTrainCount, + List expectedDepartureTimes + ) { + } + + List scenarios = List.of( + new TimeFilterScenario( + "전체 열차 조회 (0시 이후)", + "00", + 3, + List.of(LocalTime.of(6, 0), LocalTime.of(12, 30), LocalTime.of(18, 0)) + ), + new TimeFilterScenario( + "오전 중반 이후 열차 조회 (10시 이후)", + "10", + 2, + List.of(LocalTime.of(12, 30), LocalTime.of(18, 0)) + ), + new TimeFilterScenario( + "오후 이후 열차 조회 (14시 이후)", + "14", + 1, + List.of(LocalTime.of(18, 0)) + ), + new TimeFilterScenario( + "심야 시간 조회 (22시 이후)", + "22", + 0, + List.of() + ) + ); + + return scenarios.stream() + .map(scenario -> DynamicTest.dynamicTest( + scenario.description, + () -> { + // given + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 2, scenario.departureHour // 2명으로 고정 (수용력과 무관) + ); + Pageable pageable = PageRequest.of(0, 20); + + // when + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, pageable); + + // then - 기본 검증 + assertThat(response.content()).hasSize(scenario.expectedTrainCount); + assertThat(response.currentPage()).isEqualTo(0); + assertThat(response.first()).isTrue(); + + // 시간순 정렬 검증 + List actualDepartureTimes = response.content().stream() + .map(TrainSearchResponse::departureTime) + .toList(); + + assertThat(actualDepartureTimes) + .as("출발 시간이 시간순으로 정렬되어야 합니다.") + .isSorted() + .containsExactlyElementsOf(scenario.expectedDepartureTimes); + + // 각 열차 기본 정보 검증 + response.content().forEach(train -> { + assertThat(train.trainScheduleId()).isNotNull(); + assertThat(train.trainNumber()).isNotBlank(); + assertThat(train.trainName()).isEqualTo("KTX"); + assertThat(train.travelTime()).isPositive(); + assertThat(train.departureStationName()).isEqualTo("서울"); + assertThat(train.arrivalStationName()).isEqualTo("부산"); + }); + + log.info("시간순 필터링 시나리오 완료 - {}: {}시 이후 → {}건 조회, 출발시간: {}", + scenario.description, scenario.departureHour, response.content().size(), + actualDepartureTimes.stream().map(Object::toString).collect(Collectors.joining(", "))); + } + )) + .toList(); + } + + @DisplayName("기본 일정 조회 시 존재하지 않는 스케줄 ID 로 조회하면 상세 오류 코드와 메시지가 포함된 예외를 던진다") + @Test + void getTrainScheduleBasicInfo_throwsInformativeExceptionForNonExistentScheduleId() { + // given + Long nonExistentScheduleId = 999999L; + + // when & then + assertThatThrownBy(() -> trainSearchService.getTrainScheduleBasicInfo(nonExistentScheduleId)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(TRAIN_SCHEDULE_DETAIL_NOT_FOUND.getMessage()); + + log.info("존재하지 않는 스케줄 ID({}) 조회 예외 처리 완료", nonExistentScheduleId); + } + + @DisplayName("열차 조회 시 페이징이 올바르게 동작하고, 페이지 내*간 중복 없이 시간순 정렬을 유지한다") + @Test + void searchTrains_paginatesLargeResultsCorrectlyWithoutDuplicationAndMaintainsTimeOrdering() { + // given + LocalDate searchDate = LocalDate.now().plusDays(1); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + + // 다양한 크기와 시간대로 10개 열차 생성 + List trains = IntStream.range(0, 10) + .mapToObj(i -> { + int standardCars = 2 + (i % 3); // 2-4개 일반실 + int firstClassCars = 1 + (i % 2); // 1-2개 특실 + return trainTestHelper.createRealisticTrain(standardCars, firstClassCars, 10, 6); + }) + .toList(); + + for (int i = 0; i < trains.size(); i++) { + createTrainSchedule(trains.get(i), searchDate, + String.format("KTX %03d", i + 1), + LocalTime.of(6 + i, 0), // 6시부터 1시간 간격 + LocalTime.of(9 + i, 0), + "서울", "부산"); + } + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 1, "00" + ); + + // when + record PagingTest(int pageSize, int expectedFirstPageSize, int expectedSecondPageSize) { + } + + // 다양한 페이지 크기로 테스트 + List pagingTests = List.of( + new PagingTest(3, 3, 3), // 3개씩, 10개 → 3, 3, 3, 1 + new PagingTest(4, 4, 4), // 4개씩, 10개 → 4, 4, 2 + new PagingTest(7, 7, 3) // 7개씩, 10개 → 7, 3 + ); + + pagingTests.forEach(test -> { + // 첫 번째 페이지 + Pageable firstPage = PageRequest.of(0, test.pageSize); + TrainSearchSlicePageResponse firstResponse = trainSearchService.searchTrains(request, firstPage); + + // 두 번째 페이지 + Pageable secondPage = PageRequest.of(1, test.pageSize); + TrainSearchSlicePageResponse secondResponse = trainSearchService.searchTrains(request, secondPage); + + // then - 첫 번째 페이지 검증 + assertThat(firstResponse.content()).hasSize(test.expectedFirstPageSize); + assertThat(firstResponse.currentPage()).isEqualTo(0); + assertThat(firstResponse.first()).isTrue(); + assertThat(firstResponse.hasNext()).isTrue(); + + // then - 두 번째 페이지 검증 + assertThat(secondResponse.content()).hasSize(test.expectedSecondPageSize); + assertThat(secondResponse.currentPage()).isEqualTo(1); + assertThat(secondResponse.first()).isFalse(); + + // 페이지 간 데이터 중복 없음 검증 + Set firstPageTrainScheduleIds = firstResponse.content().stream() + .map(TrainSearchResponse::trainScheduleId) + .collect(Collectors.toSet()); + Set secondPageTrainScheduleIds = secondResponse.content().stream() + .map(TrainSearchResponse::trainScheduleId) + .collect(Collectors.toSet()); + + assertThat(firstPageTrainScheduleIds).doesNotContainAnyElementsOf(secondPageTrainScheduleIds); + + // 시간순 정렬 검증 (각 페이지 내에서) + assertThat(firstResponse.content()) + .extracting(TrainSearchResponse::departureTime) + .isSorted(); + assertThat(secondResponse.content()) + .extracting(TrainSearchResponse::departureTime) + .isSorted(); + + // 페이지 간 시간 순서 검증 (첫 페이지 마지막 < 둘째 페이지 첫번째) + if (!firstResponse.content().isEmpty() && !secondResponse.content().isEmpty()) { + LocalTime lastTimeFirstPage = firstResponse.content() + .get(firstResponse.content().size() - 1) + .departureTime(); + LocalTime firstTimeSecondPage = secondResponse.content().get(0).departureTime(); + assertThat(lastTimeFirstPage).isBefore(firstTimeSecondPage); + } + + log.info("페이징 테스트 완료 (크기 {}): 1페이지 {}건, 2페이지 {}건", + test.pageSize, firstResponse.content().size(), secondResponse.content().size()); + }); + } + + @DisplayName("조회하는 구간의 요금 정보가 없으면 STATION_FARE_NOT_FOUND 예외를 던진다") + @Test + void shouldThrowStationFareNotFoundWhenFareIsMissing() { + // given + LocalDate searchDate = LocalDate.now().plusDays(1); + + // 역 생성 + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + // 서울→부산 요금 등록 + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + + Train train = trainTestHelper.createRealisticTrain(1, 1, 10, 6); + // 요금 없는 방향으로 부산→서울 스케줄 생성 + createTrainSchedule(train, searchDate, "KTX Rev", + LocalTime.of(15, 0), LocalTime.of(18, 0), + "부산", "서울" + ); + + // when & then + TrainSearchRequest request = new TrainSearchRequest( + busan.getId(), // 출발: 요금 미등록 방향 + seoul.getId(), // 도착 + searchDate, + 1, + "15" // 15시 이후 + ); + + assertThatThrownBy(() -> + trainSearchService.searchTrains(request, PageRequest.of(0, 10)) + ) + .isInstanceOf(BusinessException.class) + .satisfies(ex -> { + BusinessException be = (BusinessException)ex; + assertThat(be.getErrorCode()) + .isEqualTo(TrainErrorCode.STATION_FARE_NOT_FOUND); + assertThat(be.getMessage()) + .contains(TrainErrorCode.STATION_FARE_NOT_FOUND.getMessage()); + }); + } + + /** + * 열차 스케줄 생성 헬퍼 + */ + private TrainScheduleWithStopStations createTrainSchedule(Train train, LocalDate operationDate, + String scheduleName, LocalTime departureTime, LocalTime arrivalTime, + String departureStation, String arrivalStation) { + return trainScheduleTestHelper.createCustomSchedule() + .scheduleName(scheduleName) + .operationDate(operationDate) + .train(train) + .addStop(departureStation, null, departureTime) + .addStop(arrivalStation, arrivalTime, null) + .build(); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/TrainSearchValidationTest.java b/src/test/java/com/sudo/railo/train/application/TrainSearchValidationTest.java new file mode 100644 index 00000000..aba64c23 --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSearchValidationTest.java @@ -0,0 +1,179 @@ +package com.sudo.railo.train.application; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Collection; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; + +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.exception.TrainErrorCode; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +public class TrainSearchValidationTest { + + @Autowired + private TrainSearchService trainSearchService; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @DisplayName("다양한 잘못된 검색 조건에 대해 적절한 비즈니스 예외가 발생한다") + @TestFactory + Collection shouldThrowAppropriateExceptionForInvalidSearchConditions() { + // given + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + LocalDate validDate = LocalDate.now().plusDays(1); + + int currentHour = LocalTime.now().getHour(); + int pastHour = (currentHour == 0 ? 0 : currentHour - 1); + String pastHourString = String.format("%02d", pastHour); + + record ValidationScenario( + String description, + TrainSearchRequest request, + Class expectedException, + TrainErrorCode expectedErrorCode, + String expectedMessageContains + ) { + @Override + public String toString() { + return description; + } + } + + List scenarios = List.of( + new ValidationScenario( + "출발역과 도착역이 동일한 경우", + new TrainSearchRequest(seoul.getId(), seoul.getId(), validDate, 1, "00"), + BusinessException.class, + TrainErrorCode.INVALID_ROUTE, + TrainErrorCode.INVALID_ROUTE.getMessage() + ), + new ValidationScenario( + "운행일이 너무 먼 미래인 경우 (3개월 후)", + new TrainSearchRequest(seoul.getId(), busan.getId(), LocalDate.now().plusMonths(3), 1, "00"), + BusinessException.class, + TrainErrorCode.OPERATION_DATE_TOO_FAR, + TrainErrorCode.OPERATION_DATE_TOO_FAR.getMessage() + ), + new ValidationScenario( + "과거 시각을 출발 시간으로 선택한 경우", + new TrainSearchRequest(seoul.getId(), busan.getId(), LocalDate.now(), 1, pastHourString), + BusinessException.class, + TrainErrorCode.DEPARTURE_TIME_PASSED, + TrainErrorCode.DEPARTURE_TIME_PASSED.getMessage() + ) + ); + + return scenarios.stream() + .map(scenario -> DynamicTest.dynamicTest( + scenario.description, + () -> { + // when & then + assertThatThrownBy(() -> trainSearchService.searchTrains( + scenario.request, PageRequest.of(0, 20))) + .isInstanceOf(scenario.expectedException) + .hasMessageContaining(scenario.expectedMessageContains); + + log.info("검증 실패 시나리오 완료 - {}: {} 발생", + scenario.description, scenario.expectedException.getSimpleName()); + } + )) + .toList(); + } + + @DisplayName("다양한 검색 시나리오에서 검색 결과가 없을 경우 빈 리스트를 반환한다.") + @TestFactory + Collection shouldReturnEmptyListForNonexistentRoutesAndDates() { + // given - 기본 테스트 데이터 + Train train = trainTestHelper.createRealisticTrain(2, 1, 10, 6); + LocalDate searchDate = LocalDate.now().plusDays(1); + + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + createTrainSchedule(train, searchDate, "KTX 001", + LocalTime.of(10, 0), LocalTime.of(13, 0), "서울", "부산"); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + Station daegu = trainScheduleTestHelper.getOrCreateStation("대구"); + + record NoResultScenario( + String description, + TrainSearchRequest request + ) { + @Override + public String toString() { + return description; + } + } + + List scenarios = List.of( + new NoResultScenario( + "존재하는 역이지만 해당 역을 경유하는 노선이 없는 경우 (서울-대구)", + new TrainSearchRequest(seoul.getId(), daegu.getId(), searchDate, 1, "00") + ), + new NoResultScenario( + "해당 날짜에 운행하는 열차 없음", + new TrainSearchRequest(seoul.getId(), busan.getId(), searchDate.plusDays(1), 1, "00") + ), + new NoResultScenario( + "요청한 출발 시간 이후에 운행하는 열차 없음", + new TrainSearchRequest(seoul.getId(), busan.getId(), searchDate, 1, "15") + ) + ); + + return scenarios.stream() + .map(scenario -> DynamicTest.dynamicTest( + scenario.description, + () -> { + // when + TrainSearchSlicePageResponse response = trainSearchService.searchTrains( + scenario.request, PageRequest.of(0, 20)); + + // then: 검색 결과 없을 때 빈 리스트 반환 + assertThat(response.content()).isEmpty(); + + log.info("검색 결과 없음 시나리오 완료 - {}", scenario.description); + } + )) + .toList(); + } + + /** + * 열차 스케줄 생성 헬퍼 + */ + private TrainScheduleTestHelper.TrainScheduleWithStopStations createTrainSchedule(Train train, + LocalDate operationDate, + String scheduleName, LocalTime departureTime, LocalTime arrivalTime, + String departureStation, String arrivalStation) { + return trainScheduleTestHelper.createCustomSchedule() + .scheduleName(scheduleName) + .operationDate(operationDate) + .train(train) + .addStop(departureStation, null, departureTime) + .addStop(arrivalStation, arrivalTime, null) + .build(); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/TrainSeatQueryServiceTest.java b/src/test/java/com/sudo/railo/train/application/TrainSeatQueryServiceTest.java new file mode 100644 index 00000000..864b1f22 --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSeatQueryServiceTest.java @@ -0,0 +1,317 @@ +package com.sudo.railo.train.application; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.booking.application.ReservationApplicationService; +import com.sudo.railo.booking.application.dto.request.ReservationCreateRequest; +import com.sudo.railo.booking.domain.type.PassengerSummary; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.booking.domain.type.TripType; +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper.TrainScheduleWithStopStations; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainCarSeatDetailRequest; +import com.sudo.railo.train.application.dto.response.SeatDetail; +import com.sudo.railo.train.application.dto.response.TrainCarInfo; +import com.sudo.railo.train.application.dto.response.TrainCarSeatDetailResponse; +import com.sudo.railo.train.domain.ScheduleStop; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.TrainCar; +import com.sudo.railo.train.domain.type.CarType; +import com.sudo.railo.train.exception.TrainErrorCode; +import com.sudo.railo.train.infrastructure.TrainCarRepository; + +@ServiceTest +class TrainSeatQueryServiceTest { + + @Autowired + private TrainSeatQueryService trainSeatQueryService; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private TrainCarRepository trainCarRepository; + + @Autowired + private ReservationApplicationService reservationApplicationService; + + @Autowired + private MemberRepository memberRepository; + + private TrainScheduleWithStopStations scheduleWithStops; + private ScheduleStop departureStop; + private ScheduleStop arrivalStop; + private Train train; + + @BeforeEach + void setUp() { + train = trainTestHelper.createCustomKTX(1, 1); + scheduleWithStops = trainScheduleTestHelper.createCustomSchedule() + .scheduleName("test-schedule") + .operationDate(LocalDate.now()) + .train(train) + .addStop("서울", null, LocalTime.of(9, 30)) + .addStop("대전", LocalTime.of(10, 30), LocalTime.of(10, 32)) + .addStop("부산", LocalTime.of(12, 30), null) + .build(); + + departureStop = trainScheduleTestHelper.getScheduleStopByStationName(scheduleWithStops, "서울"); + arrivalStop = trainScheduleTestHelper.getScheduleStopByStationName(scheduleWithStops, "부산"); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 10000); + } + + @Test + @DisplayName("잔여 좌석이 있는 객차 목록을 성공적으로 조회한다") + void getAvailableTrainCars() { + // when + List availableTrainCars = trainSeatQueryService.getAvailableTrainCars( + scheduleWithStops.trainSchedule().getId(), + departureStop.getStation().getId(), + arrivalStop.getStation().getId() + ); + + // then + assertThat(availableTrainCars).hasSize(2); + + TrainCarInfo standardCar = availableTrainCars.get(0); + assertThat(standardCar.id()).isEqualTo(1L); + assertThat(standardCar.carNumber()).isEqualTo("0001"); + assertThat(standardCar.carType()).isEqualTo(CarType.STANDARD); + assertThat(standardCar.totalSeats()).isEqualTo(2); + assertThat(standardCar.remainingSeats()).isEqualTo(2); + + TrainCarInfo firstClassCar = availableTrainCars.get(1); + assertThat(firstClassCar.id()).isEqualTo(2L); + assertThat(firstClassCar.carNumber()).isEqualTo("0002"); + assertThat(firstClassCar.carType()).isEqualTo(CarType.FIRST_CLASS); + assertThat(firstClassCar.totalSeats()).isEqualTo(2); + assertThat(firstClassCar.remainingSeats()).isEqualTo(2); + } + + @Test + @DisplayName("예약된 좌석이 있으면 해당 좌석은 조회 되지 않는다") + void shouldExcludeReservedSeatsFromAvailableCount() { + // given + Member testMember = MemberFixture.createStandardMember(); + memberRepository.save(testMember); + + List standardSeatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, 1); + ReservationCreateRequest standardRequest = getReservationCreateRequest(standardSeatIds); + reservationApplicationService.createReservation(standardRequest, testMember.getMemberDetail().getMemberNo()); + + // when + List availableTrainCars = trainSeatQueryService.getAvailableTrainCars( + scheduleWithStops.trainSchedule().getId(), + departureStop.getStation().getId(), + arrivalStop.getStation().getId() + ); + + // then + TrainCarInfo standardCar = availableTrainCars.get(0); + assertThat(standardCar.carType()).isEqualTo(CarType.STANDARD); + assertThat(standardCar.totalSeats()).isEqualTo(2); + assertThat(standardCar.remainingSeats()).isEqualTo(1); + } + + @Test + @DisplayName("잔여 좌석이 없으면 조회 되지 않는다") + void shouldThrowExceptionWhenNoAvailableSeats() { + // given + Member testMember = MemberFixture.createStandardMember(); + memberRepository.save(testMember); + + List standardSeatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, 2); + ReservationCreateRequest standardRequest = getReservationCreateRequest(standardSeatIds); + reservationApplicationService.createReservation(standardRequest, testMember.getMemberDetail().getMemberNo()); + + List firstClassSeatIds = trainTestHelper.getSeatIds(train, CarType.FIRST_CLASS, 2); + ReservationCreateRequest firstClassRequest = getReservationCreateRequest(firstClassSeatIds); + reservationApplicationService.createReservation(firstClassRequest, testMember.getMemberDetail().getMemberNo()); + + // when & then + assertThatThrownBy(() -> trainSeatQueryService.getAvailableTrainCars( + scheduleWithStops.trainSchedule().getId(), departureStop.getStation().getId(), + arrivalStop.getStation().getId() + )) + .isInstanceOf(BusinessException.class) + .hasMessage(TrainErrorCode.NO_AVAILABLE_CARS.getMessage()); + } + + @Test + @DisplayName("존재하지 않는 열차 스케줄로 객차 조회 시 예외가 발생한다") + void shouldThrowExceptionWhenGetAvailableTrainCarsWithTrainScheduleNotFound() { + // when & then + assertThatThrownBy(() -> trainSeatQueryService.getAvailableTrainCars( + 999L, + departureStop.getStation().getId(), + arrivalStop.getStation().getId() + )) + .isInstanceOf(BusinessException.class) + .hasMessage(TrainErrorCode.TRAIN_SCHEDULE_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("존재하지 않는 출발역으로 객차 조회 시 예외가 발생한다") + void shouldThrowExceptionWhenGetAvailableTrainCarsWithDepartureStationNotFound() { + // when & then + assertThatThrownBy(() -> trainSeatQueryService.getAvailableTrainCars( + scheduleWithStops.trainSchedule().getId(), + 999L, + arrivalStop.getStation().getId() + )) + .isInstanceOf(BusinessException.class) + .hasMessage(TrainErrorCode.STATION_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("존재하지 않는 도착역으로 객차 조회 시 예외가 발생한다") + void shouldThrowExceptionWhenGetAvailableTrainCarsWithArrivalStationNotFound() { + // when & then + assertThatThrownBy(() -> trainSeatQueryService.getAvailableTrainCars( + scheduleWithStops.trainSchedule().getId(), + departureStop.getStation().getId(), + 999L + )) + .isInstanceOf(BusinessException.class) + .hasMessage(TrainErrorCode.STATION_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("유효하지 않은 경로로 조회하면 예외가 발생한다") + void shouldThrowExceptionWhenGetAvailableTrainCarsWithInvalidRoute() { + // when & then + assertThatThrownBy(() -> trainSeatQueryService.getAvailableTrainCars( + scheduleWithStops.trainSchedule().getId(), + arrivalStop.getStation().getId(), + arrivalStop.getStation().getId() + )) + .isInstanceOf(BusinessException.class) + .hasMessage(TrainErrorCode.INVALID_ROUTE.getMessage()); + } + + @Test + @DisplayName("객차 좌석 상세 정보를 성공적으로 조회한다") + void getTrainCarSeatDetail() { + // given + List trainCars = trainCarRepository.findByTrainIn(List.of(train)); + TrainCar trainCar = trainCars.get(0); + TrainCarSeatDetailRequest request = new TrainCarSeatDetailRequest( + trainCar.getId(), + scheduleWithStops.trainSchedule().getId(), + departureStop.getStation().getId(), + arrivalStop.getStation().getId() + ); + + // when + TrainCarSeatDetailResponse response = trainSeatQueryService.getTrainCarSeatDetail(request); + + // then + assertThat(response.carNumber()).isEqualTo("1"); + assertThat(response.carType()).isEqualTo(CarType.STANDARD); + assertThat(response.totalSeatCount()).isEqualTo(2); + assertThat(response.remainingSeatCount()).isEqualTo(2); + assertThat(response.layoutType()).isEqualTo(2); + assertThat(response.seatList()).hasSize(2); + + SeatDetail firstSeatDetail = response.seatList().get(0); + assertThat(firstSeatDetail.seatNumber()).isEqualTo("1A"); + assertThat(firstSeatDetail.isAvailable()).isTrue(); + + SeatDetail secondSeatDetail = response.seatList().get(1); + assertThat(secondSeatDetail.seatNumber()).isEqualTo("1B"); + assertThat(secondSeatDetail.isAvailable()).isTrue(); + } + + @Test + @DisplayName("예약된 좌석은 조회시 사용 불가능한 상태로 조회된다.") + void shouldReservedSeatsAsUnavailable() { + // given + Member testMember = MemberFixture.createStandardMember(); + memberRepository.save(testMember); + List standardSeatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, 1); + ReservationCreateRequest standardRequest = getReservationCreateRequest(standardSeatIds); + reservationApplicationService.createReservation(standardRequest, testMember.getMemberDetail().getMemberNo()); + List trainCars = trainCarRepository.findByTrainIn(List.of(train)); + TrainCar trainCar = trainCars.get(0); + TrainCarSeatDetailRequest request = new TrainCarSeatDetailRequest( + trainCar.getId(), + scheduleWithStops.trainSchedule().getId(), + departureStop.getStation().getId(), + arrivalStop.getStation().getId() + ); + + // when + TrainCarSeatDetailResponse response = trainSeatQueryService.getTrainCarSeatDetail(request); + + // then + SeatDetail secondSeatDetail = response.seatList().get(0); + assertThat(secondSeatDetail.isAvailable()).isFalse(); + } + + @Test + @DisplayName("존재하지 않는 객차로 좌석 상세 조회 시 예외가 발생한다") + void shouldThrowExceptionWhenGetTrainCarSeatDetailWithTrainCarNotFound() { + // given + TrainCarSeatDetailRequest request = new TrainCarSeatDetailRequest( + 999L, + scheduleWithStops.trainSchedule().getId(), + departureStop.getStation().getId(), + arrivalStop.getStation().getId() + ); + + // when & then + assertThatThrownBy(() -> trainSeatQueryService.getTrainCarSeatDetail(request)) + .isInstanceOf(BusinessException.class) + .hasMessage(TrainErrorCode.TRAIN_CAR_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("존재하지 않는 열차 스케줄로 좌석 상세 조회 시 예외가 발생한다") + void shouldThrowExceptionWhenGetTrainCarSeatDetailWithTrainScheduleNotFound() { + // given + List trainCars = trainCarRepository.findByTrainIn(List.of(train)); + TrainCar trainCar = trainCars.get(0); + TrainCarSeatDetailRequest request = new TrainCarSeatDetailRequest( + trainCar.getId(), + 999L, + departureStop.getStation().getId(), + arrivalStop.getStation().getId() + ); + + // when & then + assertThatThrownBy(() -> trainSeatQueryService.getTrainCarSeatDetail(request)) + .isInstanceOf(BusinessException.class) + .hasMessage(TrainErrorCode.TRAIN_SCHEDULE_NOT_FOUND.getMessage()); + } + + private ReservationCreateRequest getReservationCreateRequest(List seatIds) { + List passengers = List.of(new PassengerSummary(PassengerType.ADULT, seatIds.size())); + + return new ReservationCreateRequest( + scheduleWithStops.trainSchedule().getId(), + departureStop.getStation().getId(), + arrivalStop.getStation().getId(), + passengers, + seatIds, + TripType.OW + ); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/dto/request/TrainSearchRequestTest.java b/src/test/java/com/sudo/railo/train/application/dto/request/TrainSearchRequestTest.java new file mode 100644 index 00000000..bd4383dd --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/dto/request/TrainSearchRequestTest.java @@ -0,0 +1,94 @@ +package com.sudo.railo.train.application.dto.request; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.support.annotation.ServiceTest; + +import jakarta.validation.Validator; + +@ServiceTest +class TrainSearchRequestTest { + + @Autowired + private Validator validator; + + record ValidationScenario( + String description, + TrainSearchRequest request, + String field, + String expectedMessage + ) { + @Override + public String toString() { + return description; + } + } + + static Stream requestValidationScenarios() { + LocalDate today = LocalDate.now(); + return Stream.of( + new ValidationScenario( + "출발역이 null인 경우", + new TrainSearchRequest(null, 1L, today, 1, "00"), + "departureStationId", + "출발역을 선택해주세요" + ), + new ValidationScenario( + "도착역이 null인 경우", + new TrainSearchRequest(1L, null, today, 1, "00"), + "arrivalStationId", + "도착역을 선택해주세요" + ), + new ValidationScenario( + "운행날짜가 과거인 경우", + new TrainSearchRequest(1L, 2L, today.minusDays(1), 1, "00"), + "operationDate", + "운행날짜는 오늘 이후여야 합니다" + ), + new ValidationScenario( + "승객 수가 0명인 경우", + new TrainSearchRequest(1L, 2L, today, 0, "00"), + "passengerCount", + "승객 수는 최소 1명이어야 합니다" + ), + new ValidationScenario( + "승객 수가 10명인 경우", + new TrainSearchRequest(1L, 2L, today, 10, "00"), + "passengerCount", + "승객 수는 최대 9명까지 가능합니다" + ), + new ValidationScenario( + "출발 희망 시간이 blank인 경우", + new TrainSearchRequest(1L, 2L, today, 1, ""), + "departureHour", + "출발 희망 시간을 선택해주세요" + ), + new ValidationScenario( + "잘못된 departureHour 형식(25시)", + new TrainSearchRequest(1L, 2L, today, 1, "25"), + "departureHour", + "출발 시간은 00~23 사이의 정시 값이어야 합니다" + ) + ); + } + + @DisplayName("잘못된 TrainSearchRequest DTO는 검증 예외가 발생한다.") + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("requestValidationScenarios") + void shouldFailValidationForInvalidDto(ValidationScenario scenario) { + var violations = validator.validate(scenario.request()); + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo(scenario.field()); + assertThat(v.getMessage()).isEqualTo(scenario.expectedMessage()); + }); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..33e7a09f --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,70 @@ +spring: + config.activate.on-profile: "test" + data: + redis: + host: localhost + port: 63790 + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:test;MODE=MYSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + jpa: + show-sql: true + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + use_sql_comments: true + highlight_sql: true + open-in-view: false + + mail: + host: localhost + port: 3025 + username: testUser + password: testPassword + test-connection: false # 테스트 시 메일 연결 확인 비활성화 + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: false + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + +logging: + level: + org.hibernate.SQL: INFO + org.hibernate.type.descriptor.sql.BasicBinder: INFO + org.springframework.jdbc.core: INFO + io.lettuce.core: WARN + +train: + schedule: + excel: + filename: "train_schedule.xlsx" + station-fare: + excel: + filename: "station_fare.xls" + standing: + ratio: 0.15 + +jwt: + secret: "crailotestjwtsecretkey2025fordevelopmentandtestingonlylonglonglonglonglonglonglonglonglong" + +cors: + allowed-origins: http://localhost:3000 + allowed-methods: GET, POST, PUT, DELETE + allowed-headers: Access-Control-Allow-Origin, Content-type, Access-Control-Allow-Headers, Authorization, X-Requested-With + +booking: + expiration: + reservation: 1 + +cookie: + domain: .test