diff --git a/.github/workflows/core-application-ci-cd-flow.yml b/.github/workflows/core-application-prod-ci-cd-flow.yml similarity index 79% rename from .github/workflows/core-application-ci-cd-flow.yml rename to .github/workflows/core-application-prod-ci-cd-flow.yml index 0c5c24c7a..b5f8ecf6f 100644 --- a/.github/workflows/core-application-ci-cd-flow.yml +++ b/.github/workflows/core-application-prod-ci-cd-flow.yml @@ -1,13 +1,13 @@ -name: core-ci/cd +name: core-prod-ci/cd on: push: branches: - - develop_back_core - - 'hotfix/[0-9a-zA-z]+-B-core-#[0-9a-zA-z]+' + - prod_back_core + - 'hotfix/[0-9a-zA-z]+-B-prod-core-#[0-9a-zA-z]+' pull_request: branches: - - develop_back_core + - prod_back_core jobs: build_and_test: @@ -83,12 +83,6 @@ jobs: role-to-assume: ${{ secrets.AWS_ROLE_ARN }} aws-region: ${{ secrets.AWS_REGION }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 @@ -98,15 +92,14 @@ jobs: uses: docker/metadata-action@v5 with: images: | - ${{ steps.login-ecr.outputs.registry }}/${{ secrets.AWS_ECR_REPOSITORY_NAME }} + ${{ steps.login-ecr.outputs.registry }}/${{ secrets.AWS_PROD_ECR_CORE_REPOSITORY_NAME }} tags: | - type=raw,value=latest + type=raw,value={{date 'YYYYMMDD-HHmmss'}} - name: Build and push uses: docker/build-push-action@v5 with: context: ./backend/core - platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} provenance: false @@ -117,16 +110,16 @@ jobs: - name: Add Github Actions IP to Security group run: | - aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_EC2_CORE_SG_ID }} --group-name ${{secrets.AWS_EC2_CORE_SG_NAME}} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_PROD_CORE_NOTI_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 - name: Connect ec2 and Run Docker Container uses: appleboy/ssh-action@v0.1.6 env: AWS_ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} with: - host: ${{ secrets.SSH_CORE_HOST }} + host: ${{ secrets.SSH_PROD_CORE_NOTI_HOST }} username: ${{ secrets.SSH_USERNAME }} - key: ${{ secrets.SSH_CORE_PRIVATE_KEY }} + key: ${{ secrets.SSH_PROD_CORE_NOTI_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT }} script: | docker ps -q --filter "name=core" | xargs -r docker stop @@ -134,12 +127,12 @@ jobs: aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username ${{ secrets.AWS_DOCKER_USER }} --password-stdin ${{ secrets.AWS_USER_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com docker image prune -f docker pull ${{ steps.meta.outputs.tags }} - docker run -d -p 8080:8080 -e ENVIRONMENT=dev --name core --network test_backend ${{ steps.meta.outputs.tags }} + docker run -d -p 8080:8080 -e ENVIRONMENT=prod --name core --network ec2-user_backend ${{ steps.meta.outputs.tags }} - name: Remove Github Actions IP from security group if: always() run: | - aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_EC2_CORE_SG_ID }} --group-name ${{secrets.AWS_EC2_CORE_SG_NAME}} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_PROD_CORE_NOTI_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 - uses: sarisia/actions-status-discord@v1 if: success() @@ -147,8 +140,8 @@ jobs: webhook: ${{ secrets.DISCORD_WEBHOOK }} status: ${{ job.status }} content: "여러분 <@384742716933668867> <@1084774841460215839> <@545902166842408960> <@1081452554149449748>\n 배포 완료했습니다!!" - title: "배포 완료 알림" - description: "백엔드 개발 브랜치에 깃허브 액션으로 배포 완료" + title: "코어 운영 서버 배포 완료 알림" + description: "백엔드 개발 브랜치에서 운영 환경으로 깃허브 액션으로 배포 완료" image: ${{ secrets.EMBED_IMAGE }} color: 0x0000ff url: "https://github.com/tukcomCD2024/DroidBlossom/actions" diff --git a/.github/workflows/core-application-test-ci-cd-flow.yml b/.github/workflows/core-application-test-ci-cd-flow.yml new file mode 100644 index 000000000..bd2f4ee1b --- /dev/null +++ b/.github/workflows/core-application-test-ci-cd-flow.yml @@ -0,0 +1,149 @@ +name: core-test-ci/cd + +on: + push: + branches: + - develop_back_core + - 'hotfix/[0-9a-zA-z]+-B-core-#[0-9a-zA-z]+' + pull_request: + branches: + - develop_back_core + +jobs: + build_and_test: + name: build and test + if: ${{github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./backend/core + + steps: + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'liberica' + + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.CI_PAT }} + + - name: formatting + uses: axel-op/googlejavaformat-action@v3 + with: + args: "--replace" + skip-commit: true + + - name: grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: test + run: ./gradlew clean test + + deploy-core: + name: core application deploy + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./backend/core + + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.CI_PAT }} + + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'liberica' + cache: gradle + + - name: grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: build gradle + run: ./gradlew clean build + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ steps.login-ecr.outputs.registry }}/${{ secrets.AWS_TEST_ENV_ECR_CORE_REPOSITORY_NAME }} + tags: | + type=raw,value={{date 'YYYYMMDD-HHmmss'}} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./backend/core + push: true + tags: ${{ steps.meta.outputs.tags }} + provenance: false + + - name: Get Github action IP + id: ip + uses: haythem/public-ip@v1.3 + + - name: Add Github Actions IP to Security group + run: | + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_TEST_ENV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + - name: Connect ec2 and Run Docker Container + uses: appleboy/ssh-action@v0.1.6 + env: + AWS_ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + with: + host: ${{ secrets.SSH_TEST_ENV_HOST }} + username: ${{ secrets.SSH_TEST_ENV_USERNAME }} + key: ${{ secrets.SSH_TEST_ENV_PRIVATE_KEY }} + port: ${{ secrets.SSH_TEST_ENV_PORT }} + script: | + docker ps -q --filter "name=core" | xargs -r docker stop + docker ps -aq --filter "name=core" | xargs -r docker rm + aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username ${{ secrets.AWS_TEST_ENV_DOCKER_USER }} --password-stdin ${{ secrets.AWS_TEST_ENV_USER_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + docker image prune -f + docker pull ${{ steps.meta.outputs.tags }} + docker run -d -p 8080:8080 -e ENVIRONMENT=container --name core --network test_backend ${{ steps.meta.outputs.tags }} + + - name: Remove Github Actions IP from security group + if: always() + run: | + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_TEST_ENV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + - uses: sarisia/actions-status-discord@v1 + if: success() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK }} + status: ${{ job.status }} + content: "여러분 <@384742716933668867> <@1084774841460215839> <@545902166842408960> <@1081452554149449748>\n 배포 완료했습니다!!" + title: "코어 테스트 서버 배포 완료 알림" + description: "백엔드 개발 브랜치에서 테스트 환경으로 깃허브 액션으로 배포 완료" + image: ${{ secrets.EMBED_IMAGE }} + color: 0x0000ff + url: "https://github.com/tukcomCD2024/DroidBlossom/actions" + username: GitHub Actions Bot + avatar_url: ${{ secrets.AVATAR_URL }} \ No newline at end of file diff --git a/.github/workflows/notification-application-ci-cd-flow.yml b/.github/workflows/notification-application-test-ci-cd-flow.yml similarity index 77% rename from .github/workflows/notification-application-ci-cd-flow.yml rename to .github/workflows/notification-application-test-ci-cd-flow.yml index 3e28a936d..69d5369ed 100644 --- a/.github/workflows/notification-application-ci-cd-flow.yml +++ b/.github/workflows/notification-application-test-ci-cd-flow.yml @@ -1,4 +1,4 @@ -name: notification-ci/cd +name: notification-test-ci/cd on: push: @@ -83,12 +83,6 @@ jobs: role-to-assume: ${{ secrets.AWS_ROLE_ARN }} aws-region: ${{ secrets.AWS_REGION }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 @@ -98,15 +92,14 @@ jobs: uses: docker/metadata-action@v5 with: images: | - ${{ steps.login-ecr.outputs.registry }}/${{ secrets.AWS_ECR_NOTIFICATION_REPOSITORY_NAME }} + ${{ steps.login-ecr.outputs.registry }}/${{ secrets.AWS_TEST_ENV_NOTIFICATION_REPOSITORY_NAME }} tags: | - type=raw,value=latest + type=raw,value={{date 'YYYYMMDD-HHmmss'}} - name: Build and push uses: docker/build-push-action@v5 with: context: ./backend/notification - platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} provenance: false @@ -117,29 +110,29 @@ jobs: - name: Add Github Actions IP to Security group run: | - aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_EC2_CORE_SG_ID }} --group-name ${{secrets.AWS_EC2_CORE_SG_NAME}} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_TEST_ENV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 - name: Connect ec2 and Run Docker Container uses: appleboy/ssh-action@v0.1.6 env: AWS_ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} with: - host: ${{ secrets.SSH_CORE_HOST }} - username: ${{ secrets.SSH_USERNAME }} - key: ${{ secrets.SSH_CORE_PRIVATE_KEY }} - port: ${{ secrets.SSH_PORT }} + host: ${{ secrets.SSH_TEST_ENV_HOST }} + username: ${{ secrets.SSH_TEST_ENV_USERNAME }} + key: ${{ secrets.SSH_TEST_ENV_PRIVATE_KEY }} + port: ${{ secrets.SSH_TEST_ENV_PORT }} script: | docker ps -q --filter "name=notification" | xargs -r docker stop docker ps -aq --filter "name=notification" | xargs -r docker rm - aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username ${{ secrets.AWS_DOCKER_USER }} --password-stdin ${{ secrets.AWS_USER_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com + aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username ${{ secrets.AWS_TEST_ENV_DOCKER_USER }} --password-stdin ${{ secrets.AWS_TEST_ENV_USER_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com docker image prune -f docker pull ${{ steps.meta.outputs.tags }} - docker run -d -p 8081:8081 -e ENVIRONMENT=dev --name notification --network test_backend ${{ steps.meta.outputs.tags }} + docker run -d -p 8081:8081 -e ENVIRONMENT=container --name notification --network test_backend ${{ steps.meta.outputs.tags }} - name: Remove Github Actions IP from security group if: always() run: | - aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_EC2_CORE_SG_ID }} --group-name ${{secrets.AWS_EC2_CORE_SG_NAME}} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_TEST_ENV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 - uses: sarisia/actions-status-discord@v1 if: success() @@ -147,8 +140,8 @@ jobs: webhook: ${{ secrets.DISCORD_WEBHOOK }} status: ${{ job.status }} content: "여러분 <@384742716933668867> <@1084774841460215839> <@545902166842408960> <@1081452554149449748>\n 배포 완료했습니다!!" - title: "알림 서버 배포 완료 알림" - description: "백엔드 알림 개발 브랜치에 깃허브 액션으로 배포 완료" + title: "알림 테스트 서버 배포 완료 알림" + description: "백엔드 알림 개발 브랜치에 깃허브 액션으로 테스트 서버 배포 완료" image: ${{ secrets.EMBED_IMAGE }} color: 0x0000ff url: "https://github.com/tukcomCD2024/DroidBlossom/actions" diff --git a/.gitignore b/.gitignore index 51e94bb56..0fd891e42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea -backend/data \ No newline at end of file +backend/data + +.env \ No newline at end of file diff --git a/backend/core/build.gradle b/backend/core/build.gradle index 196b0558a..d65dfbe74 100644 --- a/backend/core/build.gradle +++ b/backend/core/build.gradle @@ -75,6 +75,15 @@ dependencies { //aop implementation 'org.springframework.boot:spring-boot-starter-aop' + //loki + implementation 'com.github.loki4j:loki-logback-appender:1.4.1' + + // Spring Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Prometheus + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/api/AnnouncementApi.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/api/AnnouncementApi.java new file mode 100644 index 000000000..1787aa40b --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/api/AnnouncementApi.java @@ -0,0 +1,23 @@ +package site.timecapsulearchive.core.domain.announcement.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; +import site.timecapsulearchive.core.domain.announcement.data.response.AnnouncementsResponse; +import site.timecapsulearchive.core.global.common.response.ApiSpec; + +public interface AnnouncementApi { + @Operation( + summary = "공지사항 페이지", + description = "모든 공지사항을 가져온다. 최신 공지가 리스트의 맨 처음에 위치한다.", + tags = {"announcement"} + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "ok" + ) + }) + ResponseEntity> getAnnouncements(); +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/api/AnnouncementApiController.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/api/AnnouncementApiController.java new file mode 100644 index 000000000..5f2f2e136 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/api/AnnouncementApiController.java @@ -0,0 +1,34 @@ +package site.timecapsulearchive.core.domain.announcement.api; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import site.timecapsulearchive.core.domain.announcement.data.response.AnnouncementsResponse; +import site.timecapsulearchive.core.domain.announcement.data.dto.AnnouncementDto; +import site.timecapsulearchive.core.domain.announcement.service.AnnouncementService; +import site.timecapsulearchive.core.global.common.response.ApiSpec; +import site.timecapsulearchive.core.global.common.response.SuccessCode; + +@RestController +@RequestMapping("/announcement") +@RequiredArgsConstructor +public class AnnouncementApiController implements AnnouncementApi { + + private final AnnouncementService announcementService; + + @GetMapping + @Override + public ResponseEntity> getAnnouncements() { + List announcements = announcementService.findAll(); + + return ResponseEntity.ok( + ApiSpec.success( + SuccessCode.SUCCESS, + AnnouncementsResponse.createOf(announcements) + ) + ); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/data/dto/AnnouncementDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/data/dto/AnnouncementDto.java new file mode 100644 index 000000000..a9f44e243 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/data/dto/AnnouncementDto.java @@ -0,0 +1,16 @@ +package site.timecapsulearchive.core.domain.announcement.data.dto; + +import java.time.ZonedDateTime; +import site.timecapsulearchive.core.domain.announcement.data.response.AnnouncementResponse; + +public record AnnouncementDto( + String title, + String content, + String version, + ZonedDateTime createdAt +) { + + public AnnouncementResponse toResponse() { + return new AnnouncementResponse(title, content, version, createdAt); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/data/response/AnnouncementResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/data/response/AnnouncementResponse.java new file mode 100644 index 000000000..b359ab911 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/data/response/AnnouncementResponse.java @@ -0,0 +1,27 @@ +package site.timecapsulearchive.core.domain.announcement.data.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.ZonedDateTime; +import site.timecapsulearchive.core.global.common.response.ResponseMappingConstant; + +@Schema(description = "공지사항 응답") +public record AnnouncementResponse( + @Schema(description = "제목") + String title, + + @Schema(description = "내용") + String content, + + @Schema(description = "공지사항 버전") + String version, + + @Schema(description = "공지사항 생성일") + ZonedDateTime createdAt +) { + + public AnnouncementResponse { + if (createdAt != null) { + createdAt = createdAt.withZoneSameInstant(ResponseMappingConstant.ZONE_ID); + } + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/data/response/AnnouncementsResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/data/response/AnnouncementsResponse.java new file mode 100644 index 000000000..aca4a54e2 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/data/response/AnnouncementsResponse.java @@ -0,0 +1,20 @@ +package site.timecapsulearchive.core.domain.announcement.data.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import site.timecapsulearchive.core.domain.announcement.data.dto.AnnouncementDto; + +@Schema(description = "공지사항 목록 응답") +public record AnnouncementsResponse( + @Schema(description = "공지사항 목록") + List announcements +) { + + public static AnnouncementsResponse createOf(List announcementDtos) { + return new AnnouncementsResponse( + announcementDtos.stream() + .map(AnnouncementDto::toResponse) + .toList() + ); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/entity/Announcement.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/entity/Announcement.java new file mode 100644 index 000000000..e61925423 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/entity/Announcement.java @@ -0,0 +1,33 @@ +package site.timecapsulearchive.core.domain.announcement.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import site.timecapsulearchive.core.global.entity.BaseEntity; + +@Entity +@Table(name = "announcement") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Announcement extends BaseEntity { + + @Id + @Column(name = "announcement_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "title") + private String title; + + @Column(name = "content") + private String content; + + @Column(name = "version") + private String version; +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/repository/AnnouncementRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/repository/AnnouncementRepository.java new file mode 100644 index 000000000..dc5b9182a --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/repository/AnnouncementRepository.java @@ -0,0 +1,32 @@ +package site.timecapsulearchive.core.domain.announcement.repository; + +import static site.timecapsulearchive.core.domain.announcement.entity.QAnnouncement.announcement; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import site.timecapsulearchive.core.domain.announcement.data.dto.AnnouncementDto; + +@Repository +@RequiredArgsConstructor +public class AnnouncementRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findAll() { + return jpaQueryFactory + .select( + Projections.constructor( + AnnouncementDto.class, + announcement.title, + announcement.content, + announcement.version, + announcement.createdAt + ) + ) + .from(announcement) + .fetch(); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/service/AnnouncementService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/service/AnnouncementService.java new file mode 100644 index 000000000..bd6da8190 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/announcement/service/AnnouncementService.java @@ -0,0 +1,23 @@ +package site.timecapsulearchive.core.domain.announcement.service; + +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import site.timecapsulearchive.core.domain.announcement.data.dto.AnnouncementDto; +import site.timecapsulearchive.core.domain.announcement.repository.AnnouncementRepository; + +@Service +@RequiredArgsConstructor +public class AnnouncementService { + + private final AnnouncementRepository announcementRepository; + + public List findAll() { + List announcements = announcementRepository.findAll(); + + announcements.sort(Comparator.comparing(AnnouncementDto::createdAt).reversed()); + + return announcements; + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/api/AuthApi.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/api/AuthApi.java index b059bb8d8..4cf3f8e41 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/api/AuthApi.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/api/AuthApi.java @@ -1,6 +1,7 @@ package site.timecapsulearchive.core.domain.auth.api; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -8,8 +9,6 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; -import site.timecapsulearchive.core.domain.auth.data.request.EmailSignInRequest; -import site.timecapsulearchive.core.domain.auth.data.request.EmailSignUpRequest; import site.timecapsulearchive.core.domain.auth.data.request.SignInRequest; import site.timecapsulearchive.core.domain.auth.data.request.SignUpRequest; import site.timecapsulearchive.core.domain.auth.data.request.TemporaryTokenReIssueRequest; @@ -133,6 +132,43 @@ public interface AuthApi { }) ResponseEntity> signInWithSocialProvider(SignInRequest request); + @Operation( + summary = "다른 소셜 프로바이더의 앱으로 인증한 클라이언트 아이디 로그아웃", + description = """ + 다른 소셜 프로바이더의 앱으로 인증한 클라이언트의 아이디로 로그아웃한다. + + 로그인된 사용자만 가능하다. + """, + tags = {"auth"} + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "ok" + ), + @ApiResponse( + responseCode = "400", + description = """ + 요청이 잘못되어 발생하는 오류이다. +
    +
  • 올바르지 않은 요청인 경우 예외가 발생한다.
  • +
  • 인증되지 않은 사용자인 경우 예외가 발생한다.
  • +
+ """, + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "404", + description = "로그아웃을 요청한 멤버를 찾을 수 없는 경우 예외가 발생한다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + ResponseEntity> signOutWithSocialProvider( + Long memberId, + + @Parameter(hidden = true) String accessToken + ); + @Operation( summary = "임시 인증 토큰 재발급", description = "인증되지 않은 사용자가 인증할 수 있는 임시 인증 토큰을 재발급한다.", @@ -255,62 +291,5 @@ ResponseEntity> validVerificationMessage( Long memberId, VerificationNumberValidRequest request ); - - @Operation( - summary = "이메일로 회원가입", - description = """ - 이메일로 회원가입 한다. - - 인증되지 않은 상태이므로 전화 번호 인증을 해야한다. - """, - tags = {"auth"} - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "ok" - ) - }) - ResponseEntity> signUpWithEmail(EmailSignUpRequest request); - - @Operation( - summary = "이메일로 로그인", - description = """ - 이메일로 로그인 한다. - - 완전히 인증된 상태의 유저만 가능하다. - """, - tags = {"auth"} - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "ok" - ), - @ApiResponse( - responseCode = "400", - description = """ - 요청이 잘못되어 발생하는 오류이다. -
    -
  • 올바르지 않은 요청인 경우 예외가 발생한다.
  • -
  • 인증되지 않은 사용자인 경우 예외가 발생한다.
  • -
- """, - content = @Content(schema = @Schema(implementation = ErrorResponse.class)) - ), - @ApiResponse( - responseCode = "401", - description = """ - 이메일 또는 비밀번호가 올바르지 않은 경우 발생하는 오류이다. (일치하지 않는 경우도 포함) - """, - content = @Content(schema = @Schema(implementation = ErrorResponse.class)) - ), - @ApiResponse( - responseCode = "404", - description = "로그인을 요청한 멤버를 찾을 수 없는 경우 예외가 발생한다.", - content = @Content(schema = @Schema(implementation = ErrorResponse.class)) - ) - }) - ResponseEntity> signInWithEmail(EmailSignInRequest request); } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/api/AuthApiController.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/api/AuthApiController.java index ab37c0ac9..a88287550 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/api/AuthApiController.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/api/AuthApiController.java @@ -10,8 +10,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import site.timecapsulearchive.core.domain.auth.data.request.EmailSignInRequest; -import site.timecapsulearchive.core.domain.auth.data.request.EmailSignUpRequest; +import site.timecapsulearchive.core.domain.auth.data.dto.TemporaryTokenDto; +import site.timecapsulearchive.core.domain.auth.data.dto.TokenDto; +import site.timecapsulearchive.core.domain.auth.data.dto.VerificationMessageSendDto; import site.timecapsulearchive.core.domain.auth.data.request.SignInRequest; import site.timecapsulearchive.core.domain.auth.data.request.SignUpRequest; import site.timecapsulearchive.core.domain.auth.data.request.TemporaryTokenReIssueRequest; @@ -22,9 +23,8 @@ import site.timecapsulearchive.core.domain.auth.data.response.TemporaryTokenResponse; import site.timecapsulearchive.core.domain.auth.data.response.TokenResponse; import site.timecapsulearchive.core.domain.auth.data.response.VerificationMessageSendResponse; -import site.timecapsulearchive.core.domain.auth.service.MessageVerificationService; -import site.timecapsulearchive.core.domain.auth.service.TokenManager; -import site.timecapsulearchive.core.domain.member.service.MemberService; +import site.timecapsulearchive.core.domain.auth.service.AuthManager; +import site.timecapsulearchive.core.global.common.argument.AccessToken; import site.timecapsulearchive.core.global.common.response.ApiSpec; import site.timecapsulearchive.core.global.common.response.SuccessCode; @@ -33,22 +33,12 @@ @RequestMapping("/auth") public class AuthApiController implements AuthApi { - private static final String KAKAO_AUTHORIZATION_ENDPOINT = "/auth/login/kakao"; - private static final String GOOGLE_AUTHORIZATION_ENDPOINT = "/auth/login/google"; - - private final TokenManager tokenService; - private final MessageVerificationService messageVerificationService; - private final MemberService memberService; - + private final AuthManager authManager; @GetMapping(value = "/login/url/kakao", produces = {"application/json"}) @Override public ResponseEntity getOAuth2KakaoUrl(final HttpServletRequest request) { - final String baseUrl = request.getRequestURL().toString(); - final String kakaoLoginUrl = baseUrl.replace( - request.getRequestURI(), - request.getContextPath() + KAKAO_AUTHORIZATION_ENDPOINT - ); + final String kakaoLoginUrl = authManager.getOAuth2KakaoUrl(request); return ResponseEntity.ok(OAuth2UriResponse.from(kakaoLoginUrl)); } @@ -56,11 +46,7 @@ public ResponseEntity getOAuth2KakaoUrl(final HttpServletRequ @GetMapping(value = "/login/url/google", produces = {"application/json"}) @Override public ResponseEntity getOAuth2GoogleUrl(final HttpServletRequest request) { - final String baseUrl = request.getRequestURL().toString(); - final String googleLoginUrl = baseUrl.replace( - request.getRequestURI(), - request.getContextPath() + GOOGLE_AUTHORIZATION_ENDPOINT - ); + final String googleLoginUrl = authManager.getOauth2GoogleUrl(request); return ResponseEntity.ok(OAuth2UriResponse.from(googleLoginUrl)); } @@ -86,14 +72,13 @@ public ResponseEntity getTemporaryTokenByGoogle() { public ResponseEntity> reIssueTemporaryToken( @Valid @RequestBody final TemporaryTokenReIssueRequest request ) { - final Long id = memberService.findNotVerifiedMemberIdByAuthIdAndSocialType( - request.authId(), request.socialType() - ); + final TemporaryTokenDto temporaryToken = authManager.reIssueTemporaryToken( + request.authId(), request.socialType()); return ResponseEntity.ok( ApiSpec.success( SuccessCode.SUCCESS, - tokenService.createTemporaryToken(id) + temporaryToken.toResponse() ) ); } @@ -107,10 +92,12 @@ public ResponseEntity> reIssueTemporaryToken( public ResponseEntity> reIssueAccessToken( @Valid @RequestBody final TokenReIssueRequest request ) { + final TokenDto token = authManager.reIssueToken(request.refreshToken()); + return ResponseEntity.ok( ApiSpec.success( SuccessCode.SUCCESS, - tokenService.reIssueToken(request.refreshToken()) + token.toResponse() ) ); } @@ -124,12 +111,12 @@ public ResponseEntity> reIssueAccessToken( public ResponseEntity> signUpWithSocialProvider( @Valid @RequestBody final SignUpRequest request ) { - final Long id = memberService.createMember(request.toDto()); + final TemporaryTokenDto temporaryToken = authManager.signUp(request.toDto()); return ResponseEntity.ok( ApiSpec.success( SuccessCode.SUCCESS, - tokenService.createTemporaryToken(id) + temporaryToken.toResponse() ) ); } @@ -142,17 +129,30 @@ public ResponseEntity> signUpWithSocialProvider( @Override public ResponseEntity> signInWithSocialProvider( @Valid @RequestBody final SignInRequest request) { - final Long memberId = memberService.findVerifiedMemberIdByAuthIdAndSocialType( - request.authId(), request.socialType()); + final TokenDto token = authManager.signIn(request.authId(), request.socialType()); return ResponseEntity.ok( ApiSpec.success( SuccessCode.SUCCESS, - tokenService.createNewToken(memberId) + token.toResponse() ) ); } + @PostMapping( + value = "/sign-out", + produces = {"application/json"} + ) + @Override + public ResponseEntity> signOutWithSocialProvider( + @AuthenticationPrincipal final Long memberId, + @AccessToken final String accessToken + ) { + authManager.signOut(memberId, accessToken); + + return ResponseEntity.ok(ApiSpec.empty(SuccessCode.SUCCESS)); + } + @PostMapping( value = "/verification/send-message", consumes = {"application/json"}, @@ -163,17 +163,14 @@ public ResponseEntity> sendVerification @AuthenticationPrincipal final Long memberId, @Valid @RequestBody final VerificationMessageSendRequest request ) { - final VerificationMessageSendResponse response = messageVerificationService.sendVerificationMessage( - memberId, - request.receiver(), - request.appHashKey() - ); + final VerificationMessageSendDto verificationMessageSendDto = authManager.sendVerificationMessage( + memberId, request.receiver(), request.appHashKey()); return ResponseEntity.accepted() .body( ApiSpec.success( SuccessCode.ACCEPTED, - response + verificationMessageSendDto.toResponse() ) ); } @@ -188,56 +185,13 @@ public ResponseEntity> validVerificationMessage( @AuthenticationPrincipal final Long memberId, @Valid @RequestBody final VerificationNumberValidRequest request ) { - final TokenResponse response = messageVerificationService.validVerificationMessage( - memberId, - request.certificationNumber(), - request.receiver() - ); - - return ResponseEntity.ok( - ApiSpec.success( - SuccessCode.SUCCESS, - response - ) - ); - } - - @PostMapping( - value = "/sign-up/email", - produces = {"application/json"}, - consumes = {"application/json"} - ) - @Override - public ResponseEntity> signUpWithEmail( - @Valid @RequestBody final EmailSignUpRequest request - ) { - final Long id = memberService.createMemberWithEmailAndPassword(request.email(), - request.password()); - - return ResponseEntity.ok( - ApiSpec.success( - SuccessCode.SUCCESS, - tokenService.createTemporaryToken(id) - ) - ); - } - - @PostMapping( - value = "/sign-in/email", - produces = {"application/json"}, - consumes = {"application/json"} - ) - @Override - public ResponseEntity> signInWithEmail( - @Valid @RequestBody final EmailSignInRequest request - ) { - final Long id = memberService.findVerifiedMemberIdByEmailAndPassword(request.email(), - request.password()); + TokenDto token = authManager.validVerificationMessage(memberId, + request.certificationNumber(), request.receiver()); return ResponseEntity.ok( ApiSpec.success( SuccessCode.SUCCESS, - tokenService.createNewToken(id) + token.toResponse() ) ); } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/dto/TemporaryTokenDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/dto/TemporaryTokenDto.java new file mode 100644 index 000000000..ed39a3972 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/dto/TemporaryTokenDto.java @@ -0,0 +1,23 @@ +package site.timecapsulearchive.core.domain.auth.data.dto; + +import site.timecapsulearchive.core.domain.auth.data.response.TemporaryTokenResponse; + +public record TemporaryTokenDto( + String temporaryAccessToken, + long expiresIn +) { + + public static TemporaryTokenDto create( + final String temporaryAccessToken, + final long expiresIn + ) { + return new TemporaryTokenDto( + temporaryAccessToken, + expiresIn + ); + } + + public TemporaryTokenResponse toResponse() { + return new TemporaryTokenResponse(temporaryAccessToken, expiresIn); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/dto/TokenDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/dto/TokenDto.java new file mode 100644 index 000000000..a93a037f3 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/dto/TokenDto.java @@ -0,0 +1,29 @@ +package site.timecapsulearchive.core.domain.auth.data.dto; + +import site.timecapsulearchive.core.domain.auth.data.response.TokenResponse; + +public record TokenDto( + String accessToken, + String refreshToken, + long expiresIn, + long refreshTokenExpiresIn +) { + + public static TokenDto create( + String accessToken, + String refreshToken, + long expiresIn, + long refreshTokenExpiresIn + ) { + return new TokenDto( + accessToken, + refreshToken, + expiresIn, + refreshTokenExpiresIn + ); + } + + public TokenResponse toResponse() { + return new TokenResponse(accessToken, refreshToken, expiresIn, refreshTokenExpiresIn); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/dto/VerificationMessageSendDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/dto/VerificationMessageSendDto.java new file mode 100644 index 000000000..2d6c33685 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/dto/VerificationMessageSendDto.java @@ -0,0 +1,18 @@ +package site.timecapsulearchive.core.domain.auth.data.dto; + +import site.timecapsulearchive.core.domain.auth.data.response.VerificationMessageSendResponse; + +public record VerificationMessageSendDto( + Integer status, + String message +) { + + public static VerificationMessageSendDto success(final Integer status, + final String message) { + return new VerificationMessageSendDto(status, message); + } + + public VerificationMessageSendResponse toResponse() { + return new VerificationMessageSendResponse(status, message); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/request/EmailSignInRequest.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/request/EmailSignInRequest.java deleted file mode 100644 index 2566946f7..000000000 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/request/EmailSignInRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package site.timecapsulearchive.core.domain.auth.data.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; - -@Schema(description = "이메일 로그인 요청") -public record EmailSignInRequest( - - @Schema(description = "이메일") - @NotBlank - @Email - String email, - - @Schema(description = "비밀번호") - @NotBlank - String password -) { - -} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/request/EmailSignUpRequest.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/request/EmailSignUpRequest.java deleted file mode 100644 index 9d220b3ba..000000000 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/request/EmailSignUpRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package site.timecapsulearchive.core.domain.auth.data.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; - -@Schema(description = "이메일 회원가입 요청") -public record EmailSignUpRequest( - - @Schema(description = "사용할 이메일") - @NotBlank - @Email - String email, - - @Schema(description = "사용할 비밀번호") - @NotBlank - String password -) { - -} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/response/TemporaryTokenResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/response/TemporaryTokenResponse.java index 382df5fcd..0ec76172f 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/response/TemporaryTokenResponse.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/response/TemporaryTokenResponse.java @@ -12,13 +12,4 @@ public record TemporaryTokenResponse( long expiresIn ) { - public static TemporaryTokenResponse create( - String temporaryAccessToken, - long expiresIn - ) { - return new TemporaryTokenResponse( - temporaryAccessToken, - expiresIn - ); - } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/response/TokenResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/response/TokenResponse.java index 6a7f2090f..0b8a8bcaa 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/response/TokenResponse.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/data/response/TokenResponse.java @@ -18,17 +18,4 @@ public record TokenResponse( long refreshTokenExpiresIn ) { - public static TokenResponse create( - String accessToken, - String refreshToken, - long expiresIn, - long refreshTokenExpiresIn - ) { - return new TokenResponse( - accessToken, - refreshToken, - expiresIn, - refreshTokenExpiresIn - ); - } } \ No newline at end of file diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/BlackListCacheRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/BlackListCacheRepository.java new file mode 100644 index 000000000..63705a9b5 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/BlackListCacheRepository.java @@ -0,0 +1,25 @@ +package site.timecapsulearchive.core.domain.auth.repository; + +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BlackListCacheRepository { + + private static final String PREFIX = "blackList_memberId_accessToken:"; + + private final StringRedisTemplate redisTemplate; + + public void save(final Long memberId, final String accessToken, long leftTime) { + redisTemplate.opsForValue() + .set(PREFIX + memberId, accessToken, leftTime, TimeUnit.MILLISECONDS); + } + + public String findBlackListTokenByMemberId(Long memberId) { + return redisTemplate.opsForValue() + .get(PREFIX + memberId); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/MemberInfoCacheRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/MemberInfoCacheRepository.java deleted file mode 100644 index 48e0731cd..000000000 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/MemberInfoCacheRepository.java +++ /dev/null @@ -1,59 +0,0 @@ -package site.timecapsulearchive.core.domain.auth.repository; - -import java.util.Optional; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Repository; -import site.timecapsulearchive.core.domain.auth.data.dto.MemberInfo; - -@Repository -@RequiredArgsConstructor -public class MemberInfoCacheRepository { - - private static final int MAXIMUM_REFRESH_TOKEN_EXPIRES_IN_DAY = 30; - private static final String PREFIX = "memberInfo:"; - - private final RedisTemplate redisTemplate; - - /** - * 키와 {@code memberInfo}를 받아서 캐시에 저장 - * - * @param key 캐시에 저장할 키, UUID string - * @param memberInfo 저장할 사용자 정보 - */ - public void save( - final String key, - final MemberInfo memberInfo - ) { - redisTemplate.opsForValue().set( - PREFIX + key, - memberInfo, - MAXIMUM_REFRESH_TOKEN_EXPIRES_IN_DAY, - TimeUnit.DAYS - ); - } - - /** - * 사용자 정보 조회 - * - * @param infoKey 캐시에 저장된 키, UUID string - * @return {@code Optional} 사용자 정보 - */ - public Optional findMemberInfoByInfoKey(final String infoKey) { - return Optional.ofNullable(redisTemplate.opsForValue().get(PREFIX + infoKey)); - } - - /** - * 삭제 로직 대신 키의 이름을 변경, O(1) 소요 - * - * @param oldKey 이전 키 - * @param newKey 새로운 키 - */ - public void rename( - final String oldKey, - final String newKey - ) { - redisTemplate.rename(PREFIX + oldKey, PREFIX + newKey); - } -} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/MessageAuthenticationCacheRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/MessageAuthenticationCacheRepository.java index 447bd503b..de6da211c 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/MessageAuthenticationCacheRepository.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/MessageAuthenticationCacheRepository.java @@ -30,4 +30,8 @@ public Optional findMessageAuthenticationCodeByMemberId(final Long membe return Optional.of(code); } + + public void delete(final Long memberId, final byte[] encrypt) { + redisTemplate.opsForHash().delete(PREFIX + memberId, encrypt); + } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/RefreshTokenCacheRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/RefreshTokenCacheRepository.java new file mode 100644 index 000000000..2e0d17929 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/repository/RefreshTokenCacheRepository.java @@ -0,0 +1,38 @@ +package site.timecapsulearchive.core.domain.auth.repository; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenCacheRepository { + + private static final int MAXIMUM_REFRESH_TOKEN_EXPIRES_IN_DAY = 30; + private static final String PREFIX = "reIssue_memberId_refreshToken:"; + + private final StringRedisTemplate redisTemplate; + + public void save( + final Long memberId, + final String refreshToken + ) { + redisTemplate.opsForValue().set( + PREFIX + memberId, + refreshToken, + MAXIMUM_REFRESH_TOKEN_EXPIRES_IN_DAY, + TimeUnit.DAYS + ); + } + + public Optional findRefreshTokenByMemberId(final Long memberId) { + return Optional.ofNullable(redisTemplate.opsForValue().get(PREFIX + memberId)); + } + + public void remove(Long memberId) { + redisTemplate.opsForValue() + .getAndDelete(PREFIX + memberId); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/AuthManager.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/AuthManager.java new file mode 100644 index 000000000..5f389aa8a --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/AuthManager.java @@ -0,0 +1,96 @@ +package site.timecapsulearchive.core.domain.auth.service; + +import jakarta.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import site.timecapsulearchive.core.domain.auth.data.dto.TemporaryTokenDto; +import site.timecapsulearchive.core.domain.auth.data.dto.TokenDto; +import site.timecapsulearchive.core.domain.auth.data.dto.VerificationMessageSendDto; +import site.timecapsulearchive.core.domain.member.data.dto.SignUpRequestDto; +import site.timecapsulearchive.core.domain.member.entity.SocialType; +import site.timecapsulearchive.core.domain.member.service.MemberService; + +@Component +@RequiredArgsConstructor +public class AuthManager { + + private static final String KAKAO_AUTHORIZATION_ENDPOINT = "/auth/login/kakao"; + private static final String GOOGLE_AUTHORIZATION_ENDPOINT = "/auth/login/google"; + + private final TokenManager tokenManager; + private final MemberService memberService; + private final MessageVerificationService messageVerificationService; + + public String getOAuth2KakaoUrl(final HttpServletRequest request) { + final String baseUrl = request.getRequestURL().toString(); + + return baseUrl.replace( + request.getRequestURI(), + request.getContextPath() + KAKAO_AUTHORIZATION_ENDPOINT + ); + } + + public String getOauth2GoogleUrl(final HttpServletRequest request) { + final String baseUrl = request.getRequestURL().toString(); + + return baseUrl.replace( + request.getRequestURI(), + request.getContextPath() + GOOGLE_AUTHORIZATION_ENDPOINT + ); + } + + public TemporaryTokenDto reIssueTemporaryToken(final String authId, + final SocialType socialType) { + final Long notVerifiedMemberId = memberService.findNotVerifiedMemberIdBy(authId, + socialType); + + return tokenManager.createTemporaryToken(notVerifiedMemberId); + } + + public TokenDto reIssueToken(final String refreshToken) { + return tokenManager.reIssueToken(refreshToken); + } + + public TemporaryTokenDto signUp(final SignUpRequestDto dto) { + final Long createdMemberId = memberService.createMember(dto); + + return tokenManager.createTemporaryToken(createdMemberId); + } + + public TokenDto signIn(final String authId, final SocialType socialType) { + final Long verifiedSocialMemberId = memberService.findVerifiedSocialMemberIdBy(authId, + socialType); + + return tokenManager.createNewToken(verifiedSocialMemberId); + } + + public VerificationMessageSendDto sendVerificationMessage( + final Long memberId, + final String receiver, + final String appHashKey + ) { + return messageVerificationService.sendVerificationMessage(memberId, receiver, appHashKey); + } + + public TokenDto validVerificationMessage( + final Long memberId, + final String certificationNumber, + final String receiver + ) { + final byte[] plain = receiver.getBytes(StandardCharsets.UTF_8); + + messageVerificationService.validVerificationMessage(memberId, + certificationNumber, plain); + + Long verifiedMemberId = memberService.updateVerifiedMember(memberId, plain); + + return tokenManager.createNewToken(verifiedMemberId); + } + + public void signOut(Long memberId, String accessToken) { + tokenManager.removeRefreshToken(memberId); + + tokenManager.addBlackList(memberId, accessToken); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/MessageVerificationService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/MessageVerificationService.java index 54e7a1afa..f52f94110 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/MessageVerificationService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/MessageVerificationService.java @@ -6,17 +6,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import site.timecapsulearchive.core.domain.auth.data.response.TokenResponse; -import site.timecapsulearchive.core.domain.auth.data.response.VerificationMessageSendResponse; +import site.timecapsulearchive.core.domain.auth.data.dto.VerificationMessageSendDto; import site.timecapsulearchive.core.domain.auth.exception.CertificationNumberNotFoundException; import site.timecapsulearchive.core.domain.auth.exception.CertificationNumberNotMatchException; import site.timecapsulearchive.core.domain.auth.repository.MessageAuthenticationCacheRepository; -import site.timecapsulearchive.core.domain.member.entity.Member; -import site.timecapsulearchive.core.domain.member.entity.MemberTemporary; -import site.timecapsulearchive.core.domain.member.exception.MemberNotFoundException; -import site.timecapsulearchive.core.domain.member.repository.MemberRepository; -import site.timecapsulearchive.core.domain.member.repository.MemberTemporaryRepository; -import site.timecapsulearchive.core.global.security.encryption.AESEncryptionManager; import site.timecapsulearchive.core.global.security.encryption.HashEncryptionManager; import site.timecapsulearchive.core.infra.sms.data.response.SmsApiResponse; import site.timecapsulearchive.core.infra.sms.manager.SmsApiManager; @@ -33,11 +26,6 @@ public class MessageVerificationService { private final MessageAuthenticationCacheRepository messageAuthenticationCacheRepository; private final SmsApiManager smsApiManager; - private final MemberRepository memberRepository; - private final MemberTemporaryRepository memberTemporaryRepository; - private final TokenManager tokenManager; - - private final AESEncryptionManager aesEncryptionManager; private final HashEncryptionManager hashEncryptionManager; /** @@ -47,7 +35,7 @@ public class MessageVerificationService { * @param receiver 수신자 핸드폰 번호 * @param appHashKey 앱의 해시 키(메시지 자동 파싱) */ - public VerificationMessageSendResponse sendVerificationMessage( + public VerificationMessageSendDto sendVerificationMessage( final Long memberId, final String receiver, final String appHashKey @@ -61,7 +49,7 @@ public VerificationMessageSendResponse sendVerificationMessage( messageAuthenticationCacheRepository.save(memberId, encrypt, code); - return VerificationMessageSendResponse.success(apiResponse.resultCode(), + return VerificationMessageSendDto.success(apiResponse.resultCode(), apiResponse.message()); } @@ -79,12 +67,11 @@ private String generateMessage(final String code, final String appHashKey) { + "타인 노출 금지"; } - public TokenResponse validVerificationMessage( + public void validVerificationMessage( final Long memberId, final String certificationNumber, - final String receiver + final byte[] plain ) { - final byte[] plain = receiver.getBytes(StandardCharsets.UTF_8); byte[] encrypt = hashEncryptionManager.encrypt(plain); final String findCertificationNumber = messageAuthenticationCacheRepository @@ -95,35 +82,11 @@ public TokenResponse validVerificationMessage( throw new CertificationNumberNotMatchException(); } - final Long verifiedMemberId = updateToVerifiedMember(memberId, plain); - - return tokenManager.createNewToken(verifiedMemberId); + messageAuthenticationCacheRepository.delete(memberId, encrypt); } private boolean isNotMatch(final String certificationNumber, final String findCertificationNumber) { return !certificationNumber.equals(findCertificationNumber); } - - private Long updateToVerifiedMember(final Long memberId, final byte[] plain) { - final MemberTemporary memberTemporary = memberTemporaryRepository.findById(memberId) - .orElseThrow(MemberNotFoundException::new); - - memberTemporaryRepository.delete(memberTemporary); - - boolean isDuplicateTag = memberRepository.checkTagDuplication(memberTemporary.getTag()); - if (isDuplicateTag) { - log.warn("member tag duplicate - email:{}, tag:{}", memberTemporary.getEmail(), - memberTemporary.getTag()); - memberTemporary.updateTagLowerCaseSocialType(); - log.warn("member tag update - tag: {}", memberTemporary.getTag()); - } - - final Member verifiedMember = memberTemporary.toMember(hashEncryptionManager.encrypt(plain), - aesEncryptionManager.encryptWithPrefixIV(plain)); - - memberRepository.save(verifiedMember); - - return verifiedMember.getId(); - } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/TokenManager.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/TokenManager.java index 1e3240f3d..6b786e984 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/TokenManager.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/auth/service/TokenManager.java @@ -1,14 +1,14 @@ package site.timecapsulearchive.core.domain.auth.service; import java.util.List; -import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import site.timecapsulearchive.core.domain.auth.data.dto.MemberInfo; -import site.timecapsulearchive.core.domain.auth.data.response.TemporaryTokenResponse; -import site.timecapsulearchive.core.domain.auth.data.response.TokenResponse; +import site.timecapsulearchive.core.domain.auth.data.dto.TemporaryTokenDto; +import site.timecapsulearchive.core.domain.auth.data.dto.TokenDto; import site.timecapsulearchive.core.domain.auth.exception.AlreadyReIssuedTokenException; -import site.timecapsulearchive.core.domain.auth.repository.MemberInfoCacheRepository; +import site.timecapsulearchive.core.domain.auth.repository.BlackListCacheRepository; +import site.timecapsulearchive.core.domain.auth.repository.RefreshTokenCacheRepository; +import site.timecapsulearchive.core.global.error.exception.InvalidTokenException; import site.timecapsulearchive.core.global.security.jwt.JwtFactory; import site.timecapsulearchive.core.global.security.jwt.TokenParseResult; import site.timecapsulearchive.core.global.security.jwt.TokenType; @@ -18,7 +18,8 @@ public class TokenManager { private final JwtFactory jwtFactory; - private final MemberInfoCacheRepository memberInfoCacheRepository; + private final RefreshTokenCacheRepository refreshTokenCacheRepository; + private final BlackListCacheRepository blackListCacheRepository; /** * 새로운 액세스 토큰과 리프레시 토큰을 발급한다. - 액세스 토큰(멤버 아이디) - 리프레시 토큰(데이터베이스에 저장된 사용자 식별자) @@ -26,17 +27,17 @@ public class TokenManager { * @param memberId 액세스 토큰 클레임 값에 넣을 사용자 아이디 * @return 토큰 응답(액세스 토큰, 리프레시 토큰, 액세스 토큰 만료일, 리프레시 토큰 만료일) */ - public TokenResponse createNewToken(final Long memberId) { - final String key = String.valueOf(UUID.randomUUID()); - memberInfoCacheRepository.save(key, MemberInfo.from(memberId)); + public TokenDto createNewToken(final Long memberId) { + String refreshToken = jwtFactory.createRefreshToken(memberId); + refreshTokenCacheRepository.save(memberId, refreshToken); - return createTokenResponse(memberId, key); + return createToken(memberId, refreshToken); } - private TokenResponse createTokenResponse(final Long memberId, final String key) { - return TokenResponse.create( + private TokenDto createToken(final Long memberId, final String refreshToken) { + return TokenDto.create( jwtFactory.createAccessToken(memberId), - jwtFactory.createRefreshToken(key), + refreshToken, jwtFactory.getExpiresIn(), jwtFactory.getRefreshTokenExpiresIn() ); @@ -48,8 +49,8 @@ private TokenResponse createTokenResponse(final Long memberId, final String key) * @param memberId 임시 인증 토큰 클레임 값에 사용할 사용자 아이디 * @return 임시 인증 토큰 응답(임시 인증 토큰, 임시 인증 토큰 만료일) */ - public TemporaryTokenResponse createTemporaryToken(final Long memberId) { - return TemporaryTokenResponse.create( + public TemporaryTokenDto createTemporaryToken(final Long memberId) { + return TemporaryTokenDto.create( jwtFactory.createTemporaryAccessToken(memberId), jwtFactory.getTemporaryTokenExpiresIn() ); @@ -61,18 +62,34 @@ public TemporaryTokenResponse createTemporaryToken(final Long memberId) { * @param refreshToken 리프레시 토큰 * @return 토큰 응답(액세스 토큰, 리프레시 토큰, 액세스 토큰 만료일, 리프레시 토큰 만료일) */ - public TokenResponse reIssueToken(final String refreshToken) { + public TokenDto reIssueToken(final String refreshToken) { final TokenParseResult tokenParseResult = jwtFactory.parse( refreshToken, List.of(TokenType.REFRESH) ); - final MemberInfo memberInfo = memberInfoCacheRepository.findMemberInfoByInfoKey( - tokenParseResult.subject()) + + Long memberId = Long.valueOf(tokenParseResult.subject()); + final String foundRefreshToken = refreshTokenCacheRepository.findRefreshTokenByMemberId( + memberId) .orElseThrow(AlreadyReIssuedTokenException::new); - final String newKey = String.valueOf(UUID.randomUUID()); - memberInfoCacheRepository.rename(tokenParseResult.subject(), newKey); + if (!refreshToken.equals(foundRefreshToken)) { + throw new InvalidTokenException(); + } + + String newRefreshToken = jwtFactory.createRefreshToken(memberId); + refreshTokenCacheRepository.save(memberId, newRefreshToken); + + return createToken(memberId, refreshToken); + } + + public void removeRefreshToken(final Long memberId) { + refreshTokenCacheRepository.remove(memberId); + } + + public void addBlackList(final Long memberId, final String accessToken) { + long leftTime = jwtFactory.getLeftTime(accessToken); - return createTokenResponse(memberInfo.memberId(), newKey); + blackListCacheRepository.save(memberId, accessToken, leftTime); } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/entity/Capsule.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/entity/Capsule.java index bb55358ec..18548adf6 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/entity/Capsule.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/entity/Capsule.java @@ -1,6 +1,5 @@ package site.timecapsulearchive.core.domain.capsule.entity; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -21,8 +20,11 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; import org.locationtech.jts.geom.Point; import site.timecapsulearchive.core.domain.capsule.exception.GroupCapsuleOpenNotFoundException; +import site.timecapsulearchive.core.domain.capsule.exception.NoCapsuleAuthorityException; import site.timecapsulearchive.core.domain.capsuleskin.entity.CapsuleSkin; import site.timecapsulearchive.core.domain.group.entity.Group; import site.timecapsulearchive.core.domain.member.entity.Member; @@ -30,47 +32,40 @@ import site.timecapsulearchive.core.global.entity.BaseEntity; @Entity +@Table(name = "capsule") @Getter +@SQLDelete(sql = "UPDATE `capsule` SET deleted_at = now() WHERE capsule_id = ?") +@Where(clause = "deleted_at is null") @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "CAPSULE") public class Capsule extends BaseEntity { + @OneToMany(mappedBy = "capsule") + private final List images = new ArrayList<>(); + @OneToMany(mappedBy = "capsule") + private final List