From 5c7bfde58ef7bccd9a85d02b5c6fbccc7b41704b Mon Sep 17 00:00:00 2001 From: Junbeom Lee <63588899+ss7622@users.noreply.github.com> Date: Fri, 18 Jul 2025 13:50:02 +0900 Subject: [PATCH 01/12] =?UTF-8?q?chore:=20test=20ci-cd=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-cd-test.yml | 78 -------------------------------- 1 file changed, 78 deletions(-) delete mode 100644 .github/workflows/ci-cd-test.yml diff --git a/.github/workflows/ci-cd-test.yml b/.github/workflows/ci-cd-test.yml deleted file mode 100644 index 95dfb9ba..00000000 --- a/.github/workflows/ci-cd-test.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Java CI/CD with Gradle - -on: - push: - branches: [ "main" ] - -permissions: - contents: read - -jobs: - build-docker-image: - runs-on: ubuntu-latest - - steps: - # 소스코드 체크아웃 - - uses: actions/checkout@v3 - - # JDK 17 설치 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - # Gradle 실행 권한 부여 - - name: Grant Execute Permission For Gradlew - run: chmod +x gradlew - - # Gradle 캐싱 - - name: Gradle Caching - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - # Gradle 빌드 (WAR 파일 생성) - - name: Build With Gradle - run: ./gradlew clean build -x test - - # Docker 이미지 빌드 (WAR → Tomcat 이미지에 복사) - - name: docker image build - run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/dolfin-main . - - # Docker Hub 로그인 - - name: docker login - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - - # Docker Hub로 이미지 푸시 - - name: docker Hub push - run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/dolfin-main - - # AWS 자격 증명 주입 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.ACCESS_KEY }} - aws-secret-access-key: ${{ secrets.SECRET_KEY }} - aws-region: ${{ secrets.REGION }} - - # S3로 Docker Compose 업로드 - - name: Upload Docker Compose to S3 - run: | - aws s3 cp docker-compose.yml s3://${{ secrets.BUCKET_NAME }}/docker-compose.yml --region ${{ secrets.REGION }} - - # 오토스케일링 그룹 롤링 재배포 - - name: Trigger ASG Rolling Update - run: | - aws autoscaling start-instance-refresh \ - --auto-scaling-group-name public-test \ - --region ${{ secrets.REGION }} \ - --preferences '{"MinHealthyPercentage": 50}' From e55124bf6859bda27bf9180a394260d6560805e1 Mon Sep 17 00:00:00 2001 From: bagjuna Date: Fri, 18 Jul 2025 14:04:43 +0900 Subject: [PATCH 02/12] =?UTF-8?q?Fix:=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - baseEntity 적용 --- .../depositsaving/entity/DepositSaving.java | 32 ++++++++++--------- .../entity/FinancialCompany.java | 4 ++- .../jeonseloan/entity/JeonseLoan.java | 6 ++-- .../entity/PersonalCreditLoan.java | 4 ++- .../domain/location/entity/Location.java | 14 ++++---- 5 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/main/java/org/scoula/domain/financialproduct/depositsaving/entity/DepositSaving.java b/src/main/java/org/scoula/domain/financialproduct/depositsaving/entity/DepositSaving.java index b7e83e50..416a8999 100644 --- a/src/main/java/org/scoula/domain/financialproduct/depositsaving/entity/DepositSaving.java +++ b/src/main/java/org/scoula/domain/financialproduct/depositsaving/entity/DepositSaving.java @@ -7,23 +7,25 @@ import java.math.BigDecimal; +import org.scoula.global.entity.BaseEntity; + @Getter @AllArgsConstructor @NoArgsConstructor @Builder -public class DepositSaving { - private Long depositSavingId; // 아이디 (PK) - private Long financialCompanyId; // 금융회사 아이디 (FK) - private DepositSavingType type; // 금융 상품 종류 / DEPOSIT(예금), SAVING(적금) - private String name; // 금융 상품명 - private String joinWay; // 가입 방법 - private String interestDescription; // 만기 후 이자율 설명 - private String spdCondition; // 우대조건 - private String etcNote; // 기타 유의사항 - private String maxLimit; // 최고 한도 - private Integer saveMonth; // 저축 기간(개월) - private BigDecimal interestRate; // 기본 금리 - private BigDecimal maxInterestRate; // 우대 최고 금리 - private String interestRateType; // 금리 유형 - private String reserveType; // 적립 유형 +public class DepositSaving extends BaseEntity { + private Long depositSavingId; // 아이디 (PK) + private Long financialCompanyId; // 금융회사 아이디 (FK) + private DepositSavingType type; // 금융 상품 종류 / DEPOSIT(예금), SAVING(적금) + private String name; // 금융 상품명 + private String joinWay; // 가입 방법 + private String interestDescription; // 만기 후 이자율 설명 + private String spdCondition; // 우대조건 + private String etcNote; // 기타 유의사항 + private String maxLimit; // 최고 한도 + private Integer saveMonth; // 저축 기간(개월) + private BigDecimal interestRate; // 기본 금리 + private BigDecimal maxInterestRate; // 우대 최고 금리 + private String interestRateType; // 금리 유형 + private String reserveType; // 적립 유형 } diff --git a/src/main/java/org/scoula/domain/financialproduct/financialcompany/entity/FinancialCompany.java b/src/main/java/org/scoula/domain/financialproduct/financialcompany/entity/FinancialCompany.java index e321dd88..0a5a91ce 100644 --- a/src/main/java/org/scoula/domain/financialproduct/financialcompany/entity/FinancialCompany.java +++ b/src/main/java/org/scoula/domain/financialproduct/financialcompany/entity/FinancialCompany.java @@ -1,6 +1,8 @@ package org.scoula.domain.financialproduct.financialcompany.entity; +import org.scoula.global.entity.BaseEntity; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -10,7 +12,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class FinancialCompany { +public class FinancialCompany extends BaseEntity { private Long financialCompanyId; private String name; private String code; diff --git a/src/main/java/org/scoula/domain/financialproduct/jeonseloan/entity/JeonseLoan.java b/src/main/java/org/scoula/domain/financialproduct/jeonseloan/entity/JeonseLoan.java index 2d2c3299..a26ffd2f 100644 --- a/src/main/java/org/scoula/domain/financialproduct/jeonseloan/entity/JeonseLoan.java +++ b/src/main/java/org/scoula/domain/financialproduct/jeonseloan/entity/JeonseLoan.java @@ -7,6 +7,8 @@ import java.math.BigDecimal; +import org.scoula.global.entity.BaseEntity; + /** * 전세자금대출 */ @@ -14,10 +16,10 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class JeonseLoan { +public class JeonseLoan extends BaseEntity { private Long jeonseLoanId; // 전세자금대출 아이디 private Long financialCompanyId; // 금융회사 아이디 - private String name; // 금융상품명 + private String name; // 금융상품명 private String joinWay; // 가입 방법 private String loanExpensive; // 대출 부대비용 private String erlyFee; // 중도상환 수수료 diff --git a/src/main/java/org/scoula/domain/financialproduct/personalcreditLoan/entity/PersonalCreditLoan.java b/src/main/java/org/scoula/domain/financialproduct/personalcreditLoan/entity/PersonalCreditLoan.java index b5079647..4224b002 100644 --- a/src/main/java/org/scoula/domain/financialproduct/personalcreditLoan/entity/PersonalCreditLoan.java +++ b/src/main/java/org/scoula/domain/financialproduct/personalcreditLoan/entity/PersonalCreditLoan.java @@ -7,6 +7,8 @@ import java.math.BigDecimal; +import org.scoula.global.entity.BaseEntity; + /** * 개인신용대출 */ @@ -14,7 +16,7 @@ @AllArgsConstructor @NoArgsConstructor @Builder -public class PersonalCreditLoan { +public class PersonalCreditLoan extends BaseEntity { private Long personalCreditLoanId; // 개인신용대출 아이디 (PK) private Long financialCompanyId; // 금융회사 아이디 (FK) diff --git a/src/main/java/org/scoula/domain/location/entity/Location.java b/src/main/java/org/scoula/domain/location/entity/Location.java index 191e4d27..908ba812 100644 --- a/src/main/java/org/scoula/domain/location/entity/Location.java +++ b/src/main/java/org/scoula/domain/location/entity/Location.java @@ -1,5 +1,7 @@ package org.scoula.domain.location.entity; +import org.scoula.global.entity.BaseEntity; + import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -9,12 +11,12 @@ */ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Location { - private Long locationId; // 위치 ID - private String locationName; // 위치 이름 - private String address; // 주소 - private Point point; // 위도, 경도 - private String locationNumber; // 전화번호 +public class Location extends BaseEntity { + private Long locationId; // 위치 ID + private String locationName; // 위치 이름 + private String address; // 주소 + private Point point; // 위도, 경도 + private String locationNumber; // 전화번호 private LocationType locationType; // 장소 타입 CENTER(은행), CONSULT(외국인 센터), BANK(은행) } From 40bec71b2f3b00c7b432d469c8de82a3fde8c0b6 Mon Sep 17 00:00:00 2001 From: Junbeom Lee <63588899+ss7622@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:36:20 +0900 Subject: [PATCH 03/12] [Feature]: log transfer change to kafka (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style: 클래스명 중복 이슈로 클래스명 변경 * fix: 예외 핸들러 root config 등록 * feat: 카프카 도입 * feat: 로그 포맷 적용 * feat: 에러 발생 시 로그 전송 카프카로 전환 * feat: 에러 전송 테스트 컨트롤러 제작 * feat: 카프카 브로커 주소 및 topic 추가 --- build.gradle | 4 +- .../controller/HealthCheckController.java | 78 +++++--- .../member/service/MemberServiceImpl.java | 186 +++++++++--------- .../global/common/config/RootConfig.java | 122 ++++++------ .../global/exception/CustomException.java | 22 ++- .../exception/GlobalExceptionHandler.java | 58 +++++- .../exception/errorCode/CommonErrorCode.java | 7 +- .../kafka/config/KafkaProducerConfig.java | 36 ++++ .../org/scoula/global/kafka/dto/Common.java | 17 ++ .../org/scoula/global/kafka/dto/LogLevel.java | 5 + .../scoula/global/kafka/dto/LogMessage.java | 15 ++ .../global/kafka/producer/KafkaProducer.java | 36 ++++ .../global/kafka/util/LogMessageMapper.java | 80 ++++++++ .../scoula/global/response/ApiResponse.java | 28 --- .../global/response/SuccessResponse.java | 28 +++ src/main/resources/application.properties | 8 +- 16 files changed, 509 insertions(+), 221 deletions(-) create mode 100644 src/main/java/org/scoula/global/kafka/config/KafkaProducerConfig.java create mode 100644 src/main/java/org/scoula/global/kafka/dto/Common.java create mode 100644 src/main/java/org/scoula/global/kafka/dto/LogLevel.java create mode 100644 src/main/java/org/scoula/global/kafka/dto/LogMessage.java create mode 100644 src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java create mode 100644 src/main/java/org/scoula/global/kafka/util/LogMessageMapper.java delete mode 100644 src/main/java/org/scoula/global/response/ApiResponse.java create mode 100644 src/main/java/org/scoula/global/response/SuccessResponse.java diff --git a/build.gradle b/build.gradle index e3320713..7e8bb389 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,7 @@ ext { log4j2Version = '2.20.0' jacksonVersion = '2.15.3' aspectjVersion = '1.9.20' + springKafkaVersion = '2.9.10' } java { @@ -113,7 +114,8 @@ dependencies { implementation 'org.flywaydb:flyway-core:10.19.0' implementation 'org.flywaydb:flyway-mysql:10.19.0' // MySQL 8.4 지원 - + //KAFKA + implementation "org.springframework.kafka:spring-kafka:${springKafkaVersion}" } diff --git a/src/main/java/org/scoula/domain/controller/HealthCheckController.java b/src/main/java/org/scoula/domain/controller/HealthCheckController.java index 7dad8fc0..6cc73e39 100644 --- a/src/main/java/org/scoula/domain/controller/HealthCheckController.java +++ b/src/main/java/org/scoula/domain/controller/HealthCheckController.java @@ -1,15 +1,22 @@ - package org.scoula.domain.controller; -import io.swagger.annotations.Api; -import io.swagger.annotations.ApiOperation; -import lombok.extern.log4j.Log4j2; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +import org.scoula.global.exception.CustomException; +import org.scoula.global.exception.errorCode.CommonErrorCode; +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; +import org.scoula.global.response.SuccessResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.HashMap; -import java.util.Map; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.log4j.Log4j2; @Api(tags = "헬스 체크 API") @RestController @@ -17,30 +24,41 @@ @Log4j2 public class HealthCheckController { - @GetMapping - @ApiOperation("서버 상태 확인") - public Map healthCheck() { - log.info("Health check called"); - - Map response = new HashMap<>(); - response.put("status", "동작 중"); - response.put("timestamp", System.currentTimeMillis()); - response.put("message", "Server is running"); - - return response; - } - - @GetMapping("/swagger") - @ApiOperation("Swagger 연동 테스트") - public Map swaggerTest() { - log.info("Swagger test called"); - - Map response = new HashMap<>(); - response.put("swagger", "enabled"); - response.put("version", "2.0"); - - return response; - } + @GetMapping + @ApiOperation("서버 상태 확인") + public Map healthCheck() { + log.info("Health check called"); + + Map response = new HashMap<>(); + response.put("status", "동작 중"); + response.put("timestamp", System.currentTimeMillis()); + response.put("message", "Server is running"); + + return response; + } + + @GetMapping("/swagger") + @ApiOperation("Swagger 연동 테스트") + public Map swaggerTest() { + log.info("Swagger test called"); + + Map response = new HashMap<>(); + response.put("swagger", "enabled"); + response.put("version", "2.0"); + + return response; + } + @GetMapping("/error") + @ApiOperation("에러 로그 전송 테스트") + public SuccessResponse error(HttpServletRequest request) { + throw new CustomException(CommonErrorCode.INTERNAL_SERVER_ERROR, LogLevel.ERROR, null, + Common.builder() + .srcIp(request.getRemoteAddr()) + .apiMethod(request.getMethod()) + .callApiPath(request.getRequestURI()) + .deviceInfo(request.getHeader("user-agent")) + .build()); + } } \ No newline at end of file diff --git a/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java b/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java index 1e0a280e..96294e47 100644 --- a/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java @@ -1,19 +1,22 @@ package org.scoula.domain.member.service; import static org.scoula.domain.member.exception.MemberErrorCode.*; -import static org.scoula.global.exception.errorCode.CommonErrorCode.*; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; +import java.util.List; +import java.util.stream.Collectors; + import org.scoula.domain.member.dto.MemberDTO; import org.scoula.domain.member.entity.Member; import org.scoula.domain.member.mapper.MemberMapper; import org.scoula.global.exception.CustomException; +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; +import org.scoula.global.kafka.util.LogMessageMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; @Service @RequiredArgsConstructor @@ -21,91 +24,92 @@ @Transactional(readOnly = true) public class MemberServiceImpl implements MemberService { - private final MemberMapper memberMapper; - - @Override - public List getAllMembers() { - log.info("getAllMembers"); - return memberMapper.selectAllMembers().stream() - .map(MemberDTO::from) - .collect(Collectors.toList()); - } - - @Override - public MemberDTO getMemberById(Long memberId) { - log.info("getMemberById: {}", memberId); - Member member = memberMapper.selectMemberById(memberId); - - if (member == null) { - throw new CustomException(MEMBER_NOT_FOUND); - } - return MemberDTO.from(member); - } - - @Override - public MemberDTO getMemberByLoginId(String loginId) { - log.info("getMemberByLoginId: {}", loginId); - Member member = memberMapper.selectMemberByLoginId(loginId); - if (member == null) { - throw new CustomException(MEMBER_NOT_FOUND); - } - return MemberDTO.from(member); - } - - @Override - @Transactional - public MemberDTO createMember(MemberDTO memberDTO) { - log.info("createMember: {}", memberDTO.getLoginId()); - - // 로그인 ID 중복 체크 - if (checkLoginIdDuplicate(memberDTO.getLoginId())) { - throw new CustomException(MEMBER_ALREADY_EXISTS); - } - - // 임시로 비밀번호를 그대로 저장 (나중에 암호화 추가) - Member member = memberDTO.toEntity(); - memberMapper.insertMember(member); - - return MemberDTO.from(member); - } - @Override - @Transactional - public MemberDTO updateMember(Long memberId, MemberDTO memberDTO) { - log.info("updateMember: {}", memberId); - - // 기존 회원 정보 조회 - Member existingMember = memberMapper.selectMemberById(memberId); - if (existingMember == null) { - throw new CustomException(MEMBER_NOT_FOUND); - } - - // 수정할 정보 설정 - memberDTO.changePassword(existingMember.getPassword()); - - Member member = memberDTO.toEntity(); - memberMapper.updateMember(member); - - return MemberDTO.from(member); - } - - - @Override - @Transactional - public void deleteMember(Long memberId) { - log.info("deleteMember: {}", memberId); - - Member member = memberMapper.selectMemberById(memberId); - if (member == null) { - throw new CustomException(MEMBER_NOT_FOUND); - } - - memberMapper.deleteMember(memberId); - } - - @Override - public boolean checkLoginIdDuplicate(String loginId) { - log.info("checkLoginIdDuplicate: {}", loginId); - return memberMapper.checkLoginIdDuplicate(loginId) > 0; - } + private final MemberMapper memberMapper; + private final LogMessageMapper logMessageMapper; + + @Override + public List getAllMembers() { + log.info("getAllMembers"); + return memberMapper.selectAllMembers().stream() + .map(MemberDTO::from) + .collect(Collectors.toList()); + } + + @Override + public MemberDTO getMemberById(Long memberId) { + log.info("getMemberById: {}", memberId); + Member member = memberMapper.selectMemberById(memberId); + + if (member == null) { + throw new CustomException(MEMBER_NOT_FOUND, LogLevel.WARNING, null, Common.builder().build()); + } + return MemberDTO.from(member); + } + + @Override + public MemberDTO getMemberByLoginId(String loginId) { + log.info("getMemberByLoginId: {}", loginId); + Member member = memberMapper.selectMemberByLoginId(loginId); + if (member == null) { + throw new CustomException(MEMBER_NOT_FOUND, LogLevel.WARNING, null, Common.builder().build()); + } + return MemberDTO.from(member); + } + + @Override + @Transactional + public MemberDTO createMember(MemberDTO memberDTO) { + log.info("createMember: {}", memberDTO.getLoginId()); + + // 로그인 ID 중복 체크 + if (checkLoginIdDuplicate(memberDTO.getLoginId())) { + throw new CustomException(MEMBER_ALREADY_EXISTS, LogLevel.WARNING, null, Common.builder().build()); + } + + // 임시로 비밀번호를 그대로 저장 (나중에 암호화 추가) + Member member = memberDTO.toEntity(); + memberMapper.insertMember(member); + + return MemberDTO.from(member); + } + + @Override + @Transactional + public MemberDTO updateMember(Long memberId, MemberDTO memberDTO) { + log.info("updateMember: {}", memberId); + + // 기존 회원 정보 조회 + Member existingMember = memberMapper.selectMemberById(memberId); + if (existingMember == null) { + throw new CustomException(MEMBER_NOT_FOUND, LogLevel.WARNING, null, Common.builder().build()); + } + + // 수정할 정보 설정 + memberDTO.changePassword(existingMember.getPassword()); + + Member member = memberDTO.toEntity(); + memberMapper.updateMember(member); + + return MemberDTO.from(member); + } + + @Override + @Transactional + public void deleteMember(Long memberId) { + log.info("deleteMember: {}", memberId); + + Member member = memberMapper.selectMemberById(memberId); + if (member == null) { + throw new CustomException(MEMBER_NOT_FOUND, LogLevel.WARNING, null, Common.builder().build()); + } + + memberMapper.deleteMember(memberId); + } + + @Override + public boolean checkLoginIdDuplicate(String loginId) { + log.info("checkLoginIdDuplicate: {}", loginId); + return memberMapper.checkLoginIdDuplicate(loginId) > 0; + } } diff --git a/src/main/java/org/scoula/global/common/config/RootConfig.java b/src/main/java/org/scoula/global/common/config/RootConfig.java index 1547dff2..975a17eb 100644 --- a/src/main/java/org/scoula/global/common/config/RootConfig.java +++ b/src/main/java/org/scoula/global/common/config/RootConfig.java @@ -1,9 +1,7 @@ package org.scoula.global.common.config; -import java.util.Map; +import javax.sql.DataSource; -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; import org.apache.ibatis.session.SqlSessionFactory; import org.flywaydb.core.Flyway; import org.mybatis.spring.SqlSessionFactoryBean; @@ -17,76 +15,80 @@ import org.springframework.context.annotation.PropertySource; import org.springframework.jdbc.datasource.DataSourceTransactionManager; -import javax.sql.DataSource; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; @Configuration @PropertySource({"classpath:/application.properties"}) @MapperScan(basePackages = {"org.scoula.domain.member.mapper"}) // 게시판과 회원 도메인 매퍼 스캔 @ComponentScan(basePackages = { - // "org.scoula.service", - "org.scoula.domain.member.service", // 도메인 객체를 포함하기 위해 추가 - "org.scoula.global.swagger.config" // Swagger 설정을 포함하기 위해 추가 + // "org.scoula.service", + "org.scoula.domain.member.service", // 도메인 객체를 포함하기 위해 추가 + "org.scoula.global.swagger.config", // Swagger 설정을 포함하기 위해 추가 + "org.scoula.global.kafka", // kafka 설정 포함 + "org.scoula.global.exception" // exception handler 등록 }) public class RootConfig { - @Value("${jdbc.driver}") String driver; - @Value("${jdbc.url}") String url; - @Value("${jdbc.username}") String username; - @Value("${jdbc.password}") String password; - - @Bean - public DataSource dataSource() { - HikariConfig config = new HikariConfig(); - config.setDriverClassName(driver); - config.setJdbcUrl(url); - config.setUsername(username); - config.setPassword(password); - config.setMaximumPoolSize(50); - config.setMinimumIdle(5); - config.setConnectionTimeout(30000); - config.setIdleTimeout(600000); - config.setMaxLifetime(1800000); - HikariDataSource dataSource = new HikariDataSource(config); - return dataSource; - } - - - @Autowired - ApplicationContext applicationContext; + @Value("${jdbc.driver}") + String driver; + @Value("${jdbc.url}") + String url; + @Value("${jdbc.username}") + String username; + @Value("${jdbc.password}") + String password; - @Bean - public Flyway flyway(DataSource dataSource) { - Flyway flyway = Flyway.configure() - .dataSource(dataSource) - .locations("classpath:db/migration") - .baselineVersion("0.0") // 초기 마이그레이션 버전 설정 - .baselineOnMigrate(true) - .encoding("UTF-8") - .cleanDisabled(true) // 안전을 위해 clean 비활성화 - .validateMigrationNaming(true) - .load(); - // Flyway 설정 + @Bean + public DataSource dataSource() { + HikariConfig config = new HikariConfig(); + config.setDriverClassName(driver); + config.setJdbcUrl(url); + config.setUsername(username); + config.setPassword(password); + config.setMaximumPoolSize(50); + config.setMinimumIdle(5); + config.setConnectionTimeout(30000); + config.setIdleTimeout(600000); + config.setMaxLifetime(1800000); + HikariDataSource dataSource = new HikariDataSource(config); + return dataSource; + } + @Autowired + ApplicationContext applicationContext; - // 수동으로 migrate 호출 - flyway.migrate(); + @Bean + public Flyway flyway(DataSource dataSource) { + Flyway flyway = Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration") + .baselineVersion("0.0") // 초기 마이그레이션 버전 설정 + .baselineOnMigrate(true) + .encoding("UTF-8") + .cleanDisabled(true) // 안전을 위해 clean 비활성화 + .validateMigrationNaming(true) + .load(); + // Flyway 설정 - return flyway; - } + // 수동으로 migrate 호출 + flyway.migrate(); - @Bean - public SqlSessionFactory sqlSessionFactory() throws Exception { - SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean(); - sqlSessionFactory.setConfigLocation( - applicationContext.getResource("classpath:/mybatis-config.xml") - ); - sqlSessionFactory.setDataSource(dataSource()); - return (SqlSessionFactory) sqlSessionFactory.getObject(); - } + return flyway; + } - @Bean - public DataSourceTransactionManager transactionManager() { - return new DataSourceTransactionManager(dataSource()); - } + @Bean + public SqlSessionFactory sqlSessionFactory() throws Exception { + SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean(); + sqlSessionFactory.setConfigLocation( + applicationContext.getResource("classpath:/mybatis-config.xml") + ); + sqlSessionFactory.setDataSource(dataSource()); + return (SqlSessionFactory)sqlSessionFactory.getObject(); + } + @Bean + public DataSourceTransactionManager transactionManager() { + return new DataSourceTransactionManager(dataSource()); + } } diff --git a/src/main/java/org/scoula/global/exception/CustomException.java b/src/main/java/org/scoula/global/exception/CustomException.java index b065fa5e..891e19e9 100644 --- a/src/main/java/org/scoula/global/exception/CustomException.java +++ b/src/main/java/org/scoula/global/exception/CustomException.java @@ -1,25 +1,39 @@ package org.scoula.global.exception; import org.scoula.global.exception.errorCode.ErrorCode; +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; import lombok.Getter; @Getter -public class CustomException extends RuntimeException{ +public class CustomException extends RuntimeException { private final ErrorCode errorCode; + private final LogLevel logLevel; + private final String txId; + private final Common common; - protected CustomException(ErrorCode errorCode, String message) { + protected CustomException(ErrorCode errorCode, String message, LogLevel logLevel, String txId, Common common) { super(errorCode.getMessage() + ": " + message); this.errorCode = errorCode; + this.logLevel = logLevel; + this.txId = txId; + this.common = common; } - public CustomException(ErrorCode errorCode) { + public CustomException(ErrorCode errorCode, LogLevel logLevel, String txId, Common common) { super(errorCode.getMessage()); this.errorCode = errorCode; + this.logLevel = logLevel; + this.txId = txId; + this.common = common; } - public CustomException(ErrorCode errorCode, Object... args) { + public CustomException(ErrorCode errorCode, LogLevel logLevel, String txId, Common common, Object... args) { super(String.format(errorCode.getMessage(), args)); this.errorCode = errorCode; + this.logLevel = logLevel; + this.txId = txId; + this.common = common; } } diff --git a/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java b/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java index fccb810d..c0da5137 100644 --- a/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java @@ -5,6 +5,10 @@ import org.scoula.global.exception.errorCode.CommonErrorCode; import org.scoula.global.exception.errorCode.ErrorCode; +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; +import org.scoula.global.kafka.producer.KafkaProducer; +import org.scoula.global.kafka.util.LogMessageMapper; import org.scoula.global.response.ErrorResponse; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; @@ -14,15 +18,31 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + @RestControllerAdvice +@RequiredArgsConstructor +@Slf4j public class GlobalExceptionHandler { + private final KafkaProducer kafkaProducer; + /** * 커스텀 예외 */ @ExceptionHandler(value = CustomException.class) public ResponseEntity> handleCustomException(CustomException e) { ErrorCode errorCode = e.getErrorCode(); + + kafkaProducer.sendToLogTopic(LogMessageMapper.buildLogMessage( + e.getLogLevel(), + e.getTxId(), + null, + e.getCommon(), + e.getMessage() + )); + return ResponseEntity.status(errorCode.getHttpStatus()) .body(ErrorResponse.error(errorCode.getCode(), e.getMessage())); } @@ -41,11 +61,19 @@ protected ResponseEntity>> handleMethodArgumen } ErrorCode errorCode = CommonErrorCode.INVALID_VALUE; + + kafkaProducer.sendToLogTopic(LogMessageMapper.buildLogMessage( + LogLevel.WARNING, + null, + errorCode.getMessage(), + Common.builder().build(), + e.getMessage() + )); + return ResponseEntity.status(errorCode.getHttpStatus()) .body(ErrorResponse.error(errors, errorCode.getCode(), errorCode.getMessage())); } - /** * 필수 요청 파라미터 누락 예외 */ @@ -53,7 +81,35 @@ protected ResponseEntity>> handleMethodArgumen public ResponseEntity> handleMissingServletRequestParameterException( MissingServletRequestParameterException e) { ErrorCode errorCode = CommonErrorCode.MISSING_PARAMETER; + + kafkaProducer.sendToLogTopic(LogMessageMapper.buildLogMessage( + LogLevel.WARNING, + null, + errorCode.getMessage(), + Common.builder().build(), + e.getMessage() + )); + return ResponseEntity.status(errorCode.getHttpStatus()) .body(ErrorResponse.error(errorCode.getCode(), errorCode.getMessage())); } + + /** + * 예상하지 못한 모든 예외 처리 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAllExceptions(Exception e) { + ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; + + kafkaProducer.sendToLogTopic(LogMessageMapper.buildLogMessage( + LogLevel.ERROR, + null, + errorCode.getMessage(), + Common.builder().build(), + e.getMessage() + )); + + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(ErrorResponse.error(errorCode.getCode(), e.getMessage())); + } } diff --git a/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java b/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java index 66d781a3..a6e0b734 100644 --- a/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java +++ b/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java @@ -1,8 +1,9 @@ package org.scoula.global.exception.errorCode; +import org.springframework.http.HttpStatus; + import lombok.AllArgsConstructor; import lombok.Getter; -import org.springframework.http.HttpStatus; @Getter @AllArgsConstructor @@ -11,8 +12,8 @@ public enum CommonErrorCode implements ErrorCode { // 400 INVALID_VALUE(HttpStatus.BAD_REQUEST, "C-001", "입력값이 올바르지 않습니다."), INVALID_ARGUMENT(HttpStatus.BAD_REQUEST, "C-002", "잘못된 인자입니다."), - MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "C-005", "필수 파라미터가 누락되었습니다.") - ; + MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "C-005", "필수 파라미터가 누락되었습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C-006", "내부 에러 메세지 사용 예정"); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/org/scoula/global/kafka/config/KafkaProducerConfig.java b/src/main/java/org/scoula/global/kafka/config/KafkaProducerConfig.java new file mode 100644 index 00000000..ebb960e7 --- /dev/null +++ b/src/main/java/org/scoula/global/kafka/config/KafkaProducerConfig.java @@ -0,0 +1,36 @@ +package org.scoula.global.kafka.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@PropertySource("classpath:application.properties") +public class KafkaProducerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Bean + public ProducerFactory producerFactory() { + Map configProps = new HashMap<>(); + configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + return new DefaultKafkaProducerFactory<>(configProps); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} diff --git a/src/main/java/org/scoula/global/kafka/dto/Common.java b/src/main/java/org/scoula/global/kafka/dto/Common.java new file mode 100644 index 00000000..f47eddbf --- /dev/null +++ b/src/main/java/org/scoula/global/kafka/dto/Common.java @@ -0,0 +1,17 @@ +package org.scoula.global.kafka.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class Common { + private String callApiPath; + private String apiMethod; + private String srcIp; + private String deviceInfo; + private String memberId; + private String ledgerCode; + private String transactionGroupId; + private Integer retryCount; +} diff --git a/src/main/java/org/scoula/global/kafka/dto/LogLevel.java b/src/main/java/org/scoula/global/kafka/dto/LogLevel.java new file mode 100644 index 00000000..b7a80274 --- /dev/null +++ b/src/main/java/org/scoula/global/kafka/dto/LogLevel.java @@ -0,0 +1,5 @@ +package org.scoula.global.kafka.dto; + +public enum LogLevel { + ERROR, WARNING, INFO +} diff --git a/src/main/java/org/scoula/global/kafka/dto/LogMessage.java b/src/main/java/org/scoula/global/kafka/dto/LogMessage.java new file mode 100644 index 00000000..aeff97c9 --- /dev/null +++ b/src/main/java/org/scoula/global/kafka/dto/LogMessage.java @@ -0,0 +1,15 @@ +package org.scoula.global.kafka.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class LogMessage { + private String logLevel; + private String timestamp; + private String txId; + private String message; + private Common data; + private String errorMessage; +} diff --git a/src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java b/src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java new file mode 100644 index 00000000..7a6a8b4a --- /dev/null +++ b/src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java @@ -0,0 +1,36 @@ +package org.scoula.global.kafka.producer; + +import org.scoula.global.kafka.dto.LogMessage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.PropertySource; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@PropertySource("classpath:application.properties") +@Slf4j +public class KafkaProducer { + + @Value("${spring.kafka.log-topic}") + private String logTopic; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final KafkaTemplate kafkaTemplate; + + public void sendToLogTopic(LogMessage logMessage) { + try { + String message = objectMapper.writeValueAsString(logMessage); + kafkaTemplate.send(logTopic, message); + System.out.println("로그 전송 됨 ????ㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇ"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/org/scoula/global/kafka/util/LogMessageMapper.java b/src/main/java/org/scoula/global/kafka/util/LogMessageMapper.java new file mode 100644 index 00000000..6297c8d9 --- /dev/null +++ b/src/main/java/org/scoula/global/kafka/util/LogMessageMapper.java @@ -0,0 +1,80 @@ +package org.scoula.global.kafka.util; + +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; +import org.scoula.global.kafka.dto.LogMessage; +import org.springframework.stereotype.Component; + +@Component +public class LogMessageMapper { + + public static LogMessage buildLogMessage( + /* 로그 레벨 ERROR, WARNING, INFO 존재하며, + ERROR는 아주 심각한 에러로 바로 슬랙에 알림이 가는 심각한 에러, + WARNING은 비정상적인 시도 접근 등을 의미 1초간 10회 이상 로그가 찍힐 경우 바로 알림 발생, + INFO는 정보 전달을 위한 로그 + */ + LogLevel logLevel, + // 고유 트랜잭션 아이디를 의미 한 번에 + // api call 내부에서 고유한 트랜잭션 아이디를 보유 + // aop를 통해 api가 호출될 때 로그를 발생하고 tx id를 발급할 예정 + // 이후 발급된 tx id를 이용해 계속 로그를 작성하면 됨 + String txId, + String message, + // api 경로 의미, 마찬가지로 컨트롤러에서 받아서 로그 작성하면 됨 + String apiPath, + // GET, POST, PATCH, DELETE 등등,, + String methodType, + // 사용자 src ip 의미 헤더에서 추출 가능 + String srcIp, + // 사용자 디바이스 정보 헤더의 host-Agent를 통해 호출 가능 + String deviceInfo, + // 거래가 이뤄질 경우 해당 멤버 아이디 기록 + String memberId, + // 거래가 이뤄질 경우 해당 거래의 회계 코드 기록 + String ledgerCode, + // 거래의 고유 tx id 기록 + String transactionGroupId, + // 에러가 발생했을 경우 에러 메세지 기록 + String errorMessage + ) { + Common common = Common.builder() + .callApiPath(apiPath) + .apiMethod(methodType) + .srcIp(srcIp) + .deviceInfo(deviceInfo) + .memberId(memberId) + .ledgerCode(ledgerCode) + .transactionGroupId(transactionGroupId) + .retryCount(0) + .build(); + + return LogMessage.builder() + .logLevel(logLevel.name()) + .timestamp(String.valueOf(System.currentTimeMillis())) + .txId(txId) + .message(message) + .data(common) + .errorMessage(errorMessage) + .build(); + } + + public static LogMessage buildLogMessage( + LogLevel logLevel, + String txId, + String message, + Common common, + String errorMessage + ) { + common.setRetryCount(0); + return LogMessage.builder() + .logLevel(logLevel.name()) + .timestamp(String.valueOf(System.currentTimeMillis())) + .txId(txId) + .message(message) + .data(common) + .errorMessage(errorMessage) + .build(); + } +} + diff --git a/src/main/java/org/scoula/global/response/ApiResponse.java b/src/main/java/org/scoula/global/response/ApiResponse.java deleted file mode 100644 index a22abd95..00000000 --- a/src/main/java/org/scoula/global/response/ApiResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.scoula.global.response; - -import lombok.Getter; - -@Getter -public class ApiResponse { - private final T data; - private final int status; - private final String message; - - private ApiResponse(T data, int status, String message) { - this.data = data; - this.status = status; - this.message = message; - } - - public static ApiResponse ok(T data) { - return new ApiResponse<>(data, 200, "OK"); - } - - public static ApiResponse created(T data) { - return new ApiResponse<>(data, 201, "CREATED"); - } - - public static ApiResponse noContent() { - return new ApiResponse<>(null, 204, "NO_CONTENT"); - } -} diff --git a/src/main/java/org/scoula/global/response/SuccessResponse.java b/src/main/java/org/scoula/global/response/SuccessResponse.java new file mode 100644 index 00000000..be56ebf2 --- /dev/null +++ b/src/main/java/org/scoula/global/response/SuccessResponse.java @@ -0,0 +1,28 @@ +package org.scoula.global.response; + +import lombok.Getter; + +@Getter +public class SuccessResponse { + private final T data; + private final int status; + private final String message; + + private SuccessResponse(T data, int status, String message) { + this.data = data; + this.status = status; + this.message = message; + } + + public static SuccessResponse ok(T data) { + return new SuccessResponse<>(data, 200, "OK"); + } + + public static SuccessResponse created(T data) { + return new SuccessResponse<>(data, 201, "CREATED"); + } + + public static SuccessResponse noContent() { + return new SuccessResponse<>(null, 204, "NO_CONTENT"); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 14d41348..5cd12e8c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,4 +1,6 @@ jdbc.driver=net.sf.log4jdbc.sql.jdbcapi.DriverSpy -jdbc.url= ${DB_URL} -jdbc.username= ${DB_USERNAME} -jdbc.password= ${DB_PASSWORD} \ No newline at end of file +jdbc.url=${DB_URL} +jdbc.username=${DB_USERNAME} +jdbc.password=${DB_PASSWORD} +spring.kafka.bootstrap-servers=${KAFKA_BROKER_SERVER} +spring.kafka.log-topic=${KAFKA_LOG_TOPIC} \ No newline at end of file From 8e3284283da0a9881d3466d77e60c9090a7ef69e Mon Sep 17 00:00:00 2001 From: Junbeom Lee <63588899+ss7622@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:06:47 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[Feature]:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83,=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: redis 관련 설정 및 jwt 시크릿 환경변수 추가 * feat: 스프링 시큐리티 의존성, 레디스 의존성, jwt 의존성, validate 의존성 추가 * feat: redis 기본 설정 추가 * feat: security 기본 설정 추가 * feat: 빈 등록 코드 추가 * feat: 존재하지 않는 경로 에러 핸들러 추가 * feat: load user by username 메서드 오버라이딩 * feat: authenticate 메서드 오버라이딩 * style: 불필요한 주석 제거 * feat: 토큰 관련 예외 추가 * feat: 토큰 기본 유틸 클래스 코드 작성 * feat: 토큰 검증 코드 추가 * feat: 쿠키 설정 코드 추가 * feat: 404 not found 에러 코드 추가 * feat: 로그인, 로그아웃, 토큰 재발급 서비스 레이어 추가 * feat: 시큐리티 필터 동작 중 발생 예외 처리 EntryPoint 클래스 추가 * feat: 컨트롤러 코드 추가 * feat: 스웨거 사용을 위한 허용 경로 추가 * feat: redis ssl 연결 설정 추가 --- .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/dataSources.xml | 12 ++ .idea/uiDesigner.xml | 124 ++++++++++++++ build.gradle | 22 ++- .../member/controller/AuthController.java | 68 ++++++++ .../scoula/domain/member/dto/MemberDTO.java | 13 +- .../dto/request/ReissueTokenRequestDto.java | 16 ++ .../member/dto/request/SignInRequestDto.java | 11 ++ .../dto/response/RefreshResponseDto.java | 6 + .../dto/response/SignInResponseDto.java | 36 +++++ .../member/service/MemberAuthService.java | 19 +++ .../member/service/MemberAuthServiceImpl.java | 81 ++++++++++ .../member/service/MemberServiceImpl.java | 6 +- .../global/common/config/RootConfig.java | 16 +- .../global/common/config/ServletConfig.java | 64 ++++---- .../global/common/config/WebConfig.java | 63 ++++---- .../exception/GlobalExceptionHandler.java | 21 ++- .../exception/errorCode/CommonErrorCode.java | 1 + .../global/kafka/producer/KafkaProducer.java | 1 - .../global/redis/config/RedisConfig.java | 62 +++++++ .../scoula/global/redis/util/RedisUtil.java | 68 ++++++++ .../security/config/SecurityConfig.java | 88 ++++++++++ .../security/config/SecurityInitializer.java | 6 + .../security/dto/CustomUserDetails.java | 58 +++++++ .../scoula/global/security/dto/JwtToken.java | 14 ++ .../CustomAuthenticationEntryPoint.java | 66 ++++++++ .../exception/JwtAuthenticationException.java | 10 ++ .../security/exception/JwtErrorMessage.java | 26 +++ .../filter/JwtAuthenticationFilter.java | 74 +++++++++ .../global/security/util/CookieUtil.java | 36 +++++ .../security/util/JwtTokenProvider.java | 153 ++++++++++++++++++ .../UsernamePwdAuthenticationProvider.java | 71 ++++++++ src/main/resources/application.properties | 6 +- 33 files changed, 1252 insertions(+), 71 deletions(-) create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/uiDesigner.xml create mode 100644 src/main/java/org/scoula/domain/member/controller/AuthController.java create mode 100644 src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java create mode 100644 src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java create mode 100644 src/main/java/org/scoula/domain/member/dto/response/RefreshResponseDto.java create mode 100644 src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java create mode 100644 src/main/java/org/scoula/domain/member/service/MemberAuthService.java create mode 100644 src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java create mode 100644 src/main/java/org/scoula/global/redis/config/RedisConfig.java create mode 100644 src/main/java/org/scoula/global/redis/util/RedisUtil.java create mode 100644 src/main/java/org/scoula/global/security/config/SecurityConfig.java create mode 100644 src/main/java/org/scoula/global/security/config/SecurityInitializer.java create mode 100644 src/main/java/org/scoula/global/security/dto/CustomUserDetails.java create mode 100644 src/main/java/org/scoula/global/security/dto/JwtToken.java create mode 100644 src/main/java/org/scoula/global/security/exception/CustomAuthenticationEntryPoint.java create mode 100644 src/main/java/org/scoula/global/security/exception/JwtAuthenticationException.java create mode 100644 src/main/java/org/scoula/global/security/exception/JwtErrorMessage.java create mode 100644 src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/org/scoula/global/security/util/CookieUtil.java create mode 100644 src/main/java/org/scoula/global/security/util/JwtTokenProvider.java create mode 100644 src/main/java/org/scoula/global/security/util/UsernamePwdAuthenticationProvider.java diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..9a295ca7 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 6e060a2f..17b95c9c 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -25,5 +25,17 @@ $ProjectFileDir$ + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://dolfin-database.cf400cwq8ly6.ap-northeast-2.rds.amazonaws.com:3306/dolfin + + + + + + $ProjectFileDir$ + \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 00000000..2b63946d --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7e8bb389..4d0d4c6f 100644 --- a/build.gradle +++ b/build.gradle @@ -75,7 +75,7 @@ dependencies { implementation "org.apache.logging.log4j:log4j-api:${log4j2Version}" implementation "org.apache.logging.log4j:log4j-core:${log4j2Version}" implementation "org.apache.logging.log4j:log4j-slf4j-impl:${log4j2Version}" - implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4:1.16' + implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16' // xml내 한글 처리 implementation 'xerces:xercesImpl:2.12.2' @@ -116,6 +116,26 @@ dependencies { //KAFKA implementation "org.springframework.kafka:spring-kafka:${springKafkaVersion}" + + // 스프링 시큐리티 + implementation 'org.springframework.security:spring-security-config:5.8.10' + implementation 'org.springframework.security:spring-security-web:5.8.10' + implementation 'org.springframework.security:spring-security-core:5.8.10' + + // JWT (JJWT) + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Redis (Lettuce 기반 클라이언트) + implementation 'org.springframework.data:spring-data-redis:2.7.18' + implementation 'io.lettuce:lettuce-core:6.3.1.RELEASE' + + // Bean Validation (JSR-380) + implementation 'javax.validation:validation-api:2.0.1.Final' + implementation 'org.hibernate.validator:hibernate-validator:6.0.20.Final' + implementation 'org.glassfish:javax.el:3.0.0' + } diff --git a/src/main/java/org/scoula/domain/member/controller/AuthController.java b/src/main/java/org/scoula/domain/member/controller/AuthController.java new file mode 100644 index 00000000..06e8d817 --- /dev/null +++ b/src/main/java/org/scoula/domain/member/controller/AuthController.java @@ -0,0 +1,68 @@ +package org.scoula.domain.member.controller; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.scoula.domain.member.dto.request.ReissueTokenRequestDto; +import org.scoula.domain.member.dto.request.SignInRequestDto; +import org.scoula.domain.member.dto.response.RefreshResponseDto; +import org.scoula.domain.member.dto.response.SignInResponseDto; +import org.scoula.domain.member.service.MemberAuthService; +import org.scoula.global.response.SuccessResponse; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +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 io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +@Api(tags = "인증 API", description = "로그인, 로그아웃, 토큰 재발급 등의 인증 관련 API") +public class AuthController { + + private final MemberAuthService memberAuthService; + + @ApiOperation(value = "로그인", notes = "아이디와 비밀번호를 이용해 로그인을 수행합니다.") + @ApiResponses({ + @ApiResponse(code = 401, message = "아이디 또는 비밀번호가 일치하지 않습니다.") + }) + @PostMapping("/signin") + public SuccessResponse signIn(@RequestBody SignInRequestDto requestDto, + HttpServletResponse response, HttpServletRequest request) { + SignInResponseDto signInResponseDto = memberAuthService.signIn(requestDto, request, response); + return SuccessResponse.ok(signInResponseDto); + } + + @ApiOperation(value = "토큰 재발급", notes = "Refresh Token을 이용하여 Access Token을 재발급합니다. " + + "로그인한 회원이 뭐든 api를 호출해 사용 중에 토큰 재발급이 필요합니다. 라는 메세지와 401 코드가 반환되면 토큰 현재 refresh api를 호출해 토큰 재발급 시키면 됩니다.\"\n" + + "\n 만약 리프레시를 시도했는데, 로그인이 필요한 서비스입니다. 라는 메세지와 400 코드가 반환되면 로그인 페이지로 넘기면 됩니다. ") + @ApiResponses({ + @ApiResponse(code = 400, message = "로그인이 필요한 서비스입니다.") + }) + @PostMapping("/refresh") + public SuccessResponse refreshToken(@RequestBody ReissueTokenRequestDto requestDto, + @ApiParam(value = "Refresh Token (쿠키)", required = true) + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletRequest request) { + return SuccessResponse.ok(memberAuthService.reissueToken(requestDto, refreshToken, request)); + } + + @ApiOperation(value = "로그아웃", notes = "클라이언트에서 보유한 Refresh Token을 만료시키고 로그아웃합니다.") + @ApiResponses({ + @ApiResponse(code = 204, message = "로그아웃 성공") + }) + @GetMapping("/signout") + public SuccessResponse signOut(HttpServletResponse response) { + memberAuthService.signOut(response); + return SuccessResponse.noContent(); + } +} diff --git a/src/main/java/org/scoula/domain/member/dto/MemberDTO.java b/src/main/java/org/scoula/domain/member/dto/MemberDTO.java index e78a7cf3..81e33019 100644 --- a/src/main/java/org/scoula/domain/member/dto/MemberDTO.java +++ b/src/main/java/org/scoula/domain/member/dto/MemberDTO.java @@ -1,5 +1,10 @@ package org.scoula.domain.member.dto; +import java.time.LocalDate; + +import org.scoula.domain.member.entity.Member; +import org.springframework.security.crypto.password.PasswordEncoder; + import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.AllArgsConstructor; @@ -7,10 +12,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import org.scoula.domain.member.entity.Member; - -import java.time.LocalDate; - @Getter @NoArgsConstructor @AllArgsConstructor @@ -80,6 +81,10 @@ public Member toEntity() { .build(); } + public void PasswordEncrypt(PasswordEncoder passwordEncoder) { + password = passwordEncoder.encode(this.password); + } + public void changePassword(String newPassword) { this.password = newPassword; } diff --git a/src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java b/src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java new file mode 100644 index 00000000..4644462e --- /dev/null +++ b/src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java @@ -0,0 +1,16 @@ +package org.scoula.domain.member.dto.request; + +import javax.validation.constraints.NotBlank; + +import io.swagger.annotations.ApiModelProperty; + +public record ReissueTokenRequestDto( + @ApiModelProperty(value = "회원 고유 id", example = "1") + @NotBlank(message = "멤버 고유 아이디는 필수 입니다.") + Long memberId, + + @ApiModelProperty(value = "회원 로그인 아이디", example = "ss7622") + @NotBlank(message = "로그인 아이디는 필수 입니다.") + String loginId +) { +} diff --git a/src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java b/src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java new file mode 100644 index 00000000..92ae3779 --- /dev/null +++ b/src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java @@ -0,0 +1,11 @@ +package org.scoula.domain.member.dto.request; + +import io.swagger.annotations.ApiModelProperty; + +public record SignInRequestDto( + @ApiModelProperty(value = "회원 로그인 아이디", example = "ss7622") + String loginId, + @ApiModelProperty(value = "회원 비밀번호", example = "password") + String password +) { +} diff --git a/src/main/java/org/scoula/domain/member/dto/response/RefreshResponseDto.java b/src/main/java/org/scoula/domain/member/dto/response/RefreshResponseDto.java new file mode 100644 index 00000000..fda29e9f --- /dev/null +++ b/src/main/java/org/scoula/domain/member/dto/response/RefreshResponseDto.java @@ -0,0 +1,6 @@ +package org.scoula.domain.member.dto.response; + +public record RefreshResponseDto( + String accessToken +) { +} diff --git a/src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java b/src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java new file mode 100644 index 00000000..cc754b8d --- /dev/null +++ b/src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java @@ -0,0 +1,36 @@ +package org.scoula.domain.member.dto.response; + +import java.time.LocalDate; + +import org.scoula.domain.member.dto.MemberDTO; +import org.scoula.global.security.dto.JwtToken; + +import io.swagger.annotations.ApiModelProperty; + +public record SignInResponseDto( + String grantType, + String accessToken, + @ApiModelProperty(value = "회원 ID", example = "1") + Long memberId, + @ApiModelProperty(value = "여권번호", example = "M12345678") + String passportNumber, + @ApiModelProperty(value = "국적", example = "KOR") + String nationality, + @ApiModelProperty(value = "생년월일") + LocalDate birth, + @ApiModelProperty(value = "성명", example = "홍길동", required = true) + String name, + @ApiModelProperty(value = "전화번호", example = "010-1234-5678") + String phoneNumber, + @ApiModelProperty(value = "잔여 체류기간") + LocalDate remainTime, + @ApiModelProperty(value = "설정 통화", example = "USD") + String currency) { + public static SignInResponseDto from(JwtToken token, MemberDTO member) { + + return new SignInResponseDto(token.getGrantType(), + token.getAccessToken(), + member.getMemberId(), member.getPassportNumber(), member.getNationality(), member.getBirth(), + member.getName(), member.getPhoneNumber(), member.getRemainTime(), member.getCurrency()); + } +} diff --git a/src/main/java/org/scoula/domain/member/service/MemberAuthService.java b/src/main/java/org/scoula/domain/member/service/MemberAuthService.java new file mode 100644 index 00000000..fcc2f2de --- /dev/null +++ b/src/main/java/org/scoula/domain/member/service/MemberAuthService.java @@ -0,0 +1,19 @@ +package org.scoula.domain.member.service; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.scoula.domain.member.dto.request.ReissueTokenRequestDto; +import org.scoula.domain.member.dto.request.SignInRequestDto; +import org.scoula.domain.member.dto.response.RefreshResponseDto; +import org.scoula.domain.member.dto.response.SignInResponseDto; + +public interface MemberAuthService { + SignInResponseDto signIn(SignInRequestDto signInRequestDto, HttpServletRequest request, + HttpServletResponse response); + + void signOut(HttpServletResponse response); + + public RefreshResponseDto reissueToken(ReissueTokenRequestDto reissueTokenRequestDto, String refreshToken, + HttpServletRequest request); +} diff --git a/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java b/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java new file mode 100644 index 00000000..019edbe7 --- /dev/null +++ b/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java @@ -0,0 +1,81 @@ +package org.scoula.domain.member.service; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.scoula.domain.member.dto.MemberDTO; +import org.scoula.domain.member.dto.request.ReissueTokenRequestDto; +import org.scoula.domain.member.dto.request.SignInRequestDto; +import org.scoula.domain.member.dto.response.RefreshResponseDto; +import org.scoula.domain.member.dto.response.SignInResponseDto; +import org.scoula.global.exception.CustomException; +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; +import org.scoula.global.redis.util.RedisUtil; +import org.scoula.global.security.dto.JwtToken; +import org.scoula.global.security.exception.JwtErrorMessage; +import org.scoula.global.security.util.CookieUtil; +import org.scoula.global.security.util.JwtTokenProvider; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class MemberAuthServiceImpl implements MemberAuthService { + + private final MemberService memberService; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final JwtTokenProvider jwtTokenProvider; + private final RedisUtil redisUtil; + + @Override + public SignInResponseDto signIn(SignInRequestDto signInRequestDto, HttpServletRequest request, + HttpServletResponse response) { + + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + signInRequestDto.loginId(), + signInRequestDto.password()); + + authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + // authenticate 메서드 내부에서 이미 존재하는지 검증이 되기 때문에 한 번 더 검증을 해주지 않았음 + // authenticate 에서 조회한 멤버 정보를 가져올 방법을 생각해 쿼리를 한 번 줄이면 좋을 듯 + MemberDTO member = memberService.getMemberByLoginId(signInRequestDto.loginId()); + + JwtToken jwtToken = jwtTokenProvider.generateToken(member.getMemberId(), member.getLoginId()); + CookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken()); + + return SignInResponseDto.from(jwtToken, member); + + } + + @Override + public void signOut(HttpServletResponse response) { + CookieUtil.deleteCookie(response, "refreshToken"); + } + + @Override + public RefreshResponseDto reissueToken(ReissueTokenRequestDto reissueTokenRequestDto, String refreshToken, + HttpServletRequest request) { + Object refreshTokenByRedis = redisUtil.get(reissueTokenRequestDto.loginId()); + if (!refreshTokenByRedis.equals(refreshToken)) { + throw new CustomException(JwtErrorMessage.NOT_MATCH_REFRESH_TOKEN, LogLevel.WARNING, + request.getHeader("txId"), Common.builder() + .srcIp(request.getRemoteAddr()) + .callApiPath(request.getRequestURI()) + .deviceInfo(request.getHeader("user-agent")) + .retryCount(0) + .build()); + } + + JwtToken jwtToken = jwtTokenProvider.generateToken(reissueTokenRequestDto.memberId(), + reissueTokenRequestDto.loginId()); + return new RefreshResponseDto(jwtToken.getAccessToken()); + } + +} diff --git a/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java b/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java index 96294e47..4d62fb16 100644 --- a/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java @@ -11,7 +11,7 @@ import org.scoula.global.exception.CustomException; import org.scoula.global.kafka.dto.Common; import org.scoula.global.kafka.dto.LogLevel; -import org.scoula.global.kafka.util.LogMessageMapper; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,7 +25,7 @@ public class MemberServiceImpl implements MemberService { private final MemberMapper memberMapper; - private final LogMessageMapper logMessageMapper; + private final PasswordEncoder passwordEncoder; @Override public List getAllMembers() { @@ -65,8 +65,8 @@ public MemberDTO createMember(MemberDTO memberDTO) { if (checkLoginIdDuplicate(memberDTO.getLoginId())) { throw new CustomException(MEMBER_ALREADY_EXISTS, LogLevel.WARNING, null, Common.builder().build()); } + memberDTO.PasswordEncrypt(passwordEncoder); - // 임시로 비밀번호를 그대로 저장 (나중에 암호화 추가) Member member = memberDTO.toEntity(); memberMapper.insertMember(member); diff --git a/src/main/java/org/scoula/global/common/config/RootConfig.java b/src/main/java/org/scoula/global/common/config/RootConfig.java index 975a17eb..f239caa9 100644 --- a/src/main/java/org/scoula/global/common/config/RootConfig.java +++ b/src/main/java/org/scoula/global/common/config/RootConfig.java @@ -14,6 +14,9 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; @@ -26,7 +29,9 @@ "org.scoula.domain.member.service", // 도메인 객체를 포함하기 위해 추가 "org.scoula.global.swagger.config", // Swagger 설정을 포함하기 위해 추가 "org.scoula.global.kafka", // kafka 설정 포함 - "org.scoula.global.exception" // exception handler 등록 + "org.scoula.global.exception", // exception handler 등록 + "org.scoula.global.redis", + "org.scoula.global.security" }) public class RootConfig { @Value("${jdbc.driver}") @@ -91,4 +96,13 @@ public DataSourceTransactionManager transactionManager() { return new DataSourceTransactionManager(dataSource()); } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public HandlerMappingIntrospector mvcHandlerMappingIntrospector() { + return new HandlerMappingIntrospector(); + } } diff --git a/src/main/java/org/scoula/global/common/config/ServletConfig.java b/src/main/java/org/scoula/global/common/config/ServletConfig.java index 097e125d..a9e34c37 100644 --- a/src/main/java/org/scoula/global/common/config/ServletConfig.java +++ b/src/main/java/org/scoula/global/common/config/ServletConfig.java @@ -11,38 +11,38 @@ @Configuration @EnableWebMvc -@ComponentScan(basePackages = {"org.scoula.domain.controller"}) +@ComponentScan(basePackages = {"org.scoula.domain.controller", "org.scoula.domain.member.controller"}) public class ServletConfig implements WebMvcConfigurer { - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry - .addResourceHandler("/resources/**") // url이 /resources/로 시작하는 모든 경로 - .addResourceLocations("/resources/"); // webapp/resources/경로로 매핑 - - // Swagger UI 리소스를 위한 핸들러 설정 - registry.addResourceHandler("/swagger-ui.html") - .addResourceLocations("classpath:/META-INF/resources/"); - - // Swagger WebJar 리소스 설정 - registry.addResourceHandler("/webjars/**") - .addResourceLocations("classpath:/META-INF/resources/webjars/"); - - // Swagger 리소스 설정 - registry.addResourceHandler("/swagger-resources/**") - .addResourceLocations("classpath:/META-INF/resources/"); - - registry.addResourceHandler("/v2/api-docs") - .addResourceLocations("classpath:/META-INF/resources/"); - } - - // jsp view resolver 설정 - @Override - public void configureViewResolvers(ViewResolverRegistry registry) { - InternalResourceViewResolver bean = new InternalResourceViewResolver(); - bean.setViewClass(JstlView.class); - bean.setPrefix("/WEB-INF/views/"); - bean.setSuffix(".jsp"); - registry.viewResolver(bean); - } + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry + .addResourceHandler("/resources/**") // url이 /resources/로 시작하는 모든 경로 + .addResourceLocations("/resources/"); // webapp/resources/경로로 매핑 + + // Swagger UI 리소스를 위한 핸들러 설정 + registry.addResourceHandler("/swagger-ui.html") + .addResourceLocations("classpath:/META-INF/resources/"); + + // Swagger WebJar 리소스 설정 + registry.addResourceHandler("/webjars/**") + .addResourceLocations("classpath:/META-INF/resources/webjars/"); + + // Swagger 리소스 설정 + registry.addResourceHandler("/swagger-resources/**") + .addResourceLocations("classpath:/META-INF/resources/"); + + registry.addResourceHandler("/v2/api-docs") + .addResourceLocations("classpath:/META-INF/resources/"); + } + + // jsp view resolver 설정 + @Override + public void configureViewResolvers(ViewResolverRegistry registry) { + InternalResourceViewResolver bean = new InternalResourceViewResolver(); + bean.setViewClass(JstlView.class); + bean.setPrefix("/WEB-INF/views/"); + bean.setSuffix(".jsp"); + registry.viewResolver(bean); + } } \ No newline at end of file diff --git a/src/main/java/org/scoula/global/common/config/WebConfig.java b/src/main/java/org/scoula/global/common/config/WebConfig.java index 6296f660..1e49533f 100644 --- a/src/main/java/org/scoula/global/common/config/WebConfig.java +++ b/src/main/java/org/scoula/global/common/config/WebConfig.java @@ -1,36 +1,45 @@ package org.scoula.global.common.config; +import javax.servlet.Filter; + import org.scoula.global.swagger.config.SwaggerConfig; +import org.springframework.web.context.WebApplicationContext; import org.springframework.web.filter.CharacterEncodingFilter; +import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer; -import javax.servlet.Filter; - public class WebConfig extends AbstractAnnotationConfigDispatcherServletInitializer { - @Override - protected Class[] getRootConfigClasses() { - return new Class[] { RootConfig.class }; - } - - @Override - protected Class[] getServletConfigClasses() { - return new Class[] { ServletConfig.class, SwaggerConfig.class }; - } - - // 스프링의 FrontController인 DispatcherServlet이 담당할 Url 매핑 패턴, / : 모든 요청에 대해 매핑 - @Override - protected String[] getServletMappings() { - return new String[]{ - "/" - }; - } - - @Override - protected Filter[] getServletFilters() { - CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter(); - characterEncodingFilter.setEncoding("UTF-8"); - characterEncodingFilter.setForceEncoding(true); - return new Filter[] { characterEncodingFilter }; - } + @Override + protected Class[] getRootConfigClasses() { + return new Class[] {RootConfig.class}; + } + + @Override + protected Class[] getServletConfigClasses() { + return new Class[] {ServletConfig.class, SwaggerConfig.class}; + } + + // 스프링의 FrontController인 DispatcherServlet이 담당할 Url 매핑 패턴, / : 모든 요청에 대해 매핑 + @Override + protected String[] getServletMappings() { + return new String[] { + "/" + }; + } + + @Override + protected Filter[] getServletFilters() { + CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter(); + characterEncodingFilter.setEncoding("UTF-8"); + characterEncodingFilter.setForceEncoding(true); + return new Filter[] {characterEncodingFilter}; + } + + @Override + protected DispatcherServlet createDispatcherServlet(WebApplicationContext servletAppContext) { + DispatcherServlet dispatcherServlet = new DispatcherServlet(servletAppContext); + dispatcherServlet.setThrowExceptionIfNoHandlerFound(true); + return dispatcherServlet; + } } \ No newline at end of file diff --git a/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java b/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java index c0da5137..b89bfd6b 100644 --- a/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/scoula/global/exception/GlobalExceptionHandler.java @@ -17,6 +17,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -94,6 +95,22 @@ public ResponseEntity> handleMissingServletRequestParameterE .body(ErrorResponse.error(errorCode.getCode(), errorCode.getMessage())); } + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity> handleNoHandlerFoundException(NoHandlerFoundException e) { + ErrorCode errorCode = CommonErrorCode.NOT_FOUND; + + kafkaProducer.sendToLogTopic(LogMessageMapper.buildLogMessage( + LogLevel.WARNING, + null, + errorCode.getMessage(), + Common.builder().build(), + e.getMessage() + )); + + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(ErrorResponse.error(errorCode.getCode(), errorCode.getMessage())); + } + /** * 예상하지 못한 모든 예외 처리 */ @@ -109,7 +126,9 @@ public ResponseEntity> handleAllExceptions(Exception e) { e.getMessage() )); + log.error(e.getMessage(), e); + return ResponseEntity.status(errorCode.getHttpStatus()) - .body(ErrorResponse.error(errorCode.getCode(), e.getMessage())); + .body(ErrorResponse.error(errorCode.getCode(), String.valueOf(e))); } } diff --git a/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java b/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java index a6e0b734..bc926180 100644 --- a/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java +++ b/src/main/java/org/scoula/global/exception/errorCode/CommonErrorCode.java @@ -13,6 +13,7 @@ public enum CommonErrorCode implements ErrorCode { INVALID_VALUE(HttpStatus.BAD_REQUEST, "C-001", "입력값이 올바르지 않습니다."), INVALID_ARGUMENT(HttpStatus.BAD_REQUEST, "C-002", "잘못된 인자입니다."), MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "C-005", "필수 파라미터가 누락되었습니다."), + NOT_FOUND(HttpStatus.valueOf(404), "C-003", "존재하지 않는 경로입니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C-006", "내부 에러 메세지 사용 예정"); private final HttpStatus httpStatus; diff --git a/src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java b/src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java index 7a6a8b4a..6c5bb60b 100644 --- a/src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java +++ b/src/main/java/org/scoula/global/kafka/producer/KafkaProducer.java @@ -28,7 +28,6 @@ public void sendToLogTopic(LogMessage logMessage) { try { String message = objectMapper.writeValueAsString(logMessage); kafkaTemplate.send(logTopic, message); - System.out.println("로그 전송 됨 ????ㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇ"); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/src/main/java/org/scoula/global/redis/config/RedisConfig.java b/src/main/java/org/scoula/global/redis/config/RedisConfig.java new file mode 100644 index 00000000..acf6a62e --- /dev/null +++ b/src/main/java/org/scoula/global/redis/config/RedisConfig.java @@ -0,0 +1,62 @@ +package org.scoula.global.redis.config; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import io.lettuce.core.resource.ClientResources; +import io.lettuce.core.resource.DefaultClientResources; + +@Configuration +@PropertySource("classpath:application.properties") +public class RedisConfig { + + @Value("${spring.redis.host}") + private String redisHost; + + @Value("${spring.redis.port}") + private int redisPort; + + @Value("${spring.redis.password}") + private String redisPassword; + + @Bean(destroyMethod = "shutdown") + public ClientResources clientResources() { + return DefaultClientResources.create(); + } + + @Bean + public RedisConnectionFactory redisConnectionFactory(ClientResources clientResources) { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(redisHost); + config.setPort(redisPort); + + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .clientResources(clientResources) + .commandTimeout(Duration.ofSeconds(10)) + .shutdownTimeout(Duration.ZERO) + .useSsl() // tls 연결 활성화 + .build(); + + return new LettuceConnectionFactory(config, clientConfig); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory(clientResources())); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} diff --git a/src/main/java/org/scoula/global/redis/util/RedisUtil.java b/src/main/java/org/scoula/global/redis/util/RedisUtil.java new file mode 100644 index 00000000..51f8fa92 --- /dev/null +++ b/src/main/java/org/scoula/global/redis/util/RedisUtil.java @@ -0,0 +1,68 @@ +package org.scoula.global.redis.util; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class RedisUtil { + + private final RedisTemplate redisTemplate; + + public void set(String key, String o, int minutes) { + redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES); + } + + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + public boolean delete(String key) { + return Boolean.TRUE.equals(redisTemplate.delete(key)); + } + + public boolean hasKey(String key) { + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + + public Long checkExpired(String key) { + Long ttl = redisTemplate.getExpire(key); + return ttl; + } + + public long countResumeKeysWithPrefix() { + ScanOptions options = ScanOptions.scanOptions() + .match("resume:temp:" + "*") + .count(1000) // 한 번에 검색할 키 수 + .build(); + + long count = 0; + try (Cursor cursor = redisTemplate.getConnectionFactory() + .getConnection() + .scan(options)) { + + while (cursor.hasNext()) { + cursor.next(); + count++; + } + } catch (Exception e) { + throw new RuntimeException("Redis scan error", e); + } + + return count; + } + + // prefix 기반 키 삭제 + public void deleteByPrefix(String prefix) { + var keys = redisTemplate.keys(prefix + "*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } +} diff --git a/src/main/java/org/scoula/global/security/config/SecurityConfig.java b/src/main/java/org/scoula/global/security/config/SecurityConfig.java new file mode 100644 index 00000000..9e69c571 --- /dev/null +++ b/src/main/java/org/scoula/global/security/config/SecurityConfig.java @@ -0,0 +1,88 @@ +package org.scoula.global.security.config; + +import java.util.List; + +import org.scoula.global.security.exception.CustomAuthenticationEntryPoint; +import org.scoula.global.security.filter.JwtAuthenticationFilter; +import org.scoula.global.security.util.JwtTokenProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAuthenticationEntryPoint customAuthenticationExceptionHandler; + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + + corsConfiguration.setAllowedOriginPatterns( + List.of("http://localhost:3000", "https://localhost:3000")); + + corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH")); + corsConfiguration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type")); + corsConfiguration.setExposedHeaders(List.of("Set-Cookie")); + corsConfiguration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", corsConfiguration); + + return source; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource( + corsConfigurationSource())); + + http.csrf(AbstractHttpConfigurer::disable); + + http.sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + http.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> + authorizationManagerRequestMatcherRegistry + + // 로그인 관련 api + .requestMatchers("/auth/**").permitAll() + // 스웨거 사용을 위한 허용 + .requestMatchers( + "/v2/api-docs/**", + "/swagger-resources/**", + "/swagger-ui.html/**", + "/swagger-ui/**", + "/webjars/**", + "/configuration/**" + ).permitAll() + .anyRequest().authenticated() + ); + + http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, customAuthenticationExceptionHandler), + UsernamePasswordAuthenticationFilter.class); + + http.formLogin().disable(); + + http.exceptionHandling() + .authenticationEntryPoint(customAuthenticationEntryPoint); + + return http.build(); + } + +} diff --git a/src/main/java/org/scoula/global/security/config/SecurityInitializer.java b/src/main/java/org/scoula/global/security/config/SecurityInitializer.java new file mode 100644 index 00000000..635b127e --- /dev/null +++ b/src/main/java/org/scoula/global/security/config/SecurityInitializer.java @@ -0,0 +1,6 @@ +package org.scoula.global.security.config; + +import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer; + +public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer { +} diff --git a/src/main/java/org/scoula/global/security/dto/CustomUserDetails.java b/src/main/java/org/scoula/global/security/dto/CustomUserDetails.java new file mode 100644 index 00000000..e0fa74c6 --- /dev/null +++ b/src/main/java/org/scoula/global/security/dto/CustomUserDetails.java @@ -0,0 +1,58 @@ +package org.scoula.global.security.dto; + +import java.util.Collection; +import java.util.List; + +import org.scoula.domain.member.entity.Member; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public class CustomUserDetails implements UserDetails { + + private final Member member; + + public CustomUserDetails(Member member) { + this.member = member; + } + + @Override + public Collection getAuthorities() { + String roleName = "ROLE_" + "MEMBER"; + return List.of(new SimpleGrantedAuthority(roleName)); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getLoginId(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + public Member getMember() { + return member; + } +} diff --git a/src/main/java/org/scoula/global/security/dto/JwtToken.java b/src/main/java/org/scoula/global/security/dto/JwtToken.java new file mode 100644 index 00000000..e46a0d41 --- /dev/null +++ b/src/main/java/org/scoula/global/security/dto/JwtToken.java @@ -0,0 +1,14 @@ +package org.scoula.global.security.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@AllArgsConstructor +@Builder +public class JwtToken { + private String grantType; + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/org/scoula/global/security/exception/CustomAuthenticationEntryPoint.java b/src/main/java/org/scoula/global/security/exception/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..f57a2f8d --- /dev/null +++ b/src/main/java/org/scoula/global/security/exception/CustomAuthenticationEntryPoint.java @@ -0,0 +1,66 @@ +package org.scoula.global.security.exception; + +import static org.scoula.global.security.exception.JwtErrorMessage.*; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.scoula.global.response.ErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + + if (request.getAttribute("signOutException") != null) { + setResponse(response, SIGN_OUT_USER.getHttpStatus(), SIGN_OUT_USER.getCode(), SIGN_OUT_USER.getMessage()); + } else if (request.getAttribute("cannotUseRefreshToken") != null) { + setResponse(response, CANNOT_USE_REFRESH_TOKEN.getHttpStatus(), CANNOT_USE_REFRESH_TOKEN.getCode(), + CANNOT_USE_REFRESH_TOKEN.getMessage()); + } else if (request.getAttribute("invalidJwtToken") != null) { + setResponse(response, INVALID_JWT_TOKEN.getHttpStatus(), INVALID_JWT_TOKEN.getCode(), + INVALID_JWT_TOKEN.getMessage()); + } else if (request.getAttribute("expiredJwtToken") != null) { + setResponse(response, NEED_ACCESS_TOKEN_REFRESH.getHttpStatus(), NEED_ACCESS_TOKEN_REFRESH.getCode(), + NEED_ACCESS_TOKEN_REFRESH.getMessage()); + } else if (request.getAttribute("notExistAccessToken") != null) { + setResponse(response, NEED_ACCESS_TOKEN_REFRESH.getHttpStatus(), NEED_ACCESS_TOKEN_REFRESH.getCode(), + NEED_ACCESS_TOKEN_REFRESH.getMessage()); + } else if (request.getAttribute("unsupportedJwtToken") != null) { + setResponse(response, UNSUPPORTED_JWT_TOKEN.getHttpStatus(), UNSUPPORTED_JWT_TOKEN.getCode(), + UNSUPPORTED_JWT_TOKEN.getMessage()); + } else if (request.getAttribute("cannotFindMemberIDInJWTToken") != null) { + setResponse(response, IN_JWT_NOT_CONTAINS_USER_INFO.getHttpStatus(), + IN_JWT_NOT_CONTAINS_USER_INFO.getCode(), + IN_JWT_NOT_CONTAINS_USER_INFO.getMessage()); + } else { + // 나머지 잘못된 요청에 대한 기본 예외 처리 + setResponse(response, HttpStatus.UNAUTHORIZED, "F-001", + "허용되지 않은 권한 또는 허용되지 않은 경로입니다."); // 기본적으로 401 상태 코드와 에러 메시지 반환 + } + + } + + // 발생한 예외에 맞게 status를 설정하고 message를 반환 + private void setResponse(HttpServletResponse response, HttpStatus status, String code, String message) throws + IOException { + response.setStatus(status.value()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + String json = new ObjectMapper().writeValueAsString(ErrorResponse.error(code, message)); + response.getWriter().write(json); + } + +} diff --git a/src/main/java/org/scoula/global/security/exception/JwtAuthenticationException.java b/src/main/java/org/scoula/global/security/exception/JwtAuthenticationException.java new file mode 100644 index 00000000..f09f6671 --- /dev/null +++ b/src/main/java/org/scoula/global/security/exception/JwtAuthenticationException.java @@ -0,0 +1,10 @@ +package org.scoula.global.security.exception; + +import org.springframework.security.core.AuthenticationException; + +public class JwtAuthenticationException extends AuthenticationException { + public JwtAuthenticationException(String msg) { + super(msg); + } +} + diff --git a/src/main/java/org/scoula/global/security/exception/JwtErrorMessage.java b/src/main/java/org/scoula/global/security/exception/JwtErrorMessage.java new file mode 100644 index 00000000..388b940d --- /dev/null +++ b/src/main/java/org/scoula/global/security/exception/JwtErrorMessage.java @@ -0,0 +1,26 @@ +package org.scoula.global.security.exception; + +import org.scoula.global.exception.errorCode.ErrorCode; +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum JwtErrorMessage implements ErrorCode { + + INVALID_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "J-001", "유효하지 않은 JWT 토큰 입니다."), + NEED_ACCESS_TOKEN_REFRESH(HttpStatus.UNAUTHORIZED, "J-002", "토큰 재발급이 필요합니다."), + CANNOT_USE_REFRESH_TOKEN(HttpStatus.valueOf(400), "J-003", "리프레쉬 토큰은 사용할 수 없습니다."), + UNSUPPORTED_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "J-004", "지원하지 않는 유형의 JWT 토큰입니다."), + NOT_MATCH_REFRESH_TOKEN(HttpStatus.valueOf(400), "J-005", "로그인이 필요한 서비스입니다."), + SIGN_OUT_USER(HttpStatus.valueOf(400), "J-006", "이미 로그아웃한 사용자 입니다."), + CLAIMS_IS_EMPTY(HttpStatus.UNAUTHORIZED, "J-007", "Claims 내부에 값이 들어있지 않습니다."), + IN_JWT_NOT_CONTAINS_USER_INFO(HttpStatus.UNAUTHORIZED, "J-008", "토큰에 유저 정보가 담겨있지 않습니다."), + SIGN_IN_FAIL(HttpStatus.valueOf(401), "J-009", "아이디 또는 비밀번호가 일치하지 않습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..b7eac0eb --- /dev/null +++ b/src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,74 @@ +package org.scoula.global.security.filter; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.scoula.global.security.exception.CustomAuthenticationEntryPoint; +import org.scoula.global.security.exception.JwtAuthenticationException; +import org.scoula.global.security.util.JwtTokenProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + // 1. Request Header에서 JWT 토큰 추출 + String token = resolveToken(request); + + // 2. 새로고침 시 메모리에 저장된 액세스 토큰이 사라졌을 시 발생시키는 에러 + if (token == null) { + request.setAttribute("notExistAccessToken", 401); + customAuthenticationEntryPoint.commence(request, response, + new JwtAuthenticationException("notExistAccessToken")); + return; + } + + // 3. validateToken으로 토큰 유효성 검사 + try { + if (jwtTokenProvider.validateToken(request, response, token)) { + // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장 + Authentication authentication = jwtTokenProvider.getAuthentication(request, response, token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (JwtAuthenticationException e) { + customAuthenticationEntryPoint.commence(request, response, e); + return; + } + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String requestURI = request.getRequestURI(); + // 해당 토큰 검증 필터가 적용되지 않게 하려는 api 경로 + return requestURI.startsWith("/auth") || + requestURI.startsWith("/swagger") || + requestURI.startsWith("/v2") || + requestURI.startsWith("/webjars") || + requestURI.startsWith("/configuration"); + } + + // Request Header에서 토큰 정보 추출 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { + return bearerToken.substring(7); + } + return null; + } + +} diff --git a/src/main/java/org/scoula/global/security/util/CookieUtil.java b/src/main/java/org/scoula/global/security/util/CookieUtil.java new file mode 100644 index 00000000..a38d3cdf --- /dev/null +++ b/src/main/java/org/scoula/global/security/util/CookieUtil.java @@ -0,0 +1,36 @@ +package org.scoula.global.security.util; + +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + private static final int MAX_AGE_OF_COOKIE = 3 * 24 * 60 * 60; + + public static void setCookie(HttpServletResponse response, String name, String value) { + String cookieString = name + "=" + value + + "; Max-Age=" + MAX_AGE_OF_COOKIE + + "; Path=/" + + "; Secure" + + "; HttpOnly" + + "; SameSite=None" + + "; Partitioned"; + + response.setHeader("Set-Cookie", cookieString); + } + + public static void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, "value") + .maxAge(0) + .path("/") + .secure(true) + .httpOnly(true) + .sameSite("None") + .build(); + + response.setHeader("Set-Cookie", cookie.toString()); + } +} diff --git a/src/main/java/org/scoula/global/security/util/JwtTokenProvider.java b/src/main/java/org/scoula/global/security/util/JwtTokenProvider.java new file mode 100644 index 00000000..117238ba --- /dev/null +++ b/src/main/java/org/scoula/global/security/util/JwtTokenProvider.java @@ -0,0 +1,153 @@ +package org.scoula.global.security.util; + +import java.io.IOException; +import java.security.Key; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.scoula.domain.member.dto.MemberDTO; +import org.scoula.domain.member.service.MemberService; +import org.scoula.global.redis.util.RedisUtil; +import org.scoula.global.security.dto.CustomUserDetails; +import org.scoula.global.security.dto.JwtToken; +import org.scoula.global.security.exception.JwtAuthenticationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +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 JwtTokenProvider { + + private static final long ACCESS_TOKEN_MAX_AGE = 1000L * 60 * 30; // 30분 + private static final long REFRESH_TOKEN_MAX_AGE = 1000L * 60 * 60 * 24 * 3; // 3일 + private static final int REDIS_REFRESH_TOKEN_MAX_AGE = 60 * 24 * 3; // 3일 minute 단위 + + private final Key key; + private final MemberService memberService; + private final RedisUtil redisUtil; + + public JwtTokenProvider(@Value("${JWT_SECRET}") String secretKey, MemberService memberService, + RedisUtil redisUtil) { + this.memberService = memberService; + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + this.redisUtil = redisUtil; + } + + // Member 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드 + public JwtToken generateToken(Long memberId, String loginId) { + + long now = (new Date()).getTime(); + + // Access Token 생성 + Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_MAX_AGE); + String accessToken = Jwts.builder() + .setSubject(String.valueOf(memberId)) + .claim("auth", "ROLE_" + "MEMBER") + .setExpiration(accessTokenExpiresIn) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + // Refresh Token 생성 + String refreshToken = Jwts.builder() + .setExpiration(new Date(now + REFRESH_TOKEN_MAX_AGE)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + redisUtil.set(loginId, refreshToken, REDIS_REFRESH_TOKEN_MAX_AGE); + + return JwtToken.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + // Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드 + public Authentication getAuthentication(HttpServletRequest request, HttpServletResponse response, + String accessToken) throws + ServletException, + IOException, JwtAuthenticationException { + // Jwt 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get("auth") == null) { + request.setAttribute("cannotUseRefreshToken", 400); + throw new JwtAuthenticationException("cannotUseRefreshToken"); + } + + // 클레임에서 권한 정보 가져오기 + Collection authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority((String)claims.get("auth"))); + + // CustomUserDetails 객체를 만들어서 Authentication return + MemberDTO member = memberService.getMemberById(Long.valueOf(claims.getSubject())); + if (member == null) { + request.setAttribute("cannotFindMemberIDInJWTToken", 400); + throw new JwtAuthenticationException("cannotUseRefreshToken"); + } + CustomUserDetails userDetails = new CustomUserDetails(member.toEntity()); + + // 3) AuthenticationToken 에 principal 로 CustomUserDetails 넣기 + return new UsernamePasswordAuthenticationToken( + userDetails, // <- CustomUserDetails + accessToken, // credentials 필드에 토큰을 넣어도 되고, null 여도 됩니다 + userDetails.getAuthorities() // 권한 + ); + } + + // 토큰 정보를 검증하는 메서드 + public boolean validateToken(HttpServletRequest request, HttpServletResponse response, String token) throws + JwtAuthenticationException { + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return true; + } catch (SecurityException | MalformedJwtException e) { + request.setAttribute("invalidJwtToken", 401); + throw new JwtAuthenticationException("cannotUseRefreshToken"); + } catch (ExpiredJwtException e) { + request.setAttribute("expiredJwtToken", 401); + throw new JwtAuthenticationException("cannotUseRefreshToken"); + } catch (UnsupportedJwtException e) { + request.setAttribute("unsupportedJwtToken", 401); + throw new JwtAuthenticationException("cannotUseRefreshToken"); + } catch (Exception e) { + throw new JwtAuthenticationException("cannotUseRefreshToken"); + } + } + + // accessToken + private 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/org/scoula/global/security/util/UsernamePwdAuthenticationProvider.java b/src/main/java/org/scoula/global/security/util/UsernamePwdAuthenticationProvider.java new file mode 100644 index 00000000..7a24371f --- /dev/null +++ b/src/main/java/org/scoula/global/security/util/UsernamePwdAuthenticationProvider.java @@ -0,0 +1,71 @@ +package org.scoula.global.security.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.scoula.domain.member.dto.MemberDTO; +import org.scoula.domain.member.service.MemberService; +import org.scoula.global.exception.CustomException; +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; +import org.scoula.global.security.exception.JwtErrorMessage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class UsernamePwdAuthenticationProvider implements AuthenticationProvider { + + @Autowired + private MemberService memberService; + @Autowired + private PasswordEncoder passwordEncoder; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + + String username = authentication.getName(); + String pwd = authentication.getCredentials().toString(); + Optional customer = Optional.ofNullable(memberService.getMemberByLoginId(username)); + + if (customer.isPresent()) { + if (passwordEncoder.matches(pwd, customer.get().getPassword())) { + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_" + "MEMBER")); + return new UsernamePasswordAuthenticationToken(username, pwd, authorities); + + } else { + throw new CustomException(JwtErrorMessage.SIGN_IN_FAIL, LogLevel.WARNING, null, + Common.builder() + .callApiPath("/auth/signin") + .apiMethod("POST") + .memberId(String.valueOf(customer.get().getMemberId())) + .retryCount(0) + .build() + ); + } + } else { + throw new CustomException(JwtErrorMessage.SIGN_IN_FAIL, LogLevel.WARNING, null, + Common.builder() + .callApiPath("/auth/signin") + .apiMethod("POST") + .memberId(String.valueOf(customer.get().getMemberId())) + .retryCount(0) + .build() + ); + } + + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5cd12e8c..36b83172 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,4 +3,8 @@ jdbc.url=${DB_URL} jdbc.username=${DB_USERNAME} jdbc.password=${DB_PASSWORD} spring.kafka.bootstrap-servers=${KAFKA_BROKER_SERVER} -spring.kafka.log-topic=${KAFKA_LOG_TOPIC} \ No newline at end of file +spring.kafka.log-topic=${KAFKA_LOG_TOPIC} +spring.redis.host=${REDIS_HOST} +spring.redis.port=${REDIS_PORT} +spring.redis.password= +jwt.secret=${JWT_SECRET} \ No newline at end of file From f35b7c0b553e69cf9421c5944ae12903d661f6f5 Mon Sep 17 00:00:00 2001 From: Junbeom Lee <63588899+ss7622@users.noreply.github.com> Date: Wed, 23 Jul 2025 13:05:35 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[Feat]:=20valid=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: valid 어노테이션 추가 * feat: NotBlank 추가 --- .../scoula/domain/member/controller/AuthController.java | 9 +++++---- .../member/dto/request/ReissueTokenRequestDto.java | 3 ++- .../domain/member/dto/request/SignInRequestDto.java | 4 ++++ .../scoula/domain/member/service/MemberAuthService.java | 2 +- .../domain/member/service/MemberAuthServiceImpl.java | 3 ++- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/scoula/domain/member/controller/AuthController.java b/src/main/java/org/scoula/domain/member/controller/AuthController.java index 06e8d817..f3bc5f3b 100644 --- a/src/main/java/org/scoula/domain/member/controller/AuthController.java +++ b/src/main/java/org/scoula/domain/member/controller/AuthController.java @@ -2,6 +2,7 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; import org.scoula.domain.member.dto.request.ReissueTokenRequestDto; import org.scoula.domain.member.dto.request.SignInRequestDto; @@ -36,7 +37,7 @@ public class AuthController { @ApiResponse(code = 401, message = "아이디 또는 비밀번호가 일치하지 않습니다.") }) @PostMapping("/signin") - public SuccessResponse signIn(@RequestBody SignInRequestDto requestDto, + public SuccessResponse signIn(@RequestBody @Valid SignInRequestDto requestDto, HttpServletResponse response, HttpServletRequest request) { SignInResponseDto signInResponseDto = memberAuthService.signIn(requestDto, request, response); return SuccessResponse.ok(signInResponseDto); @@ -49,11 +50,11 @@ public SuccessResponse signIn(@RequestBody SignInRequestDto r @ApiResponse(code = 400, message = "로그인이 필요한 서비스입니다.") }) @PostMapping("/refresh") - public SuccessResponse refreshToken(@RequestBody ReissueTokenRequestDto requestDto, + public SuccessResponse refreshToken(@RequestBody @Valid ReissueTokenRequestDto requestDto, @ApiParam(value = "Refresh Token (쿠키)", required = true) @CookieValue(name = "refreshToken", required = false) String refreshToken, - HttpServletRequest request) { - return SuccessResponse.ok(memberAuthService.reissueToken(requestDto, refreshToken, request)); + HttpServletRequest request, HttpServletResponse response) { + return SuccessResponse.ok(memberAuthService.reissueToken(requestDto, refreshToken, request, response)); } @ApiOperation(value = "로그아웃", notes = "클라이언트에서 보유한 Refresh Token을 만료시키고 로그아웃합니다.") diff --git a/src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java b/src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java index 4644462e..5783244a 100644 --- a/src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java +++ b/src/main/java/org/scoula/domain/member/dto/request/ReissueTokenRequestDto.java @@ -1,12 +1,13 @@ package org.scoula.domain.member.dto.request; import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; import io.swagger.annotations.ApiModelProperty; public record ReissueTokenRequestDto( @ApiModelProperty(value = "회원 고유 id", example = "1") - @NotBlank(message = "멤버 고유 아이디는 필수 입니다.") + @NotNull(message = "멤버 고유 아이디는 필수 입니다.") Long memberId, @ApiModelProperty(value = "회원 로그인 아이디", example = "ss7622") diff --git a/src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java b/src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java index 92ae3779..e685ace9 100644 --- a/src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java +++ b/src/main/java/org/scoula/domain/member/dto/request/SignInRequestDto.java @@ -1,11 +1,15 @@ package org.scoula.domain.member.dto.request; +import javax.validation.constraints.NotBlank; + import io.swagger.annotations.ApiModelProperty; public record SignInRequestDto( @ApiModelProperty(value = "회원 로그인 아이디", example = "ss7622") + @NotBlank(message = "아이디는 필수 입력값입니다.") String loginId, @ApiModelProperty(value = "회원 비밀번호", example = "password") + @NotBlank(message = "비밀번호는 필수 입력값입니다.") String password ) { } diff --git a/src/main/java/org/scoula/domain/member/service/MemberAuthService.java b/src/main/java/org/scoula/domain/member/service/MemberAuthService.java index fcc2f2de..02b064d2 100644 --- a/src/main/java/org/scoula/domain/member/service/MemberAuthService.java +++ b/src/main/java/org/scoula/domain/member/service/MemberAuthService.java @@ -15,5 +15,5 @@ SignInResponseDto signIn(SignInRequestDto signInRequestDto, HttpServletRequest r void signOut(HttpServletResponse response); public RefreshResponseDto reissueToken(ReissueTokenRequestDto reissueTokenRequestDto, String refreshToken, - HttpServletRequest request); + HttpServletRequest request, HttpServletResponse response); } diff --git a/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java b/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java index 019edbe7..39047c3b 100644 --- a/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java +++ b/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java @@ -61,7 +61,7 @@ public void signOut(HttpServletResponse response) { @Override public RefreshResponseDto reissueToken(ReissueTokenRequestDto reissueTokenRequestDto, String refreshToken, - HttpServletRequest request) { + HttpServletRequest request, HttpServletResponse response) { Object refreshTokenByRedis = redisUtil.get(reissueTokenRequestDto.loginId()); if (!refreshTokenByRedis.equals(refreshToken)) { throw new CustomException(JwtErrorMessage.NOT_MATCH_REFRESH_TOKEN, LogLevel.WARNING, @@ -75,6 +75,7 @@ public RefreshResponseDto reissueToken(ReissueTokenRequestDto reissueTokenReques JwtToken jwtToken = jwtTokenProvider.generateToken(reissueTokenRequestDto.memberId(), reissueTokenRequestDto.loginId()); + CookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken()); return new RefreshResponseDto(jwtToken.getAccessToken()); } From ba343eac0d96a953c980f534f9f1a20df9992293 Mon Sep 17 00:00:00 2001 From: Junbeom Lee <63588899+ss7622@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:41:03 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EB=94=94=EB=B2=84=EA=B9=85?= =?UTF-8?q?=EC=9A=A9=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoula/domain/member/service/MemberAuthServiceImpl.java | 6 +++++- src/main/java/org/scoula/global/redis/util/RedisUtil.java | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java b/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java index 39047c3b..d2ae2ea8 100644 --- a/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java +++ b/src/main/java/org/scoula/domain/member/service/MemberAuthServiceImpl.java @@ -22,7 +22,9 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -62,7 +64,9 @@ public void signOut(HttpServletResponse response) { @Override public RefreshResponseDto reissueToken(ReissueTokenRequestDto reissueTokenRequestDto, String refreshToken, HttpServletRequest request, HttpServletResponse response) { - Object refreshTokenByRedis = redisUtil.get(reissueTokenRequestDto.loginId()); + String refreshTokenByRedis = redisUtil.get(reissueTokenRequestDto.loginId()); + log.info("레디스 저장 토큰: {}", refreshTokenByRedis); + log.info("사용자 토큰: {}", refreshToken); if (!refreshTokenByRedis.equals(refreshToken)) { throw new CustomException(JwtErrorMessage.NOT_MATCH_REFRESH_TOKEN, LogLevel.WARNING, request.getHeader("txId"), Common.builder() diff --git a/src/main/java/org/scoula/global/redis/util/RedisUtil.java b/src/main/java/org/scoula/global/redis/util/RedisUtil.java index 51f8fa92..e3e8b68b 100644 --- a/src/main/java/org/scoula/global/redis/util/RedisUtil.java +++ b/src/main/java/org/scoula/global/redis/util/RedisUtil.java @@ -19,7 +19,7 @@ public void set(String key, String o, int minutes) { redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES); } - public Object get(String key) { + public String get(String key) { return redisTemplate.opsForValue().get(key); } From 82462a553c9e1c5c6d4b0a5a5837b236532cfa43 Mon Sep 17 00:00:00 2001 From: cherryiJuice <143402486+cherryiJuice@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:37:21 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[Feat]:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: 회원가입 관련 API - 회원가입 기능 - 아이디 중복 체크 Related to: #23 * Style: 필요없는 주석 제거 Related to: #23 * Feat: 잔여 체류 기간 조회 기능 추가 - Codef Token 레디스 저장 - Codef 잔여 체류 기간 API 연동 - 회원가입 로직 중 체류기간 호출 Related to: #23 * Fix: 패키지 이름 수정 Related to: #23 * Doc: 스웨거 명세서 코드 추가 Related to: #23 * Refactor: 겹치는 코드 수정 Related to: #23 * Feat: Codef 시크릿값 추가 Related to: #23 * Doc: 스웨거 수정 Related to: #23 * Feat: 체류시가 아닐때 오류 추가 Related to: #23 * Doc: 스웨거 추가 Related to: #23 * Feat: member 엔티티 수정된 부분 업데이트 Related to: #23 * Fix: 스네이크 설정 삭제 Related to: #23 * Refactor: 예외처리 Related to: #23 * Refactor: flyway sql문 추가 Related to: #23 * Fix: 충돌나는 부분 해결 Related to: #23 * Fix: 이름 수정 Related to: #23 * Fix: 인증없이 접근 가능하도록 수정 Related to: #23 --- .../codef/controller/CodefController.java | 45 ++ .../codef/dto/common/CodefCommonResponse.java | 13 + .../domain/codef/dto/common/CodefResult.java | 17 + .../dto/request/StayExpirationRequest.java | 23 + .../dto/response/CodefTokenResponse.java | 21 + .../dto/response/StayExpirationResponse.java | 24 ++ .../codef/exception/CodefErrorCode.java | 21 + .../domain/codef/service/CodefApiClient.java | 97 +++++ .../codef/service/CodefAuthService.java | 84 ++++ .../member/controller/JoinController.java | 48 +++ .../scoula/domain/member/dto/MemberDTO.java | 6 +- .../member/dto/request/JoinRequest.java | 44 ++ .../dto/response/SignInResponseDto.java | 6 +- .../scoula/domain/member/entity/Member.java | 8 +- .../member/exception/MemberErrorCode.java | 4 +- .../domain/member/service/JoinService.java | 14 + .../member/service/JoinServiceImpl.java | 89 ++++ .../domain/member/service/MemberService.java | 11 +- .../member/service/MemberServiceImpl.java | 23 - .../global/common/config/AppConfig.java | 26 ++ .../global/common/config/RootConfig.java | 12 +- .../global/common/config/ServletConfig.java | 8 +- .../global/common/config/WebConfig.java | 10 +- .../org/scoula/global/constants/Currency.java | 27 ++ .../global/constants/NationalityCode.java | 36 ++ .../global/redis/config/RedisConfig.java | 1 + .../scoula/global/redis/util/RedisUtil.java | 1 + .../security/config/SecurityConfig.java | 2 + src/main/resources/application.properties | 4 +- .../db/migration/V1__Create_init.sql | 407 +++++++++--------- .../db/migration/V3__edit_member.sql | 8 + .../mapper/DepositSavingMapper.xml | 2 +- .../jeonseloan/mapper/JeonseLoanMapper.xml | 2 +- .../mapper/PersonalCreditLoanMapper.xml | 2 +- 34 files changed, 895 insertions(+), 251 deletions(-) create mode 100644 src/main/java/org/scoula/domain/codef/controller/CodefController.java create mode 100644 src/main/java/org/scoula/domain/codef/dto/common/CodefCommonResponse.java create mode 100644 src/main/java/org/scoula/domain/codef/dto/common/CodefResult.java create mode 100644 src/main/java/org/scoula/domain/codef/dto/request/StayExpirationRequest.java create mode 100644 src/main/java/org/scoula/domain/codef/dto/response/CodefTokenResponse.java create mode 100644 src/main/java/org/scoula/domain/codef/dto/response/StayExpirationResponse.java create mode 100644 src/main/java/org/scoula/domain/codef/exception/CodefErrorCode.java create mode 100644 src/main/java/org/scoula/domain/codef/service/CodefApiClient.java create mode 100644 src/main/java/org/scoula/domain/codef/service/CodefAuthService.java create mode 100644 src/main/java/org/scoula/domain/member/controller/JoinController.java create mode 100644 src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java create mode 100644 src/main/java/org/scoula/domain/member/service/JoinService.java create mode 100644 src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java create mode 100644 src/main/java/org/scoula/global/common/config/AppConfig.java create mode 100644 src/main/java/org/scoula/global/constants/Currency.java create mode 100644 src/main/java/org/scoula/global/constants/NationalityCode.java create mode 100644 src/main/resources/db/migration/V3__edit_member.sql diff --git a/src/main/java/org/scoula/domain/codef/controller/CodefController.java b/src/main/java/org/scoula/domain/codef/controller/CodefController.java new file mode 100644 index 00000000..78d3ff76 --- /dev/null +++ b/src/main/java/org/scoula/domain/codef/controller/CodefController.java @@ -0,0 +1,45 @@ +package org.scoula.domain.codef.controller; + +import javax.servlet.http.HttpServletRequest; + +import org.scoula.domain.codef.dto.request.StayExpirationRequest; +import org.scoula.domain.codef.dto.response.StayExpirationResponse; +import org.scoula.domain.codef.service.CodefApiClient; +import org.scoula.domain.codef.service.CodefAuthService; +import org.scoula.global.response.SuccessResponse; +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.fasterxml.jackson.core.JsonProcessingException; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.RequiredArgsConstructor; + +@Api(tags = "Codef API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/codef") +public class CodefController { + + private final CodefAuthService codefAuthService; + private final CodefApiClient codefApiClient; + + @ApiOperation(value = "Codef 토큰 발급 API", notes = "Codef 토큰을 발급한 후 레디스에 저장합니다.") + @PostMapping("/token") + public SuccessResponse getCodefToken(HttpServletRequest request) { + codefAuthService.issueCodefToken(request); + return SuccessResponse.noContent(); + } + + @ApiOperation(value = "Codef 체류 기간 조회 API", notes = "체류 기간을 조회합니다.") + @PostMapping("/stay") + public SuccessResponse getCodef( + @RequestBody StayExpirationRequest stayExpirationRequest, HttpServletRequest request) throws + JsonProcessingException { + StayExpirationResponse stayExpiration = codefApiClient.getStayExpiration(stayExpirationRequest, request); + return SuccessResponse.ok(stayExpiration); + } +} diff --git a/src/main/java/org/scoula/domain/codef/dto/common/CodefCommonResponse.java b/src/main/java/org/scoula/domain/codef/dto/common/CodefCommonResponse.java new file mode 100644 index 00000000..13285e5f --- /dev/null +++ b/src/main/java/org/scoula/domain/codef/dto/common/CodefCommonResponse.java @@ -0,0 +1,13 @@ +package org.scoula.domain.codef.dto.common; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +@ApiModel(description = "Codef API 공통 응답 구조") +public record CodefCommonResponse( + @ApiModelProperty(notes = "API 호출 결과 상세", required = true) + CodefResult result, + @ApiModelProperty(notes = "API 상품별 데이터 페이로드") + T data +) { +} diff --git a/src/main/java/org/scoula/domain/codef/dto/common/CodefResult.java b/src/main/java/org/scoula/domain/codef/dto/common/CodefResult.java new file mode 100644 index 00000000..fe6cc522 --- /dev/null +++ b/src/main/java/org/scoula/domain/codef/dto/common/CodefResult.java @@ -0,0 +1,17 @@ +package org.scoula.domain.codef.dto.common; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +@ApiModel(description = "Codef API 공통 결과 응답 객체") +public record CodefResult( + @ApiModelProperty(notes = "API 호출 결과 코드", example = "CF-00000", required = true) + String code, + @ApiModelProperty(notes = "API 호출 결과 메시지", example = "성공", required = true) + String message, + @ApiModelProperty(notes = "API 호출 추가 메시지 (선택 사항)", example = "passportNo", required = false) + String extraMessage, + @ApiModelProperty(notes = "API 호출 트랜잭션 아이디", example = "f41d087dd6314", required = true) + String transactionId +) { +} diff --git a/src/main/java/org/scoula/domain/codef/dto/request/StayExpirationRequest.java b/src/main/java/org/scoula/domain/codef/dto/request/StayExpirationRequest.java new file mode 100644 index 00000000..8ebc87a2 --- /dev/null +++ b/src/main/java/org/scoula/domain/codef/dto/request/StayExpirationRequest.java @@ -0,0 +1,23 @@ +package org.scoula.domain.codef.dto.request; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Builder; + +@ApiModel(description = "체류 만료 기간 조회 요청 객체") +@Builder +public record StayExpirationRequest( + @ApiModelProperty(notes = "기관코드 (고정 값 '0001')", example = "0001", required = true) + String organization, + @ApiModelProperty(notes = "여권번호", example = "E12345678", required = true) + String passportNo, + @ApiModelProperty(notes = "국적 코드 (0:러시아, 1:몽골, ..., 99:기타)", example = "2", required = true) + String nationality, + @ApiModelProperty(notes = "국적 '99'일 때 국가명", example = "가나", required = false) + String country, + @ApiModelProperty(notes = "생년월일 (YYYYMMDD)", example = "19900101", required = true) + String birthDate +) { +} + + diff --git a/src/main/java/org/scoula/domain/codef/dto/response/CodefTokenResponse.java b/src/main/java/org/scoula/domain/codef/dto/response/CodefTokenResponse.java new file mode 100644 index 00000000..c5131ea3 --- /dev/null +++ b/src/main/java/org/scoula/domain/codef/dto/response/CodefTokenResponse.java @@ -0,0 +1,21 @@ +package org.scoula.domain.codef.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +@ApiModel(description = "Codef Access Token 응답 객체") +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CodefTokenResponse( + @ApiModelProperty(notes = "Access Token", example = "eyJhbGciOiJIUzI1Ni...", required = true) + String accessToken, + @ApiModelProperty(notes = "토큰 타입", example = "bearer", required = true) + String tokenType, + @ApiModelProperty(notes = "만료 시간", example = "3600", required = true) + Integer expiresIn, + @ApiModelProperty(notes = "스코프", example = "read", required = true) + String scope +) { +} diff --git a/src/main/java/org/scoula/domain/codef/dto/response/StayExpirationResponse.java b/src/main/java/org/scoula/domain/codef/dto/response/StayExpirationResponse.java new file mode 100644 index 00000000..26526841 --- /dev/null +++ b/src/main/java/org/scoula/domain/codef/dto/response/StayExpirationResponse.java @@ -0,0 +1,24 @@ +package org.scoula.domain.codef.dto.response; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +@ApiModel(description = "체류 만료 기간 조회 응답 데이터 객체") +public record StayExpirationResponse( + @ApiModelProperty(notes = "진위 확인 (0: false, 1: true)", example = "1", required = true) + String resAuthenticity, + @ApiModelProperty(notes = "진위 확인 내용 (resAuthenticity = '0'일 때 제공)", example = "현재 체류중인 외국인이 아닙니다.", required = false) + String resAuthenticityDesc, + @ApiModelProperty(notes = "여권번호 (resAuthenticity = '1'일 때 제공)", example = "E12345678") + String resPassportNo, + @ApiModelProperty(notes = "국적 (resAuthenticity = '1'일 때 제공)", example = "2") + String resNationality, + @ApiModelProperty(notes = "생년월일 (YYYYMMDD, resAuthenticity = '1'일 때 제공)", example = "19900101") + String commBirthDate, + @ApiModelProperty(notes = "상태 (resAuthenticity = '1'일 때 제공)", example = "VALID") + String resStatus, + @ApiModelProperty(notes = "만료일 (YYYYMMDD, resAuthenticity = '1'일 때 제공)", example = "20251231") + String resExpirationDate +) { +} + diff --git a/src/main/java/org/scoula/domain/codef/exception/CodefErrorCode.java b/src/main/java/org/scoula/domain/codef/exception/CodefErrorCode.java new file mode 100644 index 00000000..9106addf --- /dev/null +++ b/src/main/java/org/scoula/domain/codef/exception/CodefErrorCode.java @@ -0,0 +1,21 @@ +package org.scoula.domain.codef.exception; + +import org.scoula.global.exception.errorCode.ErrorCode; +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum CodefErrorCode implements ErrorCode { + CODEF_TOKEN_API_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "C-001", "Codef 인증 API 연동에 실패했습니다."), + CODEF_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "C-002", "Codef 토큰이 없습니다."), + CODEF_STAY_EXPIRATION_API_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "C-003", "체류기간 API 연동에 실패했습니다."), + CODEF_REQUIRED_PARAMETER_MISSING(HttpStatus.BAD_REQUEST, "C-004", "필수 파라미터가 누락되었습니다."), + STAY_AUTHENTICITY_FAILED(HttpStatus.BAD_REQUEST, "C-005", "현재 체류중이 아닙니다."), + ; + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/org/scoula/domain/codef/service/CodefApiClient.java b/src/main/java/org/scoula/domain/codef/service/CodefApiClient.java new file mode 100644 index 00000000..48499d76 --- /dev/null +++ b/src/main/java/org/scoula/domain/codef/service/CodefApiClient.java @@ -0,0 +1,97 @@ +package org.scoula.domain.codef.service; + +import static org.scoula.domain.codef.exception.CodefErrorCode.*; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import javax.servlet.http.HttpServletRequest; + +import org.scoula.domain.codef.dto.common.CodefCommonResponse; +import org.scoula.domain.codef.dto.request.StayExpirationRequest; +import org.scoula.domain.codef.dto.response.StayExpirationResponse; +import org.scoula.global.exception.CustomException; +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; +import org.scoula.global.redis.util.RedisUtil; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CodefApiClient { + + private static final String REDIS_ACCESS_TOKEN_KEY = "codef_access_token"; + private final static String STAY_EXPIRATION_URL = "https://development.codef.io/v1/kr/public/mj/hi-korea/stay-expiration-date"; + + private final RedisUtil redisUtil; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public StayExpirationResponse getStayExpiration(StayExpirationRequest stayExpirationRequest, + HttpServletRequest request) throws + JsonProcessingException { + String cachedAccessToken = redisUtil.get(REDIS_ACCESS_TOKEN_KEY).toString(); + + Common common = Common.builder() + .srcIp(request.getRemoteAddr()) + .apiMethod(request.getMethod()) + .callApiPath(request.getRequestURI()) + .deviceInfo(request.getHeader("user-agent")) + .build(); + + // TODO jwt 만료 로직 추가 + if (cachedAccessToken == null) { + throw new CustomException(CODEF_TOKEN_NOT_FOUND, LogLevel.INFO, null, common); + } + + HttpEntity requestEntity = getRequestHttpEntity(stayExpirationRequest, + cachedAccessToken); + + CodefCommonResponse response = getCodefCommonResponse(requestEntity, common); + + String resultCode = response.result().code(); + + if (resultCode.equals("CF-00000")) { + if (response.data().resAuthenticity().equals("0")) { + throw new CustomException(STAY_AUTHENTICITY_FAILED, LogLevel.WARNING, null, common); + } + return response.data(); + } else if (resultCode.equals("CF-00001")) { + throw new CustomException(CODEF_REQUIRED_PARAMETER_MISSING, LogLevel.WARNING, null, common); + } else { + throw new CustomException(CODEF_STAY_EXPIRATION_API_FAILED, LogLevel.ERROR, null, common); + } + } + + private HttpEntity getRequestHttpEntity( + StayExpirationRequest stayExpirationRequest, String cachedAccessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(cachedAccessToken); + headers.setContentType(MediaType.APPLICATION_JSON); + + return new HttpEntity<>(stayExpirationRequest, headers); + } + + private CodefCommonResponse getCodefCommonResponse( + HttpEntity requestEntity, Common common) throws JsonProcessingException { + String rawResponse = restTemplate.postForObject(STAY_EXPIRATION_URL, requestEntity, String.class); + + if (rawResponse == null) { + throw new CustomException(CODEF_STAY_EXPIRATION_API_FAILED, LogLevel.ERROR, null, common); + } + String decoded = URLDecoder.decode(rawResponse, StandardCharsets.UTF_8); + + return objectMapper.readValue(decoded, new TypeReference<>() { + }); + } +} diff --git a/src/main/java/org/scoula/domain/codef/service/CodefAuthService.java b/src/main/java/org/scoula/domain/codef/service/CodefAuthService.java new file mode 100644 index 00000000..bb887b88 --- /dev/null +++ b/src/main/java/org/scoula/domain/codef/service/CodefAuthService.java @@ -0,0 +1,84 @@ +package org.scoula.domain.codef.service; + +import static org.scoula.domain.codef.exception.CodefErrorCode.*; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import javax.servlet.http.HttpServletRequest; + +import org.scoula.domain.codef.dto.response.CodefTokenResponse; +import org.scoula.global.exception.CustomException; +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; +import org.scoula.global.redis.util.RedisUtil; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CodefAuthService { + + private static final String OAUTH_ENDPOINT = "https://oauth.codef.io/oauth/token"; + private static final String REDIS_ACCESS_TOKEN_KEY = "codef_access_token"; + private final RestTemplate restTemplate; + private final RedisUtil redisUtil; + @Value("${codef.client.id}") + private String clientId; + @Value("${codef.client.secret}") + private String clientSecret; + + public void issueCodefToken(HttpServletRequest request) { + HttpEntity> requestEntity = getRequestEntity(); + + ResponseEntity responseEntity = restTemplate.postForEntity( + OAUTH_ENDPOINT, requestEntity, CodefTokenResponse.class); + + CodefTokenResponse response = responseEntity.getBody(); + String codefToken = response.accessToken(); + Integer expiresIn = response.expiresIn(); + + Common common = Common.builder() + .srcIp(request.getRemoteAddr()) + .apiMethod(request.getMethod()) + .callApiPath(request.getRequestURI()) + .deviceInfo(request.getHeader("user-agent")) + .build(); + + if (codefToken == null || expiresIn == null) { + throw new CustomException(CODEF_TOKEN_NOT_FOUND, LogLevel.INFO, null, common); + } + + if (responseEntity.getStatusCode().is2xxSuccessful()) { + redisUtil.set(REDIS_ACCESS_TOKEN_KEY, codefToken, expiresIn); + } else { + throw new CustomException(CODEF_TOKEN_API_FAILED, LogLevel.ERROR, null, common); + } + } + + private HttpEntity> getRequestEntity() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + String auth = clientId + ":" + clientSecret; + String authStringEnc = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + headers.set("Authorization", "Basic " + authStringEnc); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "client_credentials"); + params.add("scope", "read"); + + return new HttpEntity<>(params, headers); + } +} diff --git a/src/main/java/org/scoula/domain/member/controller/JoinController.java b/src/main/java/org/scoula/domain/member/controller/JoinController.java new file mode 100644 index 00000000..e56093d1 --- /dev/null +++ b/src/main/java/org/scoula/domain/member/controller/JoinController.java @@ -0,0 +1,48 @@ +package org.scoula.domain.member.controller; + +import javax.servlet.http.HttpServletRequest; +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; + +import org.scoula.domain.member.dto.request.JoinRequest; +import org.scoula.domain.member.service.JoinService; +import org.scoula.global.response.SuccessResponse; +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.fasterxml.jackson.core.JsonProcessingException; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; + +@Api(tags = "회원 가입 API") +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class JoinController { + private final JoinService joinService; + + @ApiOperation(value = "회원 가입", notes = "사용자 정보를 받아 회원가입을 처리합니다.") + @PostMapping("/join") + public SuccessResponse joinMember(@RequestBody @Valid JoinRequest joinRequest, + HttpServletRequest request) throws + JsonProcessingException { + joinService.joinMember(joinRequest, request); + return SuccessResponse.noContent(); + } + + @ApiOperation(value = "아이디 중복 확인", notes = "입력한 아이디의 중복 여부를 확인합니다.") + @PostMapping("/check-id") + public SuccessResponse verifyLoginId(@ApiParam(value = "확인할 로그인 아이디") + @RequestBody @NotBlank(message = "아이디를 입력해주세요.") String loginId) { + joinService.checkLoginId(loginId); + return SuccessResponse.ok(null); + } + + // TODO 핸드폰 인증 + +} diff --git a/src/main/java/org/scoula/domain/member/dto/MemberDTO.java b/src/main/java/org/scoula/domain/member/dto/MemberDTO.java index 81e33019..06eb1454 100644 --- a/src/main/java/org/scoula/domain/member/dto/MemberDTO.java +++ b/src/main/java/org/scoula/domain/member/dto/MemberDTO.java @@ -3,6 +3,8 @@ import java.time.LocalDate; import org.scoula.domain.member.entity.Member; +import org.scoula.global.constants.Currency; +import org.scoula.global.constants.NationalityCode; import org.springframework.security.crypto.password.PasswordEncoder; import io.swagger.annotations.ApiModel; @@ -32,7 +34,7 @@ public class MemberDTO { private String passportNumber; @ApiModelProperty(value = "국적", example = "KOR") - private String nationality; + private NationalityCode nationality; @ApiModelProperty(value = "생년월일") private LocalDate birth; @@ -47,7 +49,7 @@ public class MemberDTO { private LocalDate remainTime; @ApiModelProperty(value = "설정 통화", example = "USD") - private String currency; + private Currency currency; // Entity -> DTO 변환 public static MemberDTO from(Member member) { diff --git a/src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java b/src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java new file mode 100644 index 00000000..a27b68c5 --- /dev/null +++ b/src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java @@ -0,0 +1,44 @@ +package org.scoula.domain.member.dto.request; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +import org.scoula.global.constants.NationalityCode; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +@ApiModel(description = "회원가입 요청 데이터") +public record JoinRequest( + @ApiModelProperty(value = "로그인 아이디", example = "user123", required = true) + @NotBlank(message = "로그인 아이디는 필수 입력 값입니다.") + String loginId, + + @ApiModelProperty(value = "비밀번호", example = "password123!", required = true) + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + String password, + + @ApiModelProperty(value = "휴대폰 번호", example = "010-1234-5678", required = true) + @NotBlank(message = "휴대폰 번호는 필수 입력 값입니다.") + String phoneNumber, + + @ApiModelProperty(value = "이름", example = "홍길동", required = true) + @NotBlank(message = "이름은 필수 입력 값입니다.") + String name, + + @ApiModelProperty(value = "생년월일", example = "20000101", required = true) + @NotNull(message = "생년월일은 필수 입력 값입니다.") + String birth, + + @ApiModelProperty(value = "여권 번호", example = "M12345678", required = true) + @NotBlank(message = "여권 번호는 필수 입력 값입니다.") + String passportNumber, + + @ApiModelProperty(value = "국적", example = "USA", required = true) + @NotBlank(message = "국적은 필수 입력 값입니다.") + NationalityCode nationality, + + @ApiModelProperty(value = "나라", example = "덴마크 - nationality가 OTHER일 때의 나라명", required = false) + String country +) { +} diff --git a/src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java b/src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java index cc754b8d..19343be6 100644 --- a/src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java +++ b/src/main/java/org/scoula/domain/member/dto/response/SignInResponseDto.java @@ -3,6 +3,8 @@ import java.time.LocalDate; import org.scoula.domain.member.dto.MemberDTO; +import org.scoula.global.constants.Currency; +import org.scoula.global.constants.NationalityCode; import org.scoula.global.security.dto.JwtToken; import io.swagger.annotations.ApiModelProperty; @@ -15,7 +17,7 @@ public record SignInResponseDto( @ApiModelProperty(value = "여권번호", example = "M12345678") String passportNumber, @ApiModelProperty(value = "국적", example = "KOR") - String nationality, + NationalityCode nationality, @ApiModelProperty(value = "생년월일") LocalDate birth, @ApiModelProperty(value = "성명", example = "홍길동", required = true) @@ -25,7 +27,7 @@ public record SignInResponseDto( @ApiModelProperty(value = "잔여 체류기간") LocalDate remainTime, @ApiModelProperty(value = "설정 통화", example = "USD") - String currency) { + Currency currency) { public static SignInResponseDto from(JwtToken token, MemberDTO member) { return new SignInResponseDto(token.getGrantType(), diff --git a/src/main/java/org/scoula/domain/member/entity/Member.java b/src/main/java/org/scoula/domain/member/entity/Member.java index de5372dc..84bfa24e 100644 --- a/src/main/java/org/scoula/domain/member/entity/Member.java +++ b/src/main/java/org/scoula/domain/member/entity/Member.java @@ -2,7 +2,8 @@ import java.time.LocalDate; -import org.apache.xerces.impl.XMLEntityManager; +import org.scoula.global.constants.Currency; +import org.scoula.global.constants.NationalityCode; import org.scoula.global.entity.BaseEntity; import lombok.AllArgsConstructor; @@ -22,11 +23,12 @@ public class Member extends BaseEntity { private String loginId; // 로그인 아이디 private String password; // 비밀번호 private String passportNumber; // 여권번호 - private String nationality; // 국적 + private NationalityCode nationality; // 국적 + private String country; // 나라 private LocalDate birth; // 생년월일 private String name; // 성명 private String phoneNumber; // 전화번호 private LocalDate remainTime; // 잔여 체류기간 - private String currency; // 설정 통화 + private Currency currency; // 설정 통화 } diff --git a/src/main/java/org/scoula/domain/member/exception/MemberErrorCode.java b/src/main/java/org/scoula/domain/member/exception/MemberErrorCode.java index 3b7f6a28..24b84e13 100644 --- a/src/main/java/org/scoula/domain/member/exception/MemberErrorCode.java +++ b/src/main/java/org/scoula/domain/member/exception/MemberErrorCode.java @@ -12,7 +12,9 @@ public enum MemberErrorCode implements ErrorCode { // 400 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M-001", "존재하지 않는 회원입니다."), - MEMBER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "M-002", "이미 존재하는 회원입니다."); + MEMBER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "M-002", "이미 존재하는 회원입니다."), + LOGIN_ID_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "M-003", "이미 존재하는 회원 아이디 입니다."), + STAY_EXPIRATION(HttpStatus.BAD_REQUEST, "M-004", "체류 기간이 만료되었습니다. 가입이 불가능합니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/org/scoula/domain/member/service/JoinService.java b/src/main/java/org/scoula/domain/member/service/JoinService.java new file mode 100644 index 00000000..76b01714 --- /dev/null +++ b/src/main/java/org/scoula/domain/member/service/JoinService.java @@ -0,0 +1,14 @@ +package org.scoula.domain.member.service; + +import javax.servlet.http.HttpServletRequest; + +import org.scoula.domain.member.dto.request.JoinRequest; + +import com.fasterxml.jackson.core.JsonProcessingException; + +public interface JoinService { + + void joinMember(JoinRequest joinRequest, HttpServletRequest request) throws JsonProcessingException; + + void checkLoginId(String loginId); +} diff --git a/src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java b/src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java new file mode 100644 index 00000000..3d9b43e9 --- /dev/null +++ b/src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java @@ -0,0 +1,89 @@ +package org.scoula.domain.member.service; + +import static org.scoula.domain.member.exception.MemberErrorCode.*; +import static org.scoula.global.constants.Currency.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import javax.servlet.http.HttpServletRequest; + +import org.scoula.domain.codef.dto.request.StayExpirationRequest; +import org.scoula.domain.codef.dto.response.StayExpirationResponse; +import org.scoula.domain.codef.service.CodefApiClient; +import org.scoula.domain.member.dto.request.JoinRequest; +import org.scoula.domain.member.entity.Member; +import org.scoula.domain.member.mapper.MemberMapper; +import org.scoula.global.exception.CustomException; +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class JoinServiceImpl implements JoinService { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private final MemberMapper memberMapper; + private final CodefApiClient codefApiClient; + + @Transactional + @Override + public void joinMember(JoinRequest joinRequest, HttpServletRequest request) throws JsonProcessingException { + checkLoginId(joinRequest.loginId()); + + StayExpirationRequest stayExpirationRequest = StayExpirationRequest.builder() + .organization("0001") // 고정값 0001 + .birthDate(joinRequest.birth()) + .passportNo(joinRequest.passportNumber()) + .nationality(joinRequest.nationality().getCode()) + .country(joinRequest.country()) + .build(); + LocalDate stayExpiration = getStayExpiration(stayExpirationRequest, request); + + Member member = Member.builder() + .loginId(joinRequest.loginId()) + .password(joinRequest.password()) + .passportNumber(joinRequest.passportNumber()) + .name(joinRequest.name()) + .birth(LocalDate.parse(joinRequest.birth(), DATE_TIME_FORMATTER)) + .nationality(joinRequest.nationality()) + .phoneNumber(joinRequest.phoneNumber()) + .currency(USD) // default USD + .remainTime(stayExpiration) + .build(); + + memberMapper.insertMember(member); + } + + @Override + public void checkLoginId(String loginId) { + if (checkLoginIdDuplicate(loginId)) { + throw new CustomException(LOGIN_ID_ALREADY_EXISTS, LogLevel.INFO, null, Common.builder().build()); + } + } + + private LocalDate getStayExpiration(StayExpirationRequest stayExpirationRequest, HttpServletRequest request) throws + JsonProcessingException { + StayExpirationResponse stayExpirationResponse = codefApiClient.getStayExpiration(stayExpirationRequest, + request); + String expirationDate = stayExpirationResponse.resExpirationDate(); + + if (stayExpirationResponse.resAuthenticity().equals("0") && expirationDate == null) { + // throw new CustomException(STAY_EXPIRATION, LogLevel.WARNING, null, null); + return null; + } + return LocalDate.parse(expirationDate, DATE_TIME_FORMATTER); + } + + private boolean checkLoginIdDuplicate(String loginId) { + return memberMapper.checkLoginIdDuplicate(loginId) > 0; + } + +} diff --git a/src/main/java/org/scoula/domain/member/service/MemberService.java b/src/main/java/org/scoula/domain/member/service/MemberService.java index f2df1969..4dde2ab1 100644 --- a/src/main/java/org/scoula/domain/member/service/MemberService.java +++ b/src/main/java/org/scoula/domain/member/service/MemberService.java @@ -1,9 +1,9 @@ package org.scoula.domain.member.service; -import org.scoula.domain.member.dto.MemberDTO; - import java.util.List; +import org.scoula.domain.member.dto.MemberDTO; + public interface MemberService { // 회원 전체 조회 @@ -15,14 +15,9 @@ public interface MemberService { // 로그인 ID로 회원 조회 MemberDTO getMemberByLoginId(String loginId); - // 회원 등록 - MemberDTO createMember(MemberDTO memberDTO); - MemberDTO updateMember(Long memberId, MemberDTO memberDTO); + // 회원 삭제 void deleteMember(Long memberId); - // 로그인 ID 중복 체크 - boolean checkLoginIdDuplicate(String loginId); - } diff --git a/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java b/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java index 4d62fb16..90d25802 100644 --- a/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/org/scoula/domain/member/service/MemberServiceImpl.java @@ -56,23 +56,6 @@ public MemberDTO getMemberByLoginId(String loginId) { return MemberDTO.from(member); } - @Override - @Transactional - public MemberDTO createMember(MemberDTO memberDTO) { - log.info("createMember: {}", memberDTO.getLoginId()); - - // 로그인 ID 중복 체크 - if (checkLoginIdDuplicate(memberDTO.getLoginId())) { - throw new CustomException(MEMBER_ALREADY_EXISTS, LogLevel.WARNING, null, Common.builder().build()); - } - memberDTO.PasswordEncrypt(passwordEncoder); - - Member member = memberDTO.toEntity(); - memberMapper.insertMember(member); - - return MemberDTO.from(member); - } - @Override @Transactional public MemberDTO updateMember(Long memberId, MemberDTO memberDTO) { @@ -106,10 +89,4 @@ public void deleteMember(Long memberId) { memberMapper.deleteMember(memberId); } - @Override - public boolean checkLoginIdDuplicate(String loginId) { - log.info("checkLoginIdDuplicate: {}", loginId); - return memberMapper.checkLoginIdDuplicate(loginId) > 0; - } - } diff --git a/src/main/java/org/scoula/global/common/config/AppConfig.java b/src/main/java/org/scoula/global/common/config/AppConfig.java new file mode 100644 index 00000000..347415c3 --- /dev/null +++ b/src/main/java/org/scoula/global/common/config/AppConfig.java @@ -0,0 +1,26 @@ +package org.scoula.global.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@Configuration +@PropertySource("classpath:application.properties") +@ComponentScan(basePackages = "org.scoula") +public class AppConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } +} + diff --git a/src/main/java/org/scoula/global/common/config/RootConfig.java b/src/main/java/org/scoula/global/common/config/RootConfig.java index f239caa9..dd99d8bd 100644 --- a/src/main/java/org/scoula/global/common/config/RootConfig.java +++ b/src/main/java/org/scoula/global/common/config/RootConfig.java @@ -23,15 +23,17 @@ @Configuration @PropertySource({"classpath:/application.properties"}) -@MapperScan(basePackages = {"org.scoula.domain.member.mapper"}) // 게시판과 회원 도메인 매퍼 스캔 +@MapperScan(basePackages = {"org.scoula.domain"}) @ComponentScan(basePackages = { - // "org.scoula.service", - "org.scoula.domain.member.service", // 도메인 객체를 포함하기 위해 추가 "org.scoula.global.swagger.config", // Swagger 설정을 포함하기 위해 추가 "org.scoula.global.kafka", // kafka 설정 포함 "org.scoula.global.exception", // exception handler 등록 "org.scoula.global.redis", - "org.scoula.global.security" + "org.scoula.global.security", + "org.scoula.domain.**.service", + // "org.scoula.domain.codef.service", + "org.scoula.global.redis.util", + "org.scoula.global.redis.config" }) public class RootConfig { @Value("${jdbc.driver}") @@ -88,7 +90,7 @@ public SqlSessionFactory sqlSessionFactory() throws Exception { applicationContext.getResource("classpath:/mybatis-config.xml") ); sqlSessionFactory.setDataSource(dataSource()); - return (SqlSessionFactory)sqlSessionFactory.getObject(); + return sqlSessionFactory.getObject(); } @Bean diff --git a/src/main/java/org/scoula/global/common/config/ServletConfig.java b/src/main/java/org/scoula/global/common/config/ServletConfig.java index a9e34c37..4850dbdb 100644 --- a/src/main/java/org/scoula/global/common/config/ServletConfig.java +++ b/src/main/java/org/scoula/global/common/config/ServletConfig.java @@ -11,7 +11,11 @@ @Configuration @EnableWebMvc -@ComponentScan(basePackages = {"org.scoula.domain.controller", "org.scoula.domain.member.controller"}) +@ComponentScan(basePackages = { + "org.scoula.domain.controller", + "org.scoula.domain.**.controller", + // "org.scoula.domain.codef.controller", +}) public class ServletConfig implements WebMvcConfigurer { @Override @@ -45,4 +49,4 @@ public void configureViewResolvers(ViewResolverRegistry registry) { bean.setSuffix(".jsp"); registry.viewResolver(bean); } -} \ No newline at end of file +} diff --git a/src/main/java/org/scoula/global/common/config/WebConfig.java b/src/main/java/org/scoula/global/common/config/WebConfig.java index 1e49533f..afaab018 100644 --- a/src/main/java/org/scoula/global/common/config/WebConfig.java +++ b/src/main/java/org/scoula/global/common/config/WebConfig.java @@ -10,10 +10,10 @@ public class WebConfig extends AbstractAnnotationConfigDispatcherServletInitializer { - @Override - protected Class[] getRootConfigClasses() { - return new Class[] {RootConfig.class}; - } + @Override + protected Class[] getRootConfigClasses() { + return new Class[] {RootConfig.class, AppConfig.class}; + } @Override protected Class[] getServletConfigClasses() { @@ -42,4 +42,4 @@ protected DispatcherServlet createDispatcherServlet(WebApplicationContext servle dispatcherServlet.setThrowExceptionIfNoHandlerFound(true); return dispatcherServlet; } -} \ No newline at end of file +} diff --git a/src/main/java/org/scoula/global/constants/Currency.java b/src/main/java/org/scoula/global/constants/Currency.java new file mode 100644 index 00000000..3d8d4fc3 --- /dev/null +++ b/src/main/java/org/scoula/global/constants/Currency.java @@ -0,0 +1,27 @@ +package org.scoula.global.constants; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Currency { + USD("미국", "달러"), + JPY("일본", "엔"), + EUR("유럽연합", "유로"), + GBP("영국", "파운드"), + CAD("캐나다", "달러"), + KRW("한국", "원"), + HKD("홍콩", "달러"), + CNY("중국", "위안"), + THB("태국", "바트"), + IDR("인도네시아", "루피아"), + VND("베트남", "동"), + RUB("러시아", "루블"), + BDT("방글라데시", "타카"), + ; + + private final String country; + private final String unit; + +} diff --git a/src/main/java/org/scoula/global/constants/NationalityCode.java b/src/main/java/org/scoula/global/constants/NationalityCode.java new file mode 100644 index 00000000..f5b69355 --- /dev/null +++ b/src/main/java/org/scoula/global/constants/NationalityCode.java @@ -0,0 +1,36 @@ +package org.scoula.global.constants; + +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum NationalityCode { + RUSSIA("0", "러시아"), + MONGOLIA("1", "몽골"), + USA("2", "미국"), + VIETNAM("3", "베트남"), + INDIA("4", "인도"), + INDONESIA("5", "인도네시아"), + JAPAN("6", "일본"), + CHINA("7", "중국"), + THAILAND("8", "타이"), + PHILIPPINES("9", "필리핀"), + KOREAN_RUSSIAN("10", "한국계 러시아인"), + KOREAN_CHINESE("11", "한국계 중국인"), + OTHER("99", "기타"); + + private static final Map BY_CODE = + Arrays.stream(values()).collect(Collectors.toMap(NationalityCode::getCode, Function.identity())); + private final String code; + private final String description; + + public static NationalityCode fromCode(String code) { + return BY_CODE.getOrDefault(code, OTHER); + } +} diff --git a/src/main/java/org/scoula/global/redis/config/RedisConfig.java b/src/main/java/org/scoula/global/redis/config/RedisConfig.java index acf6a62e..8546bfd1 100644 --- a/src/main/java/org/scoula/global/redis/config/RedisConfig.java +++ b/src/main/java/org/scoula/global/redis/config/RedisConfig.java @@ -60,3 +60,4 @@ public RedisTemplate redisTemplate() { return redisTemplate; } } + diff --git a/src/main/java/org/scoula/global/redis/util/RedisUtil.java b/src/main/java/org/scoula/global/redis/util/RedisUtil.java index e3e8b68b..e4a26ef7 100644 --- a/src/main/java/org/scoula/global/redis/util/RedisUtil.java +++ b/src/main/java/org/scoula/global/redis/util/RedisUtil.java @@ -66,3 +66,4 @@ public void deleteByPrefix(String prefix) { } } } + diff --git a/src/main/java/org/scoula/global/security/config/SecurityConfig.java b/src/main/java/org/scoula/global/security/config/SecurityConfig.java index 9e69c571..18482702 100644 --- a/src/main/java/org/scoula/global/security/config/SecurityConfig.java +++ b/src/main/java/org/scoula/global/security/config/SecurityConfig.java @@ -62,6 +62,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // 로그인 관련 api .requestMatchers("/auth/**").permitAll() + // codef 관련 api + .requestMatchers("/codef/**").permitAll() // 스웨거 사용을 위한 허용 .requestMatchers( "/v2/api-docs/**", diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 36b83172..e930313e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -7,4 +7,6 @@ spring.kafka.log-topic=${KAFKA_LOG_TOPIC} spring.redis.host=${REDIS_HOST} spring.redis.port=${REDIS_PORT} spring.redis.password= -jwt.secret=${JWT_SECRET} \ No newline at end of file +jwt.secret=${JWT_SECRET} +codef.client.id=${CODEF_CLIENT_ID} +codef.client.secret=${CODEF_CLIENT_SECRET} diff --git a/src/main/resources/db/migration/V1__Create_init.sql b/src/main/resources/db/migration/V1__Create_init.sql index 7ddd8c2e..63f9acef 100644 --- a/src/main/resources/db/migration/V1__Create_init.sql +++ b/src/main/resources/db/migration/V1__Create_init.sql @@ -4,242 +4,257 @@ -- Member table -CREATE TABLE member ( - member_id BIGINT AUTO_INCREMENT PRIMARY KEY, - login_id VARCHAR(100) NOT NULL, - password VARCHAR(100) NOT NULL, - passport_number VARCHAR(100) NOT NULL, - nationality VARCHAR(100) NOT NULL, - birth DATE NOT NULL, - name VARCHAR(100) NOT NULL, - phone_number VARCHAR(50), - remain_time DATE, - currency VARCHAR(20), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE member +( + member_id BIGINT AUTO_INCREMENT PRIMARY KEY, + login_id VARCHAR(100) NOT NULL, + password VARCHAR(100) NOT NULL, + passport_number VARCHAR(100) NOT NULL, + nationality VARCHAR(100) NOT NULL, + birth DATE NOT NULL, + name VARCHAR(100) NOT NULL, + phone_number VARCHAR(50), + remain_time DATE, + currency VARCHAR(20), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- Wallet table -CREATE TABLE wallet ( - wallet_id BIGINT AUTO_INCREMENT PRIMARY KEY, - member_id BIGINT NOT NULL, - balance DECIMAL(18, 2) NOT NULL DEFAULT 0.00, - password INT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (member_id) REFERENCES member(member_id) +CREATE TABLE wallet +( + wallet_id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + balance DECIMAL(18, 2) NOT NULL DEFAULT 0.00, + password INT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (member_id) REFERENCES member (member_id) ); -- Account table -CREATE TABLE account ( - account_id BIGINT AUTO_INCREMENT PRIMARY KEY, - wallet_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - account_number VARCHAR(30) NOT NULL, - password VARCHAR(30) NOT NULL, - balance DECIMAL(18, 2) NOT NULL DEFAULT 0.00, - bank_code VARCHAR(15), - bank_name VARCHAR(20), - is_verified BOOLEAN DEFAULT FALSE, - verified_at TIMESTAMP, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (wallet_id) REFERENCES wallet(wallet_id), - FOREIGN KEY (member_id) REFERENCES member(member_id) +CREATE TABLE account +( + account_id BIGINT AUTO_INCREMENT PRIMARY KEY, + wallet_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + account_number VARCHAR(30) NOT NULL, + password VARCHAR(30) NOT NULL, + balance DECIMAL(18, 2) NOT NULL DEFAULT 0.00, + bank_code VARCHAR(15), + bank_name VARCHAR(20), + is_verified BOOLEAN DEFAULT FALSE, + verified_at TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (wallet_id) REFERENCES wallet (wallet_id), + FOREIGN KEY (member_id) REFERENCES member (member_id) ); -- Remittance_group table -CREATE TABLE remittance_group ( - remittance_group_id BIGINT AUTO_INCREMENT PRIMARY KEY, - title VARCHAR(100), - currency VARCHAR(20), - date TIMESTAMP, - count INT, - total_amount DECIMAL(18, 2), - applied_exchange_rate DECIMAL(18, 6), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE remittance_group +( + remittance_group_id BIGINT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(100), + currency VARCHAR(20), + date TIMESTAMP, + count INT, + total_amount DECIMAL(18, 2), + applied_exchange_rate DECIMAL(18, 6), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- Member_remittance_group table -CREATE TABLE member_remittance_group ( - member_remittance_group_id BIGINT AUTO_INCREMENT PRIMARY KEY, - remittance_group_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - receiver_bank VARCHAR(100), - swift_code VARCHAR(100), - router_code VARCHAR(100), - receiver_account VARCHAR(100), - receiver_name VARCHAR(100), - receiver_nationality VARCHAR(100), - receiver_address VARCHAR(255), - purpose VARCHAR(255), - amount BIGINT, - exchange_amount BIGINT, - status ENUM('PENDING', 'SUCCESS', 'FAILED'), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (remittance_group_id) REFERENCES remittance_group(remittance_group_id), - FOREIGN KEY (member_id) REFERENCES member(member_id) +CREATE TABLE member_remittance_group +( + member_remittance_group_id BIGINT AUTO_INCREMENT PRIMARY KEY, + remittance_group_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + receiver_bank VARCHAR(100), + swift_code VARCHAR(100), + router_code VARCHAR(100), + receiver_account VARCHAR(100), + receiver_name VARCHAR(100), + receiver_nationality VARCHAR(100), + receiver_address VARCHAR(255), + purpose VARCHAR(255), + amount BIGINT, + exchange_amount BIGINT, + status ENUM('PENDING', 'SUCCESS', 'FAILED'), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (remittance_group_id) REFERENCES remittance_group (remittance_group_id), + FOREIGN KEY (member_id) REFERENCES member (member_id) ); -- Transaction table -CREATE TABLE transaction ( - transaction_id BIGINT AUTO_INCREMENT PRIMARY KEY, - wallet_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - transaction_group_id CHAR(36), - amount DECIMAL(18, 2) NOT NULL, - before_balance DECIMAL(18, 2) NOT NULL, - after_balance DECIMAL(18, 2) NOT NULL, - transaction_type ENUM('CHARGE', 'TRANSFER'), - counterparty_member_id BIGINT, - counterparty_wallet_id BIGINT, - status ENUM('PENDING', 'SUCCESS', 'FAILED'), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (wallet_id) REFERENCES wallet(wallet_id), - FOREIGN KEY (member_id) REFERENCES member(member_id) +CREATE TABLE transaction +( + transaction_id BIGINT AUTO_INCREMENT PRIMARY KEY, + wallet_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + transaction_group_id CHAR(36), + amount DECIMAL(18, 2) NOT NULL, + before_balance DECIMAL(18, 2) NOT NULL, + after_balance DECIMAL(18, 2) NOT NULL, + transaction_type ENUM('CHARGE', 'TRANSFER'), + counterparty_member_id BIGINT, + counterparty_wallet_id BIGINT, + status ENUM('PENDING', 'SUCCESS', 'FAILED'), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (wallet_id) REFERENCES wallet (wallet_id), + FOREIGN KEY (member_id) REFERENCES member (member_id) ); -- 1. 전표 테이블 -CREATE TABLE ledger_voucher ( - ledger_voucher_id BIGINT AUTO_INCREMENT PRIMARY KEY, - voucher_no VARCHAR(30) NOT NULL, - transaction_id BIGINT NOT NULL, - entry_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - voucher_type ENUM('CHARGE', 'TRANSFER') NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE ledger_voucher +( + ledger_voucher_id BIGINT AUTO_INCREMENT PRIMARY KEY, + voucher_no VARCHAR(30) NOT NULL, + transaction_id BIGINT NOT NULL, + entry_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + voucher_type ENUM('CHARGE', 'TRANSFER') NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 2. 회계 코드 테이블 -CREATE TABLE ledger_code ( - account_code_id BIGINT AUTO_INCREMENT PRIMARY KEY, - type ENUM('ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE') NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE ledger_code +( + account_code_id BIGINT AUTO_INCREMENT PRIMARY KEY, + type ENUM('ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE') NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 3. 분개 테이블 -CREATE TABLE ledger_entry ( - ledger_entry_id BIGINT AUTO_INCREMENT PRIMARY KEY, - ledger_voucher_id BIGINT NOT NULL, - ledger_type ENUM('DEBIT', 'CREDIT') NOT NULL, - account_code_id BIGINT NOT NULL, - amount DECIMAL(18, 2) NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - CONSTRAINT fk_entry_voucher - FOREIGN KEY (ledger_voucher_id) REFERENCES ledger_voucher(ledger_voucher_id) - ON DELETE CASCADE, - - CONSTRAINT fk_entry_account_code - FOREIGN KEY (account_code_id) REFERENCES ledger_code(account_code_id) - ON DELETE RESTRICT +CREATE TABLE ledger_entry +( + ledger_entry_id BIGINT AUTO_INCREMENT PRIMARY KEY, + ledger_voucher_id BIGINT NOT NULL, + ledger_type ENUM('DEBIT', 'CREDIT') NOT NULL, + account_code_id BIGINT NOT NULL, + amount DECIMAL(18, 2) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_entry_voucher + FOREIGN KEY (ledger_voucher_id) REFERENCES ledger_voucher (ledger_voucher_id) + ON DELETE CASCADE, + + CONSTRAINT fk_entry_account_code + FOREIGN KEY (account_code_id) REFERENCES ledger_code (account_code_id) + ON DELETE RESTRICT ); -- 금융회사 테이블 -CREATE TABLE financial_company ( - financial_company_id BIGINT AUTO_INCREMENT PRIMARY KEY, - code VARCHAR(255) NOT NULL, - name VARCHAR(255) NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE financial_company +( + financial_company_id BIGINT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 예금 적금 테이블 -CREATE TABLE deposit_saving ( - deposit_saving_id BIGINT AUTO_INCREMENT PRIMARY KEY, - financial_company_id BIGINT NOT NULL, - type ENUM('DEPOSIT', 'SAVING') NOT NULL, -- 예/적금 - name VARCHAR(255) NOT NULL, - join_way VARCHAR(255), - interest_description VARCHAR(255), - spcl_condition VARCHAR(255), - ctc_note VARCHAR(1024), - max_limit VARCHAR(255), - save_month INT NOT NULL, - interest_rate DECIMAL(10, 2) NOT NULL, - max_interest_rate DECIMAL(10, 2) NOT NULL, - interest_rate_type VARCHAR(255) NOT NULL, - reserve_type VARCHAR(255), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (financial_company_id) REFERENCES financial_company(financial_company_id) +CREATE TABLE deposit_saving +( + deposit_saving_id BIGINT AUTO_INCREMENT PRIMARY KEY, + financial_company_id BIGINT NOT NULL, + type ENUM('DEPOSIT', 'SAVING') NOT NULL, -- 예/적금 + name VARCHAR(255) NOT NULL, + join_way VARCHAR(255), + interest_description VARCHAR(255), + spcl_condition VARCHAR(255), + ctc_note VARCHAR(1024), + max_limit VARCHAR(255), + save_month INT NOT NULL, + interest_rate DECIMAL(10, 2) NOT NULL, + max_interest_rate DECIMAL(10, 2) NOT NULL, + interest_rate_type VARCHAR(255) NOT NULL, + reserve_type VARCHAR(255), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (financial_company_id) REFERENCES financial_company (financial_company_id) ); -- 전세자금대출 테이블 -CREATE TABLE jeonse_loan ( - jeonse_loan_id BIGINT AUTO_INCREMENT PRIMARY KEY, - financial_company_id BIGINT NOT NULL, - name VARCHAR(255) NOT NULL, - join_way VARCHAR(255), - loan_expensive VARCHAR(255), - erly_fee VARCHAR(255), - dly_rate VARCHAR(255) NOT NULL, - loan_lmt VARCHAR(255), - repay_type_name VARCHAR(255), - lend_rate_type_name VARCHAR(255), - lend_rate_min DECIMAL(10, 2) NOT NULL, - lend_rate_max DECIMAL(10, 2) NOT NULL, - lend_rate_avg DECIMAL(10, 2) NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (financial_company_id) REFERENCES financial_company(financial_company_id) +CREATE TABLE jeonse_loan +( + jeonse_loan_id BIGINT AUTO_INCREMENT PRIMARY KEY, + financial_company_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + join_way VARCHAR(255), + loan_expensive VARCHAR(255), + erly_fee VARCHAR(255), + dly_rate VARCHAR(255) NOT NULL, + loan_lmt VARCHAR(255), + repay_type_name VARCHAR(255), + lend_rate_type_name VARCHAR(255), + lend_rate_min DECIMAL(10, 2) NOT NULL, + lend_rate_max DECIMAL(10, 2) NOT NULL, + lend_rate_avg DECIMAL(10, 2) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (financial_company_id) REFERENCES financial_company (financial_company_id) ); -CREATE TABLE personal_credit_loan ( - personal_credit_loan_id BIGINT AUTO_INCREMENT PRIMARY KEY, - financial_company_id BIGINT NOT NULL, - name VARCHAR(255) NOT NULL, - join_way VARCHAR(255), - crdt_prdt_type_nm VARCHAR(255), - cb_name VARCHAR(255) NOT NULL, - crdt_grad_1 DECIMAL(10, 2), - crdt_grad_4 DECIMAL(10, 2), - crdt_grad_5 DECIMAL(10, 2), - crdt_grad_6 DECIMAL(10, 2), - crdt_grad_10 DECIMAL(10, 2), - crdt_grad_11 DECIMAL(10, 2), - crdt_grad_12 DECIMAL(10, 2), - crdt_grad_13 DECIMAL(10, 2), - crdt_grad_avg DECIMAL(10, 2), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (financial_company_id) REFERENCES financial_company(financial_company_id) +CREATE TABLE personal_credit_loan +( + personal_credit_loan_id BIGINT AUTO_INCREMENT PRIMARY KEY, + financial_company_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + join_way VARCHAR(255), + crdt_prdt_type_nm VARCHAR(255), + cb_name VARCHAR(255) NOT NULL, + crdt_grad_1 DECIMAL(10, 2), + crdt_grad_4 DECIMAL(10, 2), + crdt_grad_5 DECIMAL(10, 2), + crdt_grad_6 DECIMAL(10, 2), + crdt_grad_10 DECIMAL(10, 2), + crdt_grad_11 DECIMAL(10, 2), + crdt_grad_12 DECIMAL(10, 2), + crdt_grad_13 DECIMAL(10, 2), + crdt_grad_avg DECIMAL(10, 2), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (financial_company_id) REFERENCES financial_company (financial_company_id) ); -- 위치 (Location) 테이블 -CREATE TABLE location ( - location_id BIGINT NOT NULL AUTO_INCREMENT, - address VARCHAR(255) NOT NULL, - point POINT NOT NULL, - location_number VARCHAR(30) NOT NULL, - place_type ENUM('대문화시설센터', '상업소', '학교', '은행', '병원') NOT NULL, - place_name VARCHAR(255) NOT NULL, - PRIMARY KEY (location_id), - UNIQUE KEY uk_location_number (location_number), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE location +( + location_id BIGINT NOT NULL AUTO_INCREMENT, + address VARCHAR(255) NOT NULL, + point POINT NOT NULL, + location_number VARCHAR(30) NOT NULL, + place_type ENUM('대문화시설센터', '상업소', '학교', '은행', '병원') NOT NULL, + place_name VARCHAR(255) NOT NULL, + PRIMARY KEY (location_id), + UNIQUE KEY uk_location_number (location_number), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 환율 (Exchange_rate) 테이블 -CREATE TABLE exchange_rate ( - exchange_id BIGINT NOT NULL AUTO_INCREMENT, - base_exchange CHAR(3) NOT NULL, - target_exchange CHAR(3) NOT NULL, - exchange_value DECIMAL(10,4) NOT NULL, - bank_name VARCHAR(100) NOT NULL, - exchange_date DATE NOT NULL, - type ENUM('GETCASH', 'SELLCASH', 'SEND', 'RECEIVE','BASE') NOT NULL, - PRIMARY KEY (exchange_id), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -); +CREATE TABLE exchange_rate +( + exchange_id BIGINT NOT NULL AUTO_INCREMENT, + base_exchange CHAR(3) NOT NULL, + target_exchange CHAR(3) NOT NULL, + exchange_value DECIMAL(10, 4) NOT NULL, + bank_name VARCHAR(100) NOT NULL, + exchange_date DATE NOT NULL, + type ENUM('GETCASH', 'SELLCASH', 'SEND', 'RECEIVE','BASE') NOT NULL, + PRIMARY KEY (exchange_id), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__edit_member.sql b/src/main/resources/db/migration/V3__edit_member.sql new file mode 100644 index 00000000..ff9dacb1 --- /dev/null +++ b/src/main/resources/db/migration/V3__edit_member.sql @@ -0,0 +1,8 @@ +ALTER TABLE member + ADD COLUMN country VARCHAR(255); + +ALTER TABLE member + MODIFY COLUMN nationality ENUM('RUSSIA', 'MONGOLIA', 'USA', 'VIETNAM', 'INDIA', 'INDONESIA', 'JAPAN', 'CHINA', 'THAILAND', 'PHILIPPINES', 'KOREAN_RUSSIAN', 'KOREAN_CHINESE', 'OTHER') NOT NULL; + +ALTER TABLE member + MODIFY COLUMN currency ENUM('USD', 'JPY', 'EUR', 'GBP', 'CAD', 'KRW', 'HKD', 'CNY', 'THB', 'IDR', 'VND', 'RUB', 'BDT'); diff --git a/src/main/resources/org/scoula/domain/financialproduct/depositsaving/mapper/DepositSavingMapper.xml b/src/main/resources/org/scoula/domain/financialproduct/depositsaving/mapper/DepositSavingMapper.xml index a908bfec..35c6751e 100644 --- a/src/main/resources/org/scoula/domain/financialproduct/depositsaving/mapper/DepositSavingMapper.xml +++ b/src/main/resources/org/scoula/domain/financialproduct/depositsaving/mapper/DepositSavingMapper.xml @@ -3,7 +3,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - + diff --git a/src/main/resources/org/scoula/domain/financialproduct/jeonseloan/mapper/JeonseLoanMapper.xml b/src/main/resources/org/scoula/domain/financialproduct/jeonseloan/mapper/JeonseLoanMapper.xml index 265ef649..fe7e1bee 100644 --- a/src/main/resources/org/scoula/domain/financialproduct/jeonseloan/mapper/JeonseLoanMapper.xml +++ b/src/main/resources/org/scoula/domain/financialproduct/jeonseloan/mapper/JeonseLoanMapper.xml @@ -3,7 +3,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - + diff --git a/src/main/resources/org/scoula/domain/financialproduct/personalcreditLoan/mapper/PersonalCreditLoanMapper.xml b/src/main/resources/org/scoula/domain/financialproduct/personalcreditLoan/mapper/PersonalCreditLoanMapper.xml index bbe7d9da..71caa2e2 100644 --- a/src/main/resources/org/scoula/domain/financialproduct/personalcreditLoan/mapper/PersonalCreditLoanMapper.xml +++ b/src/main/resources/org/scoula/domain/financialproduct/personalcreditLoan/mapper/PersonalCreditLoanMapper.xml @@ -3,7 +3,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - + From d4e3fedb80cdad87295e9de51dcbbfab2f0ddeef Mon Sep 17 00:00:00 2001 From: Junbeom Lee <63588899+ss7622@users.noreply.github.com> Date: Wed, 23 Jul 2025 17:12:26 +0900 Subject: [PATCH 08/12] =?UTF-8?q?hotfix:=20V1=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EB=B3=80=EA=B2=BD=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V1__Create_init.sql | 407 +++++++++--------- 1 file changed, 196 insertions(+), 211 deletions(-) diff --git a/src/main/resources/db/migration/V1__Create_init.sql b/src/main/resources/db/migration/V1__Create_init.sql index 63f9acef..7ddd8c2e 100644 --- a/src/main/resources/db/migration/V1__Create_init.sql +++ b/src/main/resources/db/migration/V1__Create_init.sql @@ -4,257 +4,242 @@ -- Member table -CREATE TABLE member -( - member_id BIGINT AUTO_INCREMENT PRIMARY KEY, - login_id VARCHAR(100) NOT NULL, - password VARCHAR(100) NOT NULL, - passport_number VARCHAR(100) NOT NULL, - nationality VARCHAR(100) NOT NULL, - birth DATE NOT NULL, - name VARCHAR(100) NOT NULL, - phone_number VARCHAR(50), - remain_time DATE, - currency VARCHAR(20), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE member ( + member_id BIGINT AUTO_INCREMENT PRIMARY KEY, + login_id VARCHAR(100) NOT NULL, + password VARCHAR(100) NOT NULL, + passport_number VARCHAR(100) NOT NULL, + nationality VARCHAR(100) NOT NULL, + birth DATE NOT NULL, + name VARCHAR(100) NOT NULL, + phone_number VARCHAR(50), + remain_time DATE, + currency VARCHAR(20), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- Wallet table -CREATE TABLE wallet -( - wallet_id BIGINT AUTO_INCREMENT PRIMARY KEY, - member_id BIGINT NOT NULL, - balance DECIMAL(18, 2) NOT NULL DEFAULT 0.00, - password INT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (member_id) REFERENCES member (member_id) +CREATE TABLE wallet ( + wallet_id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + balance DECIMAL(18, 2) NOT NULL DEFAULT 0.00, + password INT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (member_id) REFERENCES member(member_id) ); -- Account table -CREATE TABLE account -( - account_id BIGINT AUTO_INCREMENT PRIMARY KEY, - wallet_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - account_number VARCHAR(30) NOT NULL, - password VARCHAR(30) NOT NULL, - balance DECIMAL(18, 2) NOT NULL DEFAULT 0.00, - bank_code VARCHAR(15), - bank_name VARCHAR(20), - is_verified BOOLEAN DEFAULT FALSE, - verified_at TIMESTAMP, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (wallet_id) REFERENCES wallet (wallet_id), - FOREIGN KEY (member_id) REFERENCES member (member_id) +CREATE TABLE account ( + account_id BIGINT AUTO_INCREMENT PRIMARY KEY, + wallet_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + account_number VARCHAR(30) NOT NULL, + password VARCHAR(30) NOT NULL, + balance DECIMAL(18, 2) NOT NULL DEFAULT 0.00, + bank_code VARCHAR(15), + bank_name VARCHAR(20), + is_verified BOOLEAN DEFAULT FALSE, + verified_at TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (wallet_id) REFERENCES wallet(wallet_id), + FOREIGN KEY (member_id) REFERENCES member(member_id) ); -- Remittance_group table -CREATE TABLE remittance_group -( - remittance_group_id BIGINT AUTO_INCREMENT PRIMARY KEY, - title VARCHAR(100), - currency VARCHAR(20), - date TIMESTAMP, - count INT, - total_amount DECIMAL(18, 2), - applied_exchange_rate DECIMAL(18, 6), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE remittance_group ( + remittance_group_id BIGINT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(100), + currency VARCHAR(20), + date TIMESTAMP, + count INT, + total_amount DECIMAL(18, 2), + applied_exchange_rate DECIMAL(18, 6), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- Member_remittance_group table -CREATE TABLE member_remittance_group -( - member_remittance_group_id BIGINT AUTO_INCREMENT PRIMARY KEY, - remittance_group_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - receiver_bank VARCHAR(100), - swift_code VARCHAR(100), - router_code VARCHAR(100), - receiver_account VARCHAR(100), - receiver_name VARCHAR(100), - receiver_nationality VARCHAR(100), - receiver_address VARCHAR(255), - purpose VARCHAR(255), - amount BIGINT, - exchange_amount BIGINT, - status ENUM('PENDING', 'SUCCESS', 'FAILED'), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (remittance_group_id) REFERENCES remittance_group (remittance_group_id), - FOREIGN KEY (member_id) REFERENCES member (member_id) +CREATE TABLE member_remittance_group ( + member_remittance_group_id BIGINT AUTO_INCREMENT PRIMARY KEY, + remittance_group_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + receiver_bank VARCHAR(100), + swift_code VARCHAR(100), + router_code VARCHAR(100), + receiver_account VARCHAR(100), + receiver_name VARCHAR(100), + receiver_nationality VARCHAR(100), + receiver_address VARCHAR(255), + purpose VARCHAR(255), + amount BIGINT, + exchange_amount BIGINT, + status ENUM('PENDING', 'SUCCESS', 'FAILED'), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (remittance_group_id) REFERENCES remittance_group(remittance_group_id), + FOREIGN KEY (member_id) REFERENCES member(member_id) ); -- Transaction table -CREATE TABLE transaction -( - transaction_id BIGINT AUTO_INCREMENT PRIMARY KEY, - wallet_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - transaction_group_id CHAR(36), - amount DECIMAL(18, 2) NOT NULL, - before_balance DECIMAL(18, 2) NOT NULL, - after_balance DECIMAL(18, 2) NOT NULL, - transaction_type ENUM('CHARGE', 'TRANSFER'), - counterparty_member_id BIGINT, - counterparty_wallet_id BIGINT, - status ENUM('PENDING', 'SUCCESS', 'FAILED'), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (wallet_id) REFERENCES wallet (wallet_id), - FOREIGN KEY (member_id) REFERENCES member (member_id) +CREATE TABLE transaction ( + transaction_id BIGINT AUTO_INCREMENT PRIMARY KEY, + wallet_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + transaction_group_id CHAR(36), + amount DECIMAL(18, 2) NOT NULL, + before_balance DECIMAL(18, 2) NOT NULL, + after_balance DECIMAL(18, 2) NOT NULL, + transaction_type ENUM('CHARGE', 'TRANSFER'), + counterparty_member_id BIGINT, + counterparty_wallet_id BIGINT, + status ENUM('PENDING', 'SUCCESS', 'FAILED'), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (wallet_id) REFERENCES wallet(wallet_id), + FOREIGN KEY (member_id) REFERENCES member(member_id) ); -- 1. 전표 테이블 -CREATE TABLE ledger_voucher -( - ledger_voucher_id BIGINT AUTO_INCREMENT PRIMARY KEY, - voucher_no VARCHAR(30) NOT NULL, - transaction_id BIGINT NOT NULL, - entry_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - voucher_type ENUM('CHARGE', 'TRANSFER') NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE ledger_voucher ( + ledger_voucher_id BIGINT AUTO_INCREMENT PRIMARY KEY, + voucher_no VARCHAR(30) NOT NULL, + transaction_id BIGINT NOT NULL, + entry_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + voucher_type ENUM('CHARGE', 'TRANSFER') NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 2. 회계 코드 테이블 -CREATE TABLE ledger_code -( - account_code_id BIGINT AUTO_INCREMENT PRIMARY KEY, - type ENUM('ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE') NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE ledger_code ( + account_code_id BIGINT AUTO_INCREMENT PRIMARY KEY, + type ENUM('ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE') NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 3. 분개 테이블 -CREATE TABLE ledger_entry -( - ledger_entry_id BIGINT AUTO_INCREMENT PRIMARY KEY, - ledger_voucher_id BIGINT NOT NULL, - ledger_type ENUM('DEBIT', 'CREDIT') NOT NULL, - account_code_id BIGINT NOT NULL, - amount DECIMAL(18, 2) NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - CONSTRAINT fk_entry_voucher - FOREIGN KEY (ledger_voucher_id) REFERENCES ledger_voucher (ledger_voucher_id) - ON DELETE CASCADE, - - CONSTRAINT fk_entry_account_code - FOREIGN KEY (account_code_id) REFERENCES ledger_code (account_code_id) - ON DELETE RESTRICT +CREATE TABLE ledger_entry ( + ledger_entry_id BIGINT AUTO_INCREMENT PRIMARY KEY, + ledger_voucher_id BIGINT NOT NULL, + ledger_type ENUM('DEBIT', 'CREDIT') NOT NULL, + account_code_id BIGINT NOT NULL, + amount DECIMAL(18, 2) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_entry_voucher + FOREIGN KEY (ledger_voucher_id) REFERENCES ledger_voucher(ledger_voucher_id) + ON DELETE CASCADE, + + CONSTRAINT fk_entry_account_code + FOREIGN KEY (account_code_id) REFERENCES ledger_code(account_code_id) + ON DELETE RESTRICT ); -- 금융회사 테이블 -CREATE TABLE financial_company -( - financial_company_id BIGINT AUTO_INCREMENT PRIMARY KEY, - code VARCHAR(255) NOT NULL, - name VARCHAR(255) NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE financial_company ( + financial_company_id BIGINT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 예금 적금 테이블 -CREATE TABLE deposit_saving -( - deposit_saving_id BIGINT AUTO_INCREMENT PRIMARY KEY, - financial_company_id BIGINT NOT NULL, - type ENUM('DEPOSIT', 'SAVING') NOT NULL, -- 예/적금 - name VARCHAR(255) NOT NULL, - join_way VARCHAR(255), - interest_description VARCHAR(255), - spcl_condition VARCHAR(255), - ctc_note VARCHAR(1024), - max_limit VARCHAR(255), - save_month INT NOT NULL, - interest_rate DECIMAL(10, 2) NOT NULL, - max_interest_rate DECIMAL(10, 2) NOT NULL, - interest_rate_type VARCHAR(255) NOT NULL, - reserve_type VARCHAR(255), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (financial_company_id) REFERENCES financial_company (financial_company_id) +CREATE TABLE deposit_saving ( + deposit_saving_id BIGINT AUTO_INCREMENT PRIMARY KEY, + financial_company_id BIGINT NOT NULL, + type ENUM('DEPOSIT', 'SAVING') NOT NULL, -- 예/적금 + name VARCHAR(255) NOT NULL, + join_way VARCHAR(255), + interest_description VARCHAR(255), + spcl_condition VARCHAR(255), + ctc_note VARCHAR(1024), + max_limit VARCHAR(255), + save_month INT NOT NULL, + interest_rate DECIMAL(10, 2) NOT NULL, + max_interest_rate DECIMAL(10, 2) NOT NULL, + interest_rate_type VARCHAR(255) NOT NULL, + reserve_type VARCHAR(255), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (financial_company_id) REFERENCES financial_company(financial_company_id) ); -- 전세자금대출 테이블 -CREATE TABLE jeonse_loan -( - jeonse_loan_id BIGINT AUTO_INCREMENT PRIMARY KEY, - financial_company_id BIGINT NOT NULL, - name VARCHAR(255) NOT NULL, - join_way VARCHAR(255), - loan_expensive VARCHAR(255), - erly_fee VARCHAR(255), - dly_rate VARCHAR(255) NOT NULL, - loan_lmt VARCHAR(255), - repay_type_name VARCHAR(255), - lend_rate_type_name VARCHAR(255), - lend_rate_min DECIMAL(10, 2) NOT NULL, - lend_rate_max DECIMAL(10, 2) NOT NULL, - lend_rate_avg DECIMAL(10, 2) NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (financial_company_id) REFERENCES financial_company (financial_company_id) +CREATE TABLE jeonse_loan ( + jeonse_loan_id BIGINT AUTO_INCREMENT PRIMARY KEY, + financial_company_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + join_way VARCHAR(255), + loan_expensive VARCHAR(255), + erly_fee VARCHAR(255), + dly_rate VARCHAR(255) NOT NULL, + loan_lmt VARCHAR(255), + repay_type_name VARCHAR(255), + lend_rate_type_name VARCHAR(255), + lend_rate_min DECIMAL(10, 2) NOT NULL, + lend_rate_max DECIMAL(10, 2) NOT NULL, + lend_rate_avg DECIMAL(10, 2) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (financial_company_id) REFERENCES financial_company(financial_company_id) ); -CREATE TABLE personal_credit_loan -( - personal_credit_loan_id BIGINT AUTO_INCREMENT PRIMARY KEY, - financial_company_id BIGINT NOT NULL, - name VARCHAR(255) NOT NULL, - join_way VARCHAR(255), - crdt_prdt_type_nm VARCHAR(255), - cb_name VARCHAR(255) NOT NULL, - crdt_grad_1 DECIMAL(10, 2), - crdt_grad_4 DECIMAL(10, 2), - crdt_grad_5 DECIMAL(10, 2), - crdt_grad_6 DECIMAL(10, 2), - crdt_grad_10 DECIMAL(10, 2), - crdt_grad_11 DECIMAL(10, 2), - crdt_grad_12 DECIMAL(10, 2), - crdt_grad_13 DECIMAL(10, 2), - crdt_grad_avg DECIMAL(10, 2), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (financial_company_id) REFERENCES financial_company (financial_company_id) +CREATE TABLE personal_credit_loan ( + personal_credit_loan_id BIGINT AUTO_INCREMENT PRIMARY KEY, + financial_company_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + join_way VARCHAR(255), + crdt_prdt_type_nm VARCHAR(255), + cb_name VARCHAR(255) NOT NULL, + crdt_grad_1 DECIMAL(10, 2), + crdt_grad_4 DECIMAL(10, 2), + crdt_grad_5 DECIMAL(10, 2), + crdt_grad_6 DECIMAL(10, 2), + crdt_grad_10 DECIMAL(10, 2), + crdt_grad_11 DECIMAL(10, 2), + crdt_grad_12 DECIMAL(10, 2), + crdt_grad_13 DECIMAL(10, 2), + crdt_grad_avg DECIMAL(10, 2), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (financial_company_id) REFERENCES financial_company(financial_company_id) ); -- 위치 (Location) 테이블 -CREATE TABLE location -( - location_id BIGINT NOT NULL AUTO_INCREMENT, - address VARCHAR(255) NOT NULL, - point POINT NOT NULL, - location_number VARCHAR(30) NOT NULL, - place_type ENUM('대문화시설센터', '상업소', '학교', '은행', '병원') NOT NULL, - place_name VARCHAR(255) NOT NULL, - PRIMARY KEY (location_id), - UNIQUE KEY uk_location_number (location_number), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +CREATE TABLE location ( + location_id BIGINT NOT NULL AUTO_INCREMENT, + address VARCHAR(255) NOT NULL, + point POINT NOT NULL, + location_number VARCHAR(30) NOT NULL, + place_type ENUM('대문화시설센터', '상업소', '학교', '은행', '병원') NOT NULL, + place_name VARCHAR(255) NOT NULL, + PRIMARY KEY (location_id), + UNIQUE KEY uk_location_number (location_number), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 환율 (Exchange_rate) 테이블 -CREATE TABLE exchange_rate -( - exchange_id BIGINT NOT NULL AUTO_INCREMENT, - base_exchange CHAR(3) NOT NULL, - target_exchange CHAR(3) NOT NULL, - exchange_value DECIMAL(10, 4) NOT NULL, - bank_name VARCHAR(100) NOT NULL, - exchange_date DATE NOT NULL, - type ENUM('GETCASH', 'SELLCASH', 'SEND', 'RECEIVE','BASE') NOT NULL, - PRIMARY KEY (exchange_id), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -); \ No newline at end of file +CREATE TABLE exchange_rate ( + exchange_id BIGINT NOT NULL AUTO_INCREMENT, + base_exchange CHAR(3) NOT NULL, + target_exchange CHAR(3) NOT NULL, + exchange_value DECIMAL(10,4) NOT NULL, + bank_name VARCHAR(100) NOT NULL, + exchange_date DATE NOT NULL, + type ENUM('GETCASH', 'SELLCASH', 'SEND', 'RECEIVE','BASE') NOT NULL, + PRIMARY KEY (exchange_id), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); From e13930815678bd7d9e3e7778726f2684f5e53b8a Mon Sep 17 00:00:00 2001 From: bagjuna Date: Thu, 24 Jul 2025 09:37:36 +0900 Subject: [PATCH 09/12] =?UTF-8?q?Feat:=20=ED=99=98=EC=9C=A8=20api=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExchangeRateController를 통해 은행 5개의 환율 조회 기능 개발 - ExchangeRateController를 통해 특정 환율로 데이터 변환 수행 --- .../controller/ExchangeRateController.java | 59 +++++++ .../dto/request/ExchangeBankRequest.java | 36 ++++ .../dto/request/ExchangeQuickRequest.java | 30 ++++ .../exchange/dto/response/BankRateInfo.java | 30 ++++ .../dto/response/ExchangeBankResponse.java | 27 +++ .../dto/response/ExchangeQuickResponse.java | 28 +++ .../domain/exchange/entity/ExchangeRate.java | 7 +- .../scoula/domain/exchange/entity/Type.java | 2 +- .../exchange/exception/ExchangeErrorCode.java | 20 +++ .../exchange/mapper/ExchangeRateMapper.java | 22 +++ .../exchange/service/ExchangeRateService.java | 16 ++ .../service/ExchangeRateServiceImpl.java | 159 ++++++++++++++++++ .../global/common/config/RootConfig.java | 20 ++- .../exchange/mapper/ExchangeRateMapper.xml | 45 +++++ 14 files changed, 491 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/scoula/domain/exchange/controller/ExchangeRateController.java create mode 100644 src/main/java/org/scoula/domain/exchange/dto/request/ExchangeBankRequest.java create mode 100644 src/main/java/org/scoula/domain/exchange/dto/request/ExchangeQuickRequest.java create mode 100644 src/main/java/org/scoula/domain/exchange/dto/response/BankRateInfo.java create mode 100644 src/main/java/org/scoula/domain/exchange/dto/response/ExchangeBankResponse.java create mode 100644 src/main/java/org/scoula/domain/exchange/dto/response/ExchangeQuickResponse.java create mode 100644 src/main/java/org/scoula/domain/exchange/exception/ExchangeErrorCode.java create mode 100644 src/main/java/org/scoula/domain/exchange/mapper/ExchangeRateMapper.java create mode 100644 src/main/java/org/scoula/domain/exchange/service/ExchangeRateService.java create mode 100644 src/main/java/org/scoula/domain/exchange/service/ExchangeRateServiceImpl.java create mode 100644 src/main/resources/org/scoula/domain/exchange/mapper/ExchangeRateMapper.xml diff --git a/src/main/java/org/scoula/domain/exchange/controller/ExchangeRateController.java b/src/main/java/org/scoula/domain/exchange/controller/ExchangeRateController.java new file mode 100644 index 00000000..3a10e973 --- /dev/null +++ b/src/main/java/org/scoula/domain/exchange/controller/ExchangeRateController.java @@ -0,0 +1,59 @@ +package org.scoula.domain.exchange.controller; + +import javax.servlet.http.HttpServletRequest; + +import org.scoula.domain.exchange.dto.request.ExchangeBankRequest; +import org.scoula.domain.exchange.dto.request.ExchangeQuickRequest; +import org.scoula.domain.exchange.dto.response.ExchangeBankResponse; +import org.scoula.domain.exchange.dto.response.ExchangeQuickResponse; +import org.scoula.domain.exchange.service.ExchangeRateService; +import org.springframework.http.ResponseEntity; +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 io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@RequiredArgsConstructor +@RestController +@RequestMapping("/exchange") +@Api(tags = "환율 API") +public class ExchangeRateController { + + private final ExchangeRateService exchangeRateService; + + @ApiOperation( + value = "환율 계산 및 비교", + notes = "KRW 기준으로 목표 통화에 대한 5개 은행의 환율을 비교하고 최적 환율을 제공합니다." + ) + @ApiResponses({ + @ApiResponse(code = 200, message = "성공적으로 요청이 처리되었습니다.", response = ExchangeBankResponse.class), + @ApiResponse(code = 400, message = "잘못된 요청입니다."), + @ApiResponse(code = 500, message = "서버에서 오류가 발생했습니다.") + }) + @PostMapping("/calculate/bank-rates") + public ResponseEntity calculateExchangeRateBank( + @RequestBody ExchangeBankRequest requestBody, + HttpServletRequest httpServletRequest) { + ExchangeBankResponse response = exchangeRateService.calculateExchangeBank(requestBody, httpServletRequest); + + return ResponseEntity.ok(response); + } + + @PostMapping("/calculate/quick-rate") + public ResponseEntity calculateExchangeQuickNormal( + @RequestBody ExchangeQuickRequest requestBody, + HttpServletRequest httpServletRequest) { + ExchangeQuickResponse response = exchangeRateService.calculateExchangeQuick(requestBody, httpServletRequest); + + return ResponseEntity.ok(response); + } + +} diff --git a/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeBankRequest.java b/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeBankRequest.java new file mode 100644 index 00000000..69026123 --- /dev/null +++ b/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeBankRequest.java @@ -0,0 +1,36 @@ +package org.scoula.domain.exchange.dto.request; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import java.math.BigDecimal; + +@Data +@ApiModel(description = "환율 계산 요청 정보") +public class ExchangeBankRequest { + + @ApiModelProperty( + value = "환전하려는 원화(KRW) 금액", + example = "1000000", + required = true, + position = 1 + ) + private BigDecimal amountInKRW; + + @ApiModelProperty( + value = "목표 통화 코드", + example = "VND", + required = true, + allowableValues = "USD, JPY, EUR, GBP, CAD, HKD, CNY, THB, IDR, VND, RUB, MYR", + position = 2 + ) + private String targetCurrency; + + @ApiModelProperty( + value = "환율 타입", + example = "GETCASH", + required = true, + allowableValues = "GETCASH, SELLCASH, SEND, RECEIVE, BASE", + position = 3 + ) + private String type; +} diff --git a/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeQuickRequest.java b/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeQuickRequest.java new file mode 100644 index 00000000..bd6dd2ff --- /dev/null +++ b/src/main/java/org/scoula/domain/exchange/dto/request/ExchangeQuickRequest.java @@ -0,0 +1,30 @@ +package org.scoula.domain.exchange.dto.request; + +import java.math.BigDecimal; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@Data +@ApiModel(description = "환율 계산 요청 정보") +public class ExchangeQuickRequest { + + @ApiModelProperty( + value = "환전하려는 원화(KRW) 금액", + example = "1000000", + required = true, + position = 1 + ) + private BigDecimal amountInKRW; + + @ApiModelProperty( + value = "목표 통화 코드", + example = "VND", + required = true, + allowableValues = "USD, JPY, EUR, GBP, CAD, HKD, CNY, THB, IDR, VND, RUB, MYR", + position = 2 + ) + private String targetCurrency; + +} diff --git a/src/main/java/org/scoula/domain/exchange/dto/response/BankRateInfo.java b/src/main/java/org/scoula/domain/exchange/dto/response/BankRateInfo.java new file mode 100644 index 00000000..e2d12470 --- /dev/null +++ b/src/main/java/org/scoula/domain/exchange/dto/response/BankRateInfo.java @@ -0,0 +1,30 @@ +package org.scoula.domain.exchange.dto.response; + +import java.math.BigDecimal; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@ApiModel(description = "은행별 환율 정보") +public class BankRateInfo { + + @ApiModelProperty(value = "은행명", example = "KB국민은행") + private String bankName; + + @ApiModelProperty(value = "환율 (1 목표통화당 KRW)", example = "0.053") + private BigDecimal exchangeRate; + + @ApiModelProperty(value = "환율 표시 문자열", example = "1 VND(100) = 5.3 KRW") + private String rateDisplay; + + @ApiModelProperty(value = "환전 후 받게 될 목표 통화 금액", example = "18867924.53") + private BigDecimal totalAmount; + + @ApiModelProperty(value = "환전 후 금액 표시 문자열", example = "18,867,925 VND") + private String totalAmountDisplay; + +} diff --git a/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeBankResponse.java b/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeBankResponse.java new file mode 100644 index 00000000..7ae309ef --- /dev/null +++ b/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeBankResponse.java @@ -0,0 +1,27 @@ +package org.scoula.domain.exchange.dto.response; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Builder; +import lombok.Data; +import java.math.BigDecimal; +import java.util.List; + +@Data +@Builder +@ApiModel(description = "환율 계산 응답 정보") +public class ExchangeBankResponse { + + @ApiModelProperty(value = "요청한 원화 금액", example = "1000000") + private BigDecimal requestedAmountKRW; + + @ApiModelProperty(value = "목표 통화 코드", example = "VND") + private String targetCurrency; + + @ApiModelProperty(value = "환율 타입", example = "SELLCASH") + private String exchangeType; + + @ApiModelProperty(value = "모든 은행의 환율 정보 (환율이 유리한 순서로 정렬)") + private List allBanks; + +} diff --git a/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeQuickResponse.java b/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeQuickResponse.java new file mode 100644 index 00000000..70a4b3e3 --- /dev/null +++ b/src/main/java/org/scoula/domain/exchange/dto/response/ExchangeQuickResponse.java @@ -0,0 +1,28 @@ +package org.scoula.domain.exchange.dto.response; + +import java.math.BigDecimal; +import java.util.List; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@ApiModel(description = "환율 계산 응답 정보") +public class ExchangeQuickResponse { + + @ApiModelProperty(value = "요청한 원화 금액", example = "1000000") + private BigDecimal requestedAmountKRW; + + @ApiModelProperty(value = "목표 통화 코드", example = "VND") + private String targetCurrency; + + @ApiModelProperty(value = "5.3", example = "5.3") + private BigDecimal ResultRate; + + @ApiModelProperty(value = "5.3 VND(100)", example = "5.3 VND") + private String ResultRateDisplay; + +} diff --git a/src/main/java/org/scoula/domain/exchange/entity/ExchangeRate.java b/src/main/java/org/scoula/domain/exchange/entity/ExchangeRate.java index 1a5026fc..e37b97ba 100644 --- a/src/main/java/org/scoula/domain/exchange/entity/ExchangeRate.java +++ b/src/main/java/org/scoula/domain/exchange/entity/ExchangeRate.java @@ -7,14 +7,18 @@ import org.scoula.global.entity.BaseEntity; import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; /** * 환율 */ @Getter +@Setter // MyBatis는 setter가 필요합니다! @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor public class ExchangeRate extends BaseEntity { private Long exchangeId; @@ -23,6 +27,5 @@ public class ExchangeRate extends BaseEntity { private BigDecimal exchangeValue; // 환율 값 private String bankName; // 은행 이름 private LocalDate ExchangeDate; // 환율 적용 날짜 - private Type type; // 환전 타입 / GETCASH(현금 인출), SELFCASH(현금 입금), SEND(송금), RECEIVE(수취), BASE(기준 환율) - + private Type type; // 환전 타입 / SELLCASH(현금 송금), GETCASH(현금 수취), SEND(송금), RECEIVE(수취), BASE(기준 환율) } diff --git a/src/main/java/org/scoula/domain/exchange/entity/Type.java b/src/main/java/org/scoula/domain/exchange/entity/Type.java index b7a3c7e3..edc78a25 100644 --- a/src/main/java/org/scoula/domain/exchange/entity/Type.java +++ b/src/main/java/org/scoula/domain/exchange/entity/Type.java @@ -1,5 +1,5 @@ package org.scoula.domain.exchange.entity; public enum Type { - GETCASH, SELFCASH, SEND, RECEIVE, BASE + GETCASH, SELLCASH, SEND, RECEIVE, BASE } diff --git a/src/main/java/org/scoula/domain/exchange/exception/ExchangeErrorCode.java b/src/main/java/org/scoula/domain/exchange/exception/ExchangeErrorCode.java new file mode 100644 index 00000000..aebe4d6d --- /dev/null +++ b/src/main/java/org/scoula/domain/exchange/exception/ExchangeErrorCode.java @@ -0,0 +1,20 @@ +package org.scoula.domain.exchange.exception; + +import org.scoula.global.exception.errorCode.ErrorCode; +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ExchangeErrorCode implements ErrorCode { + + // 400 + EXCHANGE_NOT_FOUND(HttpStatus.NOT_FOUND, "M-001", "존재하지 않는 화폐 입니다."), + EXCHANGE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "M-002", "이미 존재하는 회원입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/org/scoula/domain/exchange/mapper/ExchangeRateMapper.java b/src/main/java/org/scoula/domain/exchange/mapper/ExchangeRateMapper.java new file mode 100644 index 00000000..90330898 --- /dev/null +++ b/src/main/java/org/scoula/domain/exchange/mapper/ExchangeRateMapper.java @@ -0,0 +1,22 @@ +package org.scoula.domain.exchange.mapper; + +import org.scoula.domain.exchange.entity.ExchangeRate; +import org.apache.ibatis.annotations.Param; + + +public interface ExchangeRateMapper { + + + /** + * 최신 환율 날짜 조회 + * @return 가장 최근의 환율 날짜 + */ + ExchangeRate findLatestExchangeRate( + @Param("bankName") String bankName, + @Param("type") String type, + @Param("targetCurrency") String targetCurrency + ); + + + +} diff --git a/src/main/java/org/scoula/domain/exchange/service/ExchangeRateService.java b/src/main/java/org/scoula/domain/exchange/service/ExchangeRateService.java new file mode 100644 index 00000000..67055081 --- /dev/null +++ b/src/main/java/org/scoula/domain/exchange/service/ExchangeRateService.java @@ -0,0 +1,16 @@ +package org.scoula.domain.exchange.service; + +import javax.servlet.http.HttpServletRequest; + +import org.scoula.domain.exchange.dto.request.ExchangeBankRequest; +import org.scoula.domain.exchange.dto.request.ExchangeQuickRequest; +import org.scoula.domain.exchange.dto.response.ExchangeBankResponse; +import org.scoula.domain.exchange.dto.response.ExchangeQuickResponse; + +public interface ExchangeRateService { + + ExchangeBankResponse calculateExchangeBank(ExchangeBankRequest request, HttpServletRequest httpServletRequest); + + ExchangeQuickResponse calculateExchangeQuick(ExchangeQuickRequest request, HttpServletRequest httpServletRequest); + +} diff --git a/src/main/java/org/scoula/domain/exchange/service/ExchangeRateServiceImpl.java b/src/main/java/org/scoula/domain/exchange/service/ExchangeRateServiceImpl.java new file mode 100644 index 00000000..de348db9 --- /dev/null +++ b/src/main/java/org/scoula/domain/exchange/service/ExchangeRateServiceImpl.java @@ -0,0 +1,159 @@ +package org.scoula.domain.exchange.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import javax.servlet.http.HttpServletRequest; + +import org.scoula.domain.exchange.dto.request.ExchangeQuickRequest; +import org.scoula.domain.exchange.dto.response.BankRateInfo; +import org.scoula.domain.exchange.dto.response.ExchangeQuickResponse; +import org.scoula.domain.exchange.exception.ExchangeErrorCode; +import org.scoula.domain.exchange.mapper.ExchangeRateMapper; +import org.scoula.domain.exchange.dto.request.ExchangeBankRequest; +import org.scoula.domain.exchange.dto.response.ExchangeBankResponse; +import org.scoula.domain.exchange.entity.ExchangeRate; +import org.scoula.global.exception.CustomException; +import org.scoula.global.kafka.dto.Common; +import org.scoula.global.kafka.dto.LogLevel; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ExchangeRateServiceImpl implements ExchangeRateService { + + private final ExchangeRateMapper exchangeRateMapper; + private final DecimalFormat currencyFormatter = new DecimalFormat("#,###.##"); + private final DecimalFormat rateFormatter = new DecimalFormat("#,##0.####"); + + @Override + public ExchangeBankResponse calculateExchangeBank(ExchangeBankRequest request, + HttpServletRequest httpServletRequest) { + + List banks = List.of("KB국민은행", "하나은행", "신한은행", "우리은행", "기업은행"); + List rates = new ArrayList<>(); + + // 각 은행별 환율 정보 조회 + for (String bank : banks) { + ExchangeRate latestExchangeRate = exchangeRateMapper + .findLatestExchangeRate(bank, request.getType(), request.getTargetCurrency()); + if (latestExchangeRate != null) { + log.info("은행: {}, 환율: {}", bank, latestExchangeRate.getExchangeValue()); + BankRateInfo bankRateInfo = calculateBankRate(latestExchangeRate, request.getAmountInKRW()); + rates.add(bankRateInfo); + } + + } + + if (rates.isEmpty()) { + throw new CustomException( + ExchangeErrorCode.EXCHANGE_NOT_FOUND, LogLevel.ERROR, null, Common.builder().build() + ); + } + + // 환율 타입에 따른 정렬 + List sortedRates = sortByExchangeType(rates, request.getType()); + + return ExchangeBankResponse.builder() + .requestedAmountKRW(request.getAmountInKRW()) + .targetCurrency(request.getTargetCurrency()) + .exchangeType(request.getType()) + .allBanks(sortedRates) + .build(); + } + + @Override + public ExchangeQuickResponse calculateExchangeQuick(ExchangeQuickRequest request, + HttpServletRequest httpServletRequest) { + return calculateExchangeQuickNormal(request); + + } + + private List sortByExchangeType(List rates, String type) { + Comparator comparator; + + switch (type) { + case "SELLCASH": // 현찰 팔 때 - 낮은 환율이 유리 + case "SEND": // 송금 보낼 때 - 낮은 환율이 유리 + case "BASE": // 기준율 - 낮은 순으로 정렬 + comparator = Comparator.comparing(BankRateInfo::getExchangeRate); + break; + + case "GETCASH": // 현찰 살 때 - 높은 환율이 유리 + case "RECEIVE": // 송금 받을 때 - 높은 환율이 유리 + comparator = Comparator.comparing(BankRateInfo::getExchangeRate).reversed(); + break; + + default: + comparator = Comparator.comparing(BankRateInfo::getExchangeRate); + } + + return rates.stream() + .sorted(comparator) + .collect(Collectors.toList()); + } + + private BankRateInfo calculateBankRate(ExchangeRate rate, BigDecimal amountInKRW) { + // 환전 후 받게 될 목표 통화 금액 계산 + BigDecimal targetAmount; + String rateDisplay; + + // KRW -> 목표통화: 원화금액 / 환율 + targetAmount = amountInKRW.divide(rate.getExchangeValue(), 2, RoundingMode.HALF_UP); + + // 환율 표시 (1 목표통화 = X KRW) + rateDisplay = String.format("1 %s = %s KRW", + rate.getTargetExchange(), + rateFormatter.format(rate.getExchangeValue())); + + + return BankRateInfo.builder() + .bankName(rate.getBankName()) + .exchangeRate(rate.getExchangeValue()) + .rateDisplay(rateDisplay) + .totalAmount(targetAmount) + .totalAmountDisplay(String.format("%s %s", + currencyFormatter.format(targetAmount.setScale(2, RoundingMode.HALF_UP)), + rate.getTargetExchange())) + .build(); + } + + private ExchangeQuickResponse calculateExchangeQuickNormal(ExchangeQuickRequest request) { + + // 기준 통화(KRW)에서 목표 통화로의 환율 조회 + ExchangeRate baseRate = exchangeRateMapper.findLatestExchangeRate( + "KB국민은행", "BASE", request.getTargetCurrency()); + + if (baseRate == null) { + throw new CustomException( + ExchangeErrorCode.EXCHANGE_NOT_FOUND, LogLevel.ERROR, null, Common.builder().build() + ); + } + + BigDecimal targetAmount = request.getAmountInKRW().divide( + baseRate.getExchangeValue(), 2, RoundingMode.HALF_UP); + + String rateDisplay = String.format("%s %s", + currencyFormatter.format(targetAmount.setScale(2, RoundingMode.HALF_UP)), + request.getTargetCurrency()); + + return ExchangeQuickResponse.builder() + .requestedAmountKRW(request.getAmountInKRW()) + .targetCurrency(request.getTargetCurrency()) + .ResultRate(targetAmount) + .ResultRateDisplay(rateDisplay) + .build(); + } + +} diff --git a/src/main/java/org/scoula/global/common/config/RootConfig.java b/src/main/java/org/scoula/global/common/config/RootConfig.java index dd99d8bd..f9a57cb4 100644 --- a/src/main/java/org/scoula/global/common/config/RootConfig.java +++ b/src/main/java/org/scoula/global/common/config/RootConfig.java @@ -21,19 +21,25 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; +/** + * RootConfig: 비즈니스 로직 (Service, Mapper, DataSource) + * 와일드카드 패턴 사용 (**) + * + * org.scoula.domain.**.mapper - 모든 도메인의 mapper 자동 스캔 + * org.scoula.domain.**.service - 모든 도메인의 service 자동 스캔 + * org.scoula.domain.**.controller - 모든 도메인의 controller 자동 스캔 + */ @Configuration @PropertySource({"classpath:/application.properties"}) -@MapperScan(basePackages = {"org.scoula.domain"}) +@MapperScan(basePackages = {"org.scoula.domain.**.mapper"}) // 게시판과 회원 도메인 매퍼 스캔 @ComponentScan(basePackages = { + // "org.scoula.service", + "org.scoula.domain.**.service", // 도메인 객체를 포함하기 위해 추가 "org.scoula.global.swagger.config", // Swagger 설정을 포함하기 위해 추가 "org.scoula.global.kafka", // kafka 설정 포함 "org.scoula.global.exception", // exception handler 등록 - "org.scoula.global.redis", - "org.scoula.global.security", - "org.scoula.domain.**.service", - // "org.scoula.domain.codef.service", - "org.scoula.global.redis.util", - "org.scoula.global.redis.config" + "org.scoula.global.redis", // 공통 설정 포함 + "org.scoula.global.security", // security 설정 포함 }) public class RootConfig { @Value("${jdbc.driver}") diff --git a/src/main/resources/org/scoula/domain/exchange/mapper/ExchangeRateMapper.xml b/src/main/resources/org/scoula/domain/exchange/mapper/ExchangeRateMapper.xml new file mode 100644 index 00000000..57718790 --- /dev/null +++ b/src/main/resources/org/scoula/domain/exchange/mapper/ExchangeRateMapper.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + From 1e6f862c0052e830313b1053a5bde477521d16c2 Mon Sep 17 00:00:00 2001 From: Junbeom Lee <63588899+ss7622@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:27:13 +0900 Subject: [PATCH 10/12] =?UTF-8?q?[Feat]:=20=ED=8C=A8=EC=8A=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B8=EC=BD=94=EB=94=A9=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: password 인코딩 후 저장 코드 추가 * feat: valid 조건 변경 * feat: 임시 회원가입을 위한 예외 처리 무효화 --- .../scoula/domain/codef/service/CodefApiClient.java | 13 ++++++++++++- .../domain/member/dto/request/JoinRequest.java | 2 +- .../domain/member/service/JoinServiceImpl.java | 8 +++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/scoula/domain/codef/service/CodefApiClient.java b/src/main/java/org/scoula/domain/codef/service/CodefApiClient.java index 48499d76..be721a33 100644 --- a/src/main/java/org/scoula/domain/codef/service/CodefApiClient.java +++ b/src/main/java/org/scoula/domain/codef/service/CodefApiClient.java @@ -4,6 +4,8 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import javax.servlet.http.HttpServletRequest; @@ -63,7 +65,16 @@ public StayExpirationResponse getStayExpiration(StayExpirationRequest stayExpira if (resultCode.equals("CF-00000")) { if (response.data().resAuthenticity().equals("0")) { - throw new CustomException(STAY_AUTHENTICITY_FAILED, LogLevel.WARNING, null, common); + // throw new CustomException(STAY_AUTHENTICITY_FAILED, LogLevel.WARNING, null, common); + return new StayExpirationResponse( + "0", // resAuthenticity + "현재 체류중인 외국인이 아닙니다.", // resAuthenticityDesc + null, // resPassportNo + null, // resNationality + null, // commBirthDate + null, // resStatus + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) // resExpirationDate + ); } return response.data(); } else if (resultCode.equals("CF-00001")) { diff --git a/src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java b/src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java index a27b68c5..ae3617f6 100644 --- a/src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java +++ b/src/main/java/org/scoula/domain/member/dto/request/JoinRequest.java @@ -35,7 +35,7 @@ public record JoinRequest( String passportNumber, @ApiModelProperty(value = "국적", example = "USA", required = true) - @NotBlank(message = "국적은 필수 입력 값입니다.") + @NotNull(message = "국적은 필수 입력 값입니다.") NationalityCode nationality, @ApiModelProperty(value = "나라", example = "덴마크 - nationality가 OTHER일 때의 나라명", required = false) diff --git a/src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java b/src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java index 3d9b43e9..69789a51 100644 --- a/src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java +++ b/src/main/java/org/scoula/domain/member/service/JoinServiceImpl.java @@ -17,6 +17,7 @@ import org.scoula.global.exception.CustomException; import org.scoula.global.kafka.dto.Common; import org.scoula.global.kafka.dto.LogLevel; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +33,7 @@ public class JoinServiceImpl implements JoinService { private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); private final MemberMapper memberMapper; private final CodefApiClient codefApiClient; + private final PasswordEncoder passwordEncoder; @Transactional @Override @@ -49,7 +51,7 @@ public void joinMember(JoinRequest joinRequest, HttpServletRequest request) thro Member member = Member.builder() .loginId(joinRequest.loginId()) - .password(joinRequest.password()) + .password(encodePassword(joinRequest.password())) .passportNumber(joinRequest.passportNumber()) .name(joinRequest.name()) .birth(LocalDate.parse(joinRequest.birth(), DATE_TIME_FORMATTER)) @@ -86,4 +88,8 @@ private boolean checkLoginIdDuplicate(String loginId) { return memberMapper.checkLoginIdDuplicate(loginId) > 0; } + private String encodePassword(String password) { + return passwordEncoder.encode(password); + } + } From ca49af9f0500230fa60a782ba936fba543f6f76e Mon Sep 17 00:00:00 2001 From: Junbeom Lee <63588899+ss7622@users.noreply.github.com> Date: Thu, 24 Jul 2025 14:15:46 +0900 Subject: [PATCH 11/12] =?UTF-8?q?hotfix:=20codef=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoula/global/security/filter/JwtAuthenticationFilter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java index b7eac0eb..c0e5ee5a 100644 --- a/src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/org/scoula/global/security/filter/JwtAuthenticationFilter.java @@ -59,7 +59,8 @@ protected boolean shouldNotFilter(HttpServletRequest request) { requestURI.startsWith("/swagger") || requestURI.startsWith("/v2") || requestURI.startsWith("/webjars") || - requestURI.startsWith("/configuration"); + requestURI.startsWith("/configuration") || + requestURI.startsWith("/codef"); } // Request Header에서 토큰 정보 추출 From 8e2521da0d2056844ddac682e996f97e6f8d6ad8 Mon Sep 17 00:00:00 2001 From: Junbeom Lee <63588899+ss7622@users.noreply.github.com> Date: Fri, 25 Jul 2025 09:16:37 +0900 Subject: [PATCH 12/12] =?UTF-8?q?hotfix:=20cors=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/scoula/global/security/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/scoula/global/security/config/SecurityConfig.java b/src/main/java/org/scoula/global/security/config/SecurityConfig.java index 18482702..61f58151 100644 --- a/src/main/java/org/scoula/global/security/config/SecurityConfig.java +++ b/src/main/java/org/scoula/global/security/config/SecurityConfig.java @@ -33,7 +33,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedOriginPatterns( - List.of("http://localhost:3000", "https://localhost:3000")); + List.of("http://localhost:5173", "https://localhost:5173")); corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH")); corsConfiguration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type"));