diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 9a2d24f4..a5e5b65d 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,11 +1,11 @@
-
+## 📋 Checklist
-## 🧩 이슈 번호
-
-- #이슈번호
+- [ ] 🔀 PR 제목의 형식을 잘 작성했나요? (e.g. `feat: 유저 조회 기능 구현`)
+- [ ] 🏷️ 라벨, 프로젝트, 마일스톤은 등록했나요?
+- [ ] 🧹 코드 스멜은 해결했나요?
-## ✅ 작업 사항
+## 🧩 이슈 번호
-- [ ] 작업 내용
+- close #이슈번호
## 👩💻 공유 포인트 및 논의 사항
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3109497d..17e57edd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,46 +1,52 @@
name: ci
on:
- pull_request:
- branches: [ "main", "develop" ]
+ pull_request:
+ branches: [ "main", "develop" ]
jobs:
- build:
- name: build
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v3
- with:
- fetch-depth: 0
-
- - name: JDK 17 셋업
- uses: actions/setup-java@v3
- with:
- java-version: '17'
- distribution: 'corretto'
-
- - name: Gradle 캐싱
- uses: actions/cache@v3
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
-
- - name: Gradle Grant 권한 부여
- run: chmod +x gradlew
-
- - name: SonarCloud 캐싱
- uses: actions/cache@v3
- with:
- path: ~/.sonar/cache
- key: ${{ runner.os }}-sonar
- restore-keys: ${{ runner.os }}-sonar
-
- - name: 빌드 및 분석
- run: ./gradlew build jacocoTestReport sonar --info --stacktrace
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SONAR_TOKEN: ${{ secrets.SONAR_CLOUD_TOKEN }}
+ build:
+ name: build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+ submodules: true
+ token: ${{ secrets.MOABAM_SUBMODULE_KEY }}
+
+ - name: JDK 17 셋업
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'corretto'
+
+ - name: Gradle 캐싱
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Gradle Grant 권한 부여
+ run: chmod +x gradlew
+
+ - name: 테스트용 MySQL 도커 컨테이너 실행
+ run: |
+ sudo docker run -d -p 3305:3306 --env MYSQL_DATABASE=moabam --env MYSQL_ROOT_PASSWORD=1234 mysql:8.0.33
+
+ - name: SonarCloud 캐싱
+ uses: actions/cache@v3
+ with:
+ path: ~/.sonar/cache
+ key: ${{ runner.os }}-sonar
+ restore-keys: ${{ runner.os }}-sonar
+
+ - name: 빌드 및 분석
+ run: ./gradlew build jacocoTestReport sonar --info --stacktrace
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SONAR_TOKEN: ${{ secrets.SONAR_CLOUD_TOKEN }}
diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml
new file mode 100644
index 00000000..cd5449bf
--- /dev/null
+++ b/.github/workflows/develop-cd.yml
@@ -0,0 +1,175 @@
+name: develop-CD
+
+on:
+ push:
+ branches: [ "develop" ]
+
+permissions:
+ contents: write
+
+jobs:
+ move-files:
+ name: move-files
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: true
+ token: ${{ secrets.MOABAM_SUBMODULE_KEY }}
+
+ - name: Github Actions IP 획득
+ id: ip
+ uses: haythem/public-ip@v1.3
+
+ - name: AWS Credentials 설정
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ secrets.AWS_REGION }}
+
+ - name: Github Actions IP 보안그룹 추가
+ run: |
+ aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_DEV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
+
+ - name: 디렉토리 생성
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.EC2_DEV_INSTANCE_HOST }}
+ port: 22
+ username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }}
+ key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }}
+ script: |
+ mkdir -p /home/ubuntu/moabam/
+
+ - name: Docker env 파일 생성
+ run:
+ cp src/main/resources/config/dev.env ./infra/.env
+
+ - name: 서버로 전송 기본 파일들 전송
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.EC2_DEV_INSTANCE_HOST }}
+ port: 22
+ username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }}
+ key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }}
+ source: "infra/mysql/*, infra/nginx/*, infra/scripts/*.sh, !infra/scripts/deploy-prod.sh, infra/docker-compose-dev.yml"
+ target: "/home/ubuntu/moabam"
+
+ - name: 파일 세팅
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.EC2_DEV_INSTANCE_HOST }}
+ port: 22
+ username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }}
+ key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }}
+ script: |
+ cd /home/ubuntu/moabam/infra
+ mv docker-compose-dev.yml docker-compose.yml
+ chmod +x ./scripts/deploy-dev.sh
+ chmod +x ./scripts/init-letsencrypt.sh
+ chmod +x ./scripts/init-nginx-converter.sh
+ chmod +x ./mysql/initdb.d/init.sql
+ chmod +x ./mysql/initdb.d/item-data.sql
+
+ - name: Github Actions IP 보안그룹에서 삭제
+ if: always()
+ run: |
+ aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_DEV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
+
+ deploy:
+ name: deploy
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: true
+ token: ${{ secrets.MOABAM_SUBMODULE_KEY }}
+
+ - name: JDK 17 셋업
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'corretto'
+
+ - name: Gradle 캐싱
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Gradle Grant 권한 부여
+ run: chmod +x gradlew
+
+ - name: 테스트용 MySQL 도커 컨테이너 실행
+ run: |
+ sudo docker run -d -p 3305:3306 --env MYSQL_DATABASE=moabam --env MYSQL_ROOT_PASSWORD=1234 mysql:8.0.33
+
+ - name: Gradle 빌드
+ uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0
+ with:
+ arguments: build
+
+ - name: 멀티플랫폼 위한 Docker Buildx 설정
+ uses: docker/setup-buildx-action@v2
+
+ - name: Docker Hub 로그인
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ password: ${{ secrets.DOCKER_HUB_TOKEN }}
+
+ - name: Docker Hub 빌드하고 푸시
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./infra/Dockerfile
+ push: true
+ tags: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ secrets.DOCKER_HUB_DEV_TAG }}
+ build-args: |
+ "SPRING_ACTIVE_PROFILES=dev"
+ platforms: |
+ linux/amd64
+ linux/arm64
+
+ - name: Github Actions IP 획득
+ id: ip
+ uses: haythem/public-ip@v1.3
+
+ - name: AWS Credentials 설정
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ secrets.AWS_REGION }}
+
+ - name: Github Actions IP 보안그룹 추가
+ run: |
+ aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_DEV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
+
+ - name: EC2 서버에 배포
+ uses: appleboy/ssh-action@master
+ id: deploy-dev
+ if: contains(github.ref, 'dev')
+ with:
+ host: ${{ secrets.EC2_DEV_INSTANCE_HOST }}
+ port: 22
+ username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }}
+ key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }}
+ source: "./infra/docker-compose-dev.yml"
+ script: |
+ cd /home/ubuntu/moabam/infra
+ echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin
+ ./scripts/deploy-dev.sh
+ docker rm `docker ps -a -q`
+ docker rmi $(docker images -aq)
+ echo "### 배포 완료 ###"
+
+ - name: Github Actions IP 보안그룹에서 삭제
+ if: always()
+ run: |
+ aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_DEV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml
new file mode 100644
index 00000000..d00dca7c
--- /dev/null
+++ b/.github/workflows/prod-cd.yml
@@ -0,0 +1,173 @@
+name: prod-CD
+
+on:
+ push:
+ branches: [ "main" ]
+
+permissions:
+ contents: write
+
+jobs:
+ move-files:
+ name: move-files
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: true
+ token: ${{ secrets.MOABAM_SUBMODULE_KEY }}
+
+ - name: Github Actions IP 획득
+ id: ip
+ uses: haythem/public-ip@v1.3
+
+ - name: AWS Credentials 설정
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ secrets.AWS_REGION }}
+
+ - name: Github Actions IP 보안그룹 추가
+ run: |
+ aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_PROD_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
+
+ - name: 디렉토리 생성
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.EC2_PROD_INSTANCE_HOST }}
+ port: 22
+ username: ${{ secrets.EC2_PROD_INSTANCE_USERNAME }}
+ key: ${{ secrets.EC2_PROD_INSTANCE_PRIVATE_KEY }}
+ script: |
+ mkdir -p /home/ubuntu/moabam/
+
+ - name: Docker env 파일 생성
+ run:
+ cp src/main/resources/config/prod.env ./infra/.env
+
+ - name: 서버로 전송 기본 파일들 전송
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.EC2_PROD_INSTANCE_HOST }}
+ port: 22
+ username: ${{ secrets.EC2_PROD_INSTANCE_USERNAME }}
+ key: ${{ secrets.EC2_PROD_INSTANCE_PRIVATE_KEY }}
+ source: "infra/mysql/*, infra/nginx/*, infra/scripts/*.sh, !infra/scripts/deploy-dev.sh, infra/docker-compose-prod.yml"
+ target: "/home/ubuntu/moabam"
+
+ - name: 파일 세팅
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.EC2_PROD_INSTANCE_HOST }}
+ port: 22
+ username: ${{ secrets.EC2_PROD_INSTANCE_USERNAME }}
+ key: ${{ secrets.EC2_PROD_INSTANCE_PRIVATE_KEY }}
+ script: |
+ cd /home/ubuntu/moabam/infra
+ mv docker-compose-prod.yml docker-compose.yml
+ chmod +x ./scripts/deploy-prod.sh
+ chmod +x ./scripts/init-letsencrypt.sh
+ chmod +x ./scripts/init-nginx-converter.sh
+
+ - name: Github Actions IP 보안그룹에서 삭제
+ if: always()
+ run: |
+ aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_PROD_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
+
+ deploy:
+ name: deploy
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: true
+ token: ${{ secrets.MOABAM_SUBMODULE_KEY }}
+
+ - name: JDK 17 셋업
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'corretto'
+
+ - name: Gradle 캐싱
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Gradle Grant 권한 부여
+ run: chmod +x gradlew
+
+ - name: 테스트용 MySQL 도커 컨테이너 실행
+ run: |
+ sudo docker run -d -p 3305:3306 --env MYSQL_DATABASE=moabam --env MYSQL_ROOT_PASSWORD=1234 mysql:8.0.33
+
+ - name: Gradle 빌드
+ uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0
+ with:
+ arguments: build
+
+ - name: 멀티플랫폼 위한 Docker Buildx 설정
+ uses: docker/setup-buildx-action@v2
+
+ - name: Docker Hub 로그인
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ password: ${{ secrets.DOCKER_HUB_TOKEN }}
+
+ - name: Docker Hub 빌드하고 푸시
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./infra/Dockerfile
+ push: true
+ tags: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ secrets.DOCKER_HUB_DEV_TAG }}
+ build-args: |
+ "SPRING_ACTIVE_PROFILES=prod"
+ platforms: |
+ linux/amd64
+ linux/arm64
+
+ - name: Github Actions IP 획득
+ id: ip
+ uses: haythem/public-ip@v1.3
+
+ - name: AWS Credentials 설정
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ secrets.AWS_REGION }}
+
+ - name: Github Actions IP 보안그룹 추가
+ run: |
+ aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_PROD_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
+
+ - name: EC2 서버에 배포
+ uses: appleboy/ssh-action@master
+ id: deploy-prod
+ if: contains(github.ref, 'main')
+ with:
+ host: ${{ secrets.EC2_PROD_INSTANCE_HOST }}
+ port: 22
+ username: ${{ secrets.EC2_PROD_INSTANCE_USERNAME }}
+ key: ${{ secrets.EC2_PROD_INSTANCE_PRIVATE_KEY }}
+ source: "./infra/docker-compose-prod.yml"
+ script: |
+ cd /home/ubuntu/moabam/infra
+ echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin
+ ./scripts/deploy-prod.sh
+ docker rm `docker ps -a -q`
+ docker rmi $(docker images -aq)
+ echo "### 배포 완료 ###"
+
+ - name: Github Actions IP 보안그룹에서 삭제
+ if: always()
+ run: |
+ aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_PROD_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32
diff --git a/.gitignore b/.gitignore
index df8fb3c3..1bd49ffd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -121,3 +121,6 @@ gradle-app.setting
logs/
application-*.yml
src/main/resources/config
+!application-test.yml
+src/main/generated
+dump.rdb
diff --git a/build.gradle b/build.gradle
index 1fc75afe..c7f8c37e 100644
--- a/build.gradle
+++ b/build.gradle
@@ -5,6 +5,7 @@ plugins {
id 'org.sonarqube' version '4.4.1.3373'
id 'jacoco'
id 'checkstyle'
+ id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
group = 'com.moabam'
@@ -14,7 +15,21 @@ java {
sourceCompatibility = '17'
}
+ext {
+ snippetsDir = file('build/generated-snippets')
+}
+
+def querydslSrcDir = 'src/main/generated'
+clean {
+ delete file(querydslSrcDir)
+}
+tasks.withType(JavaCompile) {
+ options.generatedSourceOutputDirectory = file(querydslSrcDir)
+}
+
configurations {
+ asciidoctorExtensions
+
compileOnly {
extendsFrom annotationProcessor
}
@@ -43,6 +58,7 @@ dependencies {
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'com.squareup.okhttp3:mockwebserver:4.11.0'
// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
@@ -52,6 +68,51 @@ dependencies {
// H2
implementation 'com.h2database:h2'
+
+ // Configuration Binding
+ annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
+
+ // Apache Commons Lang 3
+ implementation 'org.apache.commons:commons-lang3:3.13.0'
+
+ // Redis
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+
+ // Embedded-Redis
+ implementation group: 'it.ozimov', name: 'embedded-redis', version: '0.7.2'
+
+ // Firebase Admin
+ implementation 'com.google.firebase:firebase-admin:9.2.0'
+
+ // JWT
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+
+ // JSON parser
+ implementation 'org.json:json:20230618'
+
+ // Asciidoctor
+ asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
+
+ // RestDocs
+ testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
+
+ // S3
+ implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.0.2")
+ implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
+
+ // Webflux
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
+
+ // Slack Webhook
+ implementation 'net.gpedro.integrations.slack:slack-webhook:1.4.0'
+
+ // Logback Slack Appender
+ implementation 'com.github.maricn:logback-slack-appender:1.6.1'
+
+ // Swagger
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
}
tasks.named('test') {
@@ -73,17 +134,21 @@ jacocoTestReport {
afterEvaluate {
classDirectories.setFrom(
- files(classDirectories.files.collect {
- fileTree(dir: it, excludes: [
- "**/*Application*",
- "**/*Config*",
- "**/*Request*",
- "**/*Response*",
- "**/*Exception*",
- "**/*Mapper*",
- "**/*ErrorMessage*",
- ] + Qdomains)
- })
+ files(classDirectories.files.collect {
+ fileTree(dir: it, excludes: [
+ "**/*Application*",
+ "**/*Config*",
+ "**/*Request*",
+ "**/*Response*",
+ "**/*Exception*",
+ "**/*Mapper*",
+ "**/*ErrorMessage*",
+ "**/*DynamicQuery*",
+ "**/*BaseTimeEntity*",
+ "**/*HealthCheckController*",
+ "**/*S3Manager*",
+ ] + Qdomains)
+ })
)
}
}
@@ -112,8 +177,37 @@ sonar {
property "sonar.host.url", "https://sonarcloud.io"
property 'sonar.coverage.jacoco.xmlReportPaths', 'build/reports/jacoco/test/jacocoTestReport.xml'
property 'sonar.coverage.exclusions', '**/test/**, **/Q*.java, **/*Doc*.java, **/resources/** ' +
- ',**/*Application*.java , **/*Config*.java, **/*Request*.java, **/*Response*.java ,**/*Exception*.java ' +
- ',**/*ErrorMessage*.java, **/*Mapper*.java'
+ ',**/*Application*.java , **/*Config*.java, **/*Request*.java, **/*Response*.java ,**/*Exception*.java ' +
+ ',**/*ErrorMessage*.java, **/*Mapper*.java, **/*DynamicQuery*, **/*BaseTimeEntity*, **/*HealthCheckController* ' +
+ ', **/*S3Manager*.java'
property 'sonar.java.checkstyle.reportPaths', 'build/reports/checkstyle/main.xml'
}
}
+
+test {
+ outputs.dir snippetsDir
+}
+
+asciidoctor {
+ configurations 'asciidoctorExtensions'
+ inputs.dir snippetsDir
+ dependsOn test
+}
+
+asciidoctor.doFirst {
+ delete file('src/main/resources/static/docs')
+}
+
+tasks.register('copyDocument', Copy) {
+ dependsOn asciidoctor
+ from file("build/docs/asciidoc")
+ into file("src/main/resources/static/docs")
+}
+
+bootJar {
+ dependsOn copyDocument
+}
+
+build {
+ dependsOn copyDocument
+}
diff --git a/config/naver-intellij-formatter-custom.xml b/config/naver-intellij-formatter-custom.xml
index 26f28954..dc4c4a2e 100644
--- a/config/naver-intellij-formatter-custom.xml
+++ b/config/naver-intellij-formatter-custom.xml
@@ -1,74 +1,75 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/infra/Dockerfile b/infra/Dockerfile
new file mode 100644
index 00000000..c7fb704b
--- /dev/null
+++ b/infra/Dockerfile
@@ -0,0 +1,8 @@
+FROM amazoncorretto:17
+
+ARG SPRING_ACTIVE_PROFILES
+ENV SPRING_ACTIVE_PROFILES ${SPRING_ACTIVE_PROFILES}
+
+COPY build/libs/moabam-server-0.0.1-SNAPSHOT.jar moabam.jar
+
+ENTRYPOINT ["java", "-jar", "-Duser.timezone=Asia/Seoul", "-Dspring.profiles.active=${SPRING_ACTIVE_PROFILES}", "/moabam.jar"]
diff --git a/infra/docker-compose-dev.yml b/infra/docker-compose-dev.yml
new file mode 100644
index 00000000..86ec3530
--- /dev/null
+++ b/infra/docker-compose-dev.yml
@@ -0,0 +1,77 @@
+version: '3.7'
+
+services:
+ nginx:
+ image: nginx:latest
+ container_name: nginx
+ platform: linux/arm64/v8
+ restart: always
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf
+ - ./nginx/conf.d:/etc/nginx/conf.d
+ - ./nginx/certbot/conf:/etc/letsencrypt
+ - ./nginx/certbot/www:/var/www/certbot
+ - ../logs/nginx:/var/log/nginx
+ certbot:
+ image: certbot/certbot:latest
+ container_name: certbot
+ platform: linux/arm64
+ restart: unless-stopped
+ volumes:
+ - ./nginx/certbot/conf:/etc/letsencrypt
+ - ./nginx/certbot/www:/var/www/certbot
+ entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
+ moabam-blue:
+ image: ${DOCKER_HUB_USERNAME}/${DOCKER_HUB_REPOSITORY}:${DOCKER_HUB_TAG}
+ container_name: ${BLUE_CONTAINER}
+ restart: unless-stopped
+ expose:
+ - ${SERVER_PORT}
+ depends_on:
+ - redis
+ - mysql
+ environment:
+ SPRING_ACTIVE_PROFILES: ${SPRING_ACTIVE_PROFILES}
+ moabam-green:
+ image: ${DOCKER_HUB_USERNAME}/${DOCKER_HUB_REPOSITORY}:${DOCKER_HUB_TAG}
+ container_name: ${GREEN_CONTAINER}
+ restart: unless-stopped
+ expose:
+ - ${SERVER_PORT}
+ depends_on:
+ - redis
+ - mysql
+ environment:
+ SPRING_ACTIVE_PROFILES: ${SPRING_ACTIVE_PROFILES}
+ redis:
+ image: redis:alpine
+ container_name: redis
+ platform: linux/arm64
+ restart: always
+ command: redis-server
+ ports:
+ - "6379:6379"
+ volumes:
+ - ./data/redis:/data
+ mysql:
+ image: mysql:8.0.33
+ container_name: mysql
+ platform: linux/arm64/v8
+ restart: always
+ ports:
+ - "3306:3306"
+ environment:
+ MYSQL_DATABASE: ${DEV_MYSQL_DATABASE}
+ MYSQL_USERNAME: ${DEV_MYSQL_USERNAME}
+ MYSQL_ROOT_PASSWORD: ${DEV_MYSQL_PASSWORD}
+ TZ: Asia/Seoul
+ command:
+ - --character-set-server=utf8mb4
+ - --collation-server=utf8mb4_unicode_ci
+ - --skip-character-set-client-handshake
+ volumes:
+ - ./data/mysql:/var/lib/mysql
+ - ./mysql/initdb.d:/docker-entrypoint-initdb.d
diff --git a/infra/docker-compose-prod.yml b/infra/docker-compose-prod.yml
new file mode 100644
index 00000000..8cf816fa
--- /dev/null
+++ b/infra/docker-compose-prod.yml
@@ -0,0 +1,56 @@
+version: '3.7'
+
+services:
+ nginx:
+ image: nginx:latest
+ container_name: nginx
+ platform: linux/arm64/v8
+ restart: always
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf
+ - ./nginx/conf.d:/etc/nginx/conf.d
+ - ./nginx/certbot/conf:/etc/letsencrypt
+ - ./nginx/certbot/www:/var/www/certbot
+ - ../logs/nginx:/var/log/nginx
+ certbot:
+ image: certbot/certbot:latest
+ container_name: certbot
+ platform: linux/arm64
+ restart: unless-stopped
+ volumes:
+ - ./nginx/certbot/conf:/etc/letsencrypt
+ - ./nginx/certbot/www:/var/www/certbot
+ entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
+ moabam-blue:
+ image: ${DOCKER_HUB_USERNAME}/${DOCKER_HUB_REPOSITORY}:${DOCKER_HUB_TAG}
+ container_name: ${BLUE_CONTAINER}
+ restart: unless-stopped
+ expose:
+ - ${SERVER_PORT}
+ depends_on:
+ - redis
+ environment:
+ SPRING_ACTIVE_PROFILES: ${SPRING_ACTIVE_PROFILES}
+ moabam-green:
+ image: ${DOCKER_HUB_USERNAME}/${DOCKER_HUB_REPOSITORY}:${DOCKER_HUB_TAG}
+ container_name: ${GREEN_CONTAINER}
+ restart: unless-stopped
+ expose:
+ - ${SERVER_PORT}
+ depends_on:
+ - redis
+ environment:
+ SPRING_ACTIVE_PROFILES: ${SPRING_ACTIVE_PROFILES}
+ redis:
+ image: redis:alpine
+ container_name: redis
+ platform: linux/arm64
+ restart: always
+ command: redis-server
+ ports:
+ - "6379:6379"
+ volumes:
+ - ./data/redis:/data
diff --git a/infra/mysql/initdb.d/init.sql b/infra/mysql/initdb.d/init.sql
new file mode 100644
index 00000000..a6edd500
--- /dev/null
+++ b/infra/mysql/initdb.d/init.sql
@@ -0,0 +1,259 @@
+use moabam_dev;
+
+create table admin
+(
+ id bigint not null auto_increment,
+ nickname varchar(255) not null unique,
+ social_id varchar(255) unique,
+ role enum ('ADMIN','BLACK','USER') default 'ADMIN' not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table badge
+(
+ id bigint not null auto_increment,
+ member_id bigint not null,
+ type enum ('BIRTH','LEVEL10','LEVEL50') not null,
+ created_at datetime(6) not null,
+ primary key (id)
+);
+
+create table bug_history
+(
+ id bigint not null auto_increment,
+ member_id bigint not null,
+ payment_id bigint,
+ bug_type enum ('GOLDEN','MORNING','NIGHT') not null,
+ action_type enum ('CHARGE','COUPON','REFUND','REWARD','USE') not null,
+ quantity integer not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table certification
+(
+ id bigint not null auto_increment,
+ routine_id bigint not null,
+ member_id bigint not null,
+ image varchar(255) not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table coupon
+(
+ id bigint not null auto_increment,
+ name varchar(20) not null unique,
+ point integer default 1 not null,
+ description varchar(50) default '',
+ type enum ('DISCOUNT','GOLDEN','MORNING','NIGHT') not null,
+ max_count integer default 1 not null,
+ start_at date not null unique,
+ open_at date not null,
+ admin_id bigint not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table coupon_wallet
+(
+ id bigint not null auto_increment,
+ member_id bigint not null,
+ coupon_id bigint not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table daily_member_certification
+(
+ id bigint not null auto_increment,
+ member_id bigint not null,
+ room_id bigint not null,
+ participant_id bigint,
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table daily_room_certification
+(
+ id bigint not null auto_increment,
+ room_id bigint not null,
+ certified_at date not null,
+ primary key (id)
+);
+
+create table inventory
+(
+ id bigint not null auto_increment,
+ member_id bigint not null,
+ item_id bigint not null,
+ is_default bit default false not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id),
+ index idx_member_id (member_id)
+);
+
+create table item
+(
+ id bigint not null auto_increment,
+ type enum ('MORNING','NIGHT') not null,
+ category enum ('SKIN') not null,
+ name varchar(255) not null,
+ awake_image varchar(255) not null,
+ sleep_image varchar(255) not null,
+ bug_price integer default 0 not null,
+ golden_bug_price integer default 0 not null,
+ unlock_level integer default 1 not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table member
+(
+ id bigint not null auto_increment,
+ social_id varchar(255) not null unique,
+ nickname varchar(255) unique,
+ intro varchar(30),
+ profile_image varchar(255) not null,
+ morning_image varchar(255) not null,
+ night_image varchar(255) not null,
+ total_certify_count bigint default 0 not null,
+ report_count integer default 0 not null,
+ current_morning_count integer default 0 not null,
+ current_night_count integer default 0 not null,
+ morning_bug integer default 0 not null,
+ night_bug integer default 0 not null,
+ golden_bug integer default 0 not null,
+ role enum ('ADMIN','BLACK','USER') default 'USER' not null,
+ deleted_at datetime(6),
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table participant
+(
+ id bigint not null auto_increment,
+ room_id bigint,
+ member_id bigint not null,
+ is_manager bit,
+ certify_count integer,
+ deleted_at datetime(6),
+ deleted_room_title varchar(30),
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table payment
+(
+ id bigint not null auto_increment,
+ member_id bigint not null,
+ product_id bigint not null,
+ coupon_wallet_id bigint,
+ order_id varchar(255),
+ order_name varchar(255) not null,
+ total_amount integer not null,
+ discount_amount integer not null,
+ payment_key varchar(255),
+ status enum ('ABORTED','CANCELED','DONE','EXPIRED','IN_PROGRESS','READY') not null,
+ created_at datetime(6) not null,
+ requested_at datetime(6),
+ approved_at datetime(6),
+ primary key (id),
+ index idx_order_id (order_id)
+);
+
+create table product
+(
+ id bigint not null auto_increment,
+ type enum ('BUG') default 'BUG' not null,
+ name varchar(255) not null,
+ price integer not null,
+ quantity integer default 1 not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table report
+(
+ id bigint not null auto_increment,
+ reporter_id bigint not null,
+ reported_member_id bigint not null,
+ room_id bigint,
+ certification_id bigint,
+ description varchar(255),
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table room
+(
+ id bigint not null auto_increment,
+ title varchar(20) not null,
+ password varchar(8),
+ level integer default 0 not null,
+ exp integer default 0 not null,
+ room_type enum ('MORNING','NIGHT'),
+ certify_time integer not null,
+ current_user_count integer not null,
+ max_user_count integer not null,
+ announcement varchar(100),
+ room_image varchar(500),
+ manager_nickname varchar(30),
+ deleted_at datetime(6),
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+create table routine
+(
+ id bigint not null auto_increment,
+ room_id bigint,
+ content varchar(20) not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6),
+ primary key (id)
+);
+
+alter table bug_history
+ add foreign key (payment_id) references payment (id);
+
+alter table certification
+ add foreign key (routine_id) references routine (id);
+
+alter table coupon_wallet
+ add foreign key (coupon_id) references coupon (id);
+
+alter table daily_member_certification
+ add foreign key (participant_id) references participant (id);
+
+alter table inventory
+ add foreign key (item_id) references item (id);
+
+alter table participant
+ add foreign key (room_id) references room (id);
+
+alter table payment
+ add foreign key (product_id) references product (id);
+
+alter table report
+ add foreign key (certification_id) references certification (id);
+
+alter table report
+ add foreign key (room_id) references room (id);
+
+alter table routine
+ add foreign key (room_id) references room (id);
diff --git a/infra/mysql/initdb.d/item-data.sql b/infra/mysql/initdb.d/item-data.sql
new file mode 100644
index 00000000..568e8f0f
--- /dev/null
+++ b/infra/mysql/initdb.d/item-data.sql
@@ -0,0 +1,48 @@
+insert into item (type, category, name, awake_image, sleep_image, unlock_level, created_at)
+values ('MORNING', 'SKIN', '오목눈이 알', 'https://image.moabam.com/moabam/skins/omok/default/egg.png',
+ 'https://image.moabam.com/moabam/skins/omok/default/egg.png', 0, current_time());
+
+insert into item (type, category, name, awake_image, sleep_image, unlock_level, created_at)
+values ('NIGHT', 'SKIN', '부엉이 알', 'https://image.moabam.com/moabam/skins/owl/default/egg.png',
+ 'https://image.moabam.com/moabam/skins/owl/default/egg.png', 0, current_time());
+
+insert into item (type, category, name, awake_image, sleep_image, unlock_level, created_at)
+values ('MORNING', 'SKIN', '오목눈이', 'https://image.moabam.com/moabam/skins/omok/default/eyes-opened.png',
+ 'https://image.moabam.com/moabam/skins/omok/default/eyes-closed.png', 1, current_time());
+
+insert into item (type, category, name, awake_image, sleep_image, unlock_level, created_at)
+values ('NIGHT', 'SKIN', '부엉이', 'https://image.moabam.com/moabam/skins/owl/default/eyes-opened.png',
+ 'https://image.moabam.com/moabam/skins/owl/default/eyes-closed.png', 1, current_time());
+
+insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at)
+values ('MORNING', 'SKIN', '안경 오목눈이', 'https://image.moabam.com/moabam/skins/omok/glasses/eyes-opened.png',
+ 'https://image.moabam.com/moabam/skins/omok/glasses/eyes-closed.png', 10, 5, 5, current_time());
+
+insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at)
+values ('NIGHT', 'SKIN', '안경 부엉이', 'https://image.moabam.com/moabam/skins/owl/glasses/eyes-opened.png',
+ 'https://image.moabam.com/moabam/skins/owl/glasses/eyes-closed.png', 10, 5, 5, current_time());
+
+insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at)
+values ('MORNING', 'SKIN', '목도리 오목눈이', 'https://image.moabam.com/moabam/skins/omok/scarf/eyes-opened.png',
+ 'https://image.moabam.com/moabam/skins/omok/scarf/eyes-closed.png', 20, 10, 10, current_time());
+
+insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at)
+values ('NIGHT', 'SKIN', '목도리 부엉이', 'https://image.moabam.com/moabam/skins/owl/scarf/eyes-opened.png',
+ 'https://image.moabam.com/moabam/skins/owl/scarf/eyes-closed.png', 20, 10, 10, current_time());
+
+insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at)
+values ('MORNING', 'SKIN', '산타 오목눈이', 'https://image.moabam.com/moabam/skins/omok/santa/eyes-opened.png',
+ 'https://image.moabam.com/moabam/skins/omok/santa/eyes-closed.png', 30, 15, 15, current_time());
+
+insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at)
+values ('NIGHT', 'SKIN', '산타 부엉이', 'https://image.moabam.com/moabam/skins/owl/santa/eyes-opened.png',
+ 'https://image.moabam.com/moabam/skins/owl/santa/eyes-closed.png', 30, 15, 15, current_time());
+
+insert into product (type, name, price, quantity, created_at)
+values ('BUG', '황금벌레 5', 3000, 5, current_time());
+
+insert into product (type, name, price, quantity, created_at)
+values ('BUG', '황금벌레 15', 7000, 15, current_time());
+
+insert into product (type, name, price, quantity, created_at)
+values ('BUG', '황금벌레 25', 9900, 25, current_time());
diff --git a/infra/nginx/conf.d/header.conf b/infra/nginx/conf.d/header.conf
new file mode 100644
index 00000000..59deea39
--- /dev/null
+++ b/infra/nginx/conf.d/header.conf
@@ -0,0 +1,9 @@
+proxy_pass_header Server;
+proxy_http_version 1.1;
+proxy_set_header Host $http_host;
+proxy_set_header Connection $connection_upgrade;
+proxy_set_header Upgrade $http_upgrade;
+
+proxy_set_header X-Real-IP $remote_addr;
+proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+proxy_set_header X-Forwarded-Proto $scheme;
diff --git a/infra/nginx/mime.types b/infra/nginx/mime.types
new file mode 100644
index 00000000..7c7cdef2
--- /dev/null
+++ b/infra/nginx/mime.types
@@ -0,0 +1,96 @@
+types {
+ text/html html htm shtml;
+ text/css css;
+ text/xml xml;
+ image/gif gif;
+ image/jpeg jpeg jpg;
+ application/javascript js;
+ application/atom+xml atom;
+ application/rss+xml rss;
+
+ text/mathml mml;
+ text/plain txt;
+ text/vnd.sun.j2me.app-descriptor jad;
+ text/vnd.wap.wml wml;
+ text/x-component htc;
+
+ image/png png;
+ image/svg+xml svg svgz;
+ image/tiff tif tiff;
+ image/vnd.wap.wbmp wbmp;
+ image/webp webp;
+ image/x-icon ico;
+ image/x-jng jng;
+ image/x-ms-bmp bmp;
+
+ font/woff woff;
+ font/woff2 woff2;
+
+ application/java-archive jar war ear;
+ application/json json;
+ application/mac-binhex40 hqx;
+ application/msword doc;
+ application/pdf pdf;
+ application/postscript ps eps ai;
+ application/rtf rtf;
+ application/vnd.apple.mpegurl m3u8;
+ application/vnd.google-earth.kml+xml kml;
+ application/vnd.google-earth.kmz kmz;
+ application/vnd.ms-excel xls;
+ application/vnd.ms-fontobject eot;
+ application/vnd.ms-powerpoint ppt;
+ application/vnd.oasis.opendocument.graphics odg;
+ application/vnd.oasis.opendocument.presentation odp;
+ application/vnd.oasis.opendocument.spreadsheet ods;
+ application/vnd.oasis.opendocument.text odt;
+ application/vnd.openxmlformats-officedocument.presentationml.presentation
+ pptx;
+ application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+ xlsx;
+ application/vnd.openxmlformats-officedocument.wordprocessingml.document
+ docx;
+ application/vnd.wap.wmlc wmlc;
+ application/x-7z-compressed 7z;
+ application/x-cocoa cco;
+ application/x-java-archive-diff jardiff;
+ application/x-java-jnlp-file jnlp;
+ application/x-makeself run;
+ application/x-perl pl pm;
+ application/x-pilot prc pdb;
+ application/x-rar-compressed rar;
+ application/x-redhat-package-manager rpm;
+ application/x-sea sea;
+ application/x-shockwave-flash swf;
+ application/x-stuffit sit;
+ application/x-tcl tcl tk;
+ application/x-x509-ca-cert der pem crt;
+ application/x-xpinstall xpi;
+ application/xhtml+xml xhtml;
+ application/xspf+xml xspf;
+ application/zip zip;
+
+ application/octet-stream bin exe dll;
+ application/octet-stream deb;
+ application/octet-stream dmg;
+ application/octet-stream iso img;
+ application/octet-stream msi msp msm;
+
+ audio/midi mid midi kar;
+ audio/mpeg mp3;
+ audio/ogg ogg;
+ audio/x-m4a m4a;
+ audio/x-realaudio ra;
+
+ video/3gpp 3gpp 3gp;
+ video/mp2t ts;
+ video/mp4 mp4;
+ video/mpeg mpeg mpg;
+ video/quicktime mov;
+ video/webm webm;
+ video/x-flv flv;
+ video/x-m4v m4v;
+ video/x-mng mng;
+ video/x-ms-asf asx asf;
+ video/x-ms-wmv wmv;
+ video/x-msvideo avi;
+}
diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf
new file mode 100644
index 00000000..73220f14
--- /dev/null
+++ b/infra/nginx/nginx.conf
@@ -0,0 +1,32 @@
+worker_processes auto;
+
+events {
+ use epoll;
+ worker_connections 1024;
+}
+
+http {
+ include mime.types;
+ default_type application/octet-stream;
+ sendfile on;
+ client_max_body_size 10M;
+
+ send_timeout 15s;
+ resolver_timeout 5s;
+
+ server_tokens off;
+
+ map $http_upgrade $connection_upgrade {
+ default "upgrade";
+ }
+
+ include conf.d/header.conf;
+
+ log_format main '$remote_addr $remote_user "$request" '
+ '$status $body_bytes_sent "$http_referer" "$request_time" '
+ '"$http_user_agent" ';
+
+ include conf.d/upstream.conf;
+ include conf.d/http-server.conf;
+ include conf.d/ssl-server.conf;
+}
diff --git a/infra/nginx/templates/http-server.template b/infra/nginx/templates/http-server.template
new file mode 100644
index 00000000..f4c91d91
--- /dev/null
+++ b/infra/nginx/templates/http-server.template
@@ -0,0 +1,13 @@
+server {
+ listen 80;
+ server_name ${SERVER_DOMAIN};
+
+ location / {
+ return 301 https://$http_host$request_uri;
+ }
+
+ location /.well-known/acme-challenge/ {
+ allow all;
+ root /var/www/certbot;
+ }
+}
diff --git a/infra/nginx/templates/ssl-server.template b/infra/nginx/templates/ssl-server.template
new file mode 100644
index 00000000..46db85fb
--- /dev/null
+++ b/infra/nginx/templates/ssl-server.template
@@ -0,0 +1,19 @@
+server {
+ listen 443 ssl;
+ server_name ${SERVER_DOMAIN};
+ access_log /var/log/nginx/access.log main;
+ error_log /var/log/nginx/error.log error;
+
+ location ^~ /actuator {
+ return 404;
+ }
+
+ ssl_certificate /etc/letsencrypt/live/${SERVER_DOMAIN}/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/${SERVER_DOMAIN}/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ location / {
+ proxy_pass http://backend;
+ }
+}
diff --git a/infra/nginx/templates/upstream.template b/infra/nginx/templates/upstream.template
new file mode 100644
index 00000000..d32d6266
--- /dev/null
+++ b/infra/nginx/templates/upstream.template
@@ -0,0 +1,4 @@
+upstream backend {
+ server ${BLUE_CONTAINER}:${SERVER_PORT};
+ keepalive 1024;
+}
diff --git a/infra/scripts/deploy-dev.sh b/infra/scripts/deploy-dev.sh
new file mode 100644
index 00000000..cc6debda
--- /dev/null
+++ b/infra/scripts/deploy-dev.sh
@@ -0,0 +1,119 @@
+#!/bin/bash
+
+# .env 파일 로드
+if [ -f /home/ubuntu/moabam/infra/.env ]; then
+ source /home/ubuntu/moabam/infra/.env
+fi
+
+if [ $(docker ps | grep -c "nginx") -eq 0 ]; then
+ echo "### nginx 시작 ###"
+ docker-compose up -d nginx
+else
+ echo "-------------------------------------------"
+ echo "nginx 이미 실행 중 입니다."
+ echo "-------------------------------------------"
+fi
+
+echo
+echo
+
+if [ $(docker ps | grep -c "redis") -eq 0 ]; then
+ echo "### redis 시작 ###"
+ docker-compose up -d redis
+else
+ echo "-------------------------------------------"
+ echo "redis 이미 실행 중 입니다."
+ echo "-------------------------------------------"
+fi
+
+echo
+echo
+
+if [ $(docker ps | grep -c "mysql") -eq 0 ]; then
+ echo "### mysql 시작 ###"
+ docker-compose up -d mysql
+else
+ echo "-------------------------------------------"
+ echo "mysql 이미 실행 중 입니다."
+ echo "-------------------------------------------"
+fi
+
+echo
+echo
+
+echo
+echo "### springboot blue-green 무중단 배포 시작 ###"
+echo
+
+IS_BLUE=$(docker ps | grep ${BLUE_CONTAINER})
+NGINX_CONF="/home/ubuntu/moabam/infra/nginx/nginx.conf"
+UPSTREAM_CONF="/home/ubuntu/moabam/infra/nginx/conf.d/upstream.conf"
+
+if [ -n "$IS_BLUE" ]; then
+ echo "### BLUE => GREEN ###"
+ echo "1. ${GREEN_CONTAINER} 이미지 가져오고 실행"
+ docker-compose pull moabam-green
+ docker-compose up -d moabam-green
+
+ attempt=1
+ while [ $attempt -le 24 ]; do
+ echo "2. ${GREEN_CONTAINER} health check (Attempt: $attempt)"
+ sleep 5
+ REQUEST=$(docker exec nginx curl http://${GREEN_CONTAINER}:${SERVER_PORT})
+
+ if [ -n "$REQUEST" ]; then
+ echo "${GREEN_CONTAINER} health check 성공"
+ sed -i "s/${BLUE_CONTAINER}/${GREEN_CONTAINER}/g" $UPSTREAM_CONF
+ echo "3. nginx 설정파일 reload"
+ docker exec nginx service nginx reload
+ echo "4. ${BLUE_CONTAINER} 컨테이너 종료"
+ docker-compose stop moabam-blue
+
+ echo "5. ${GREEN_CONTAINER} 배포 성공"
+ break;
+ fi
+
+ if [ $attempt -eq 24 ]; then
+ echo "${GREEN_CONTAINER} 배포 실패 !!"
+
+ docker-compose stop moabam-green
+
+ exit 1;
+ fi
+
+ attempt=$((attempt+1))
+ done;
+else
+ echo "### GREEN => BLUE ###"
+ echo "1. ${BLUE_CONTAINER} 이미지 가져오고 실행"
+ docker-compose pull moabam-blue
+ docker-compose up -d moabam-blue
+
+ attempt=1
+ while [ $attempt -le 24 ]; do
+ echo "2. ${BLUE_CONTAINER} health check (Attempt: $attempt)"
+ sleep 5
+ REQUEST=$(docker exec nginx curl http://${BLUE_CONTAINER}:${SERVER_PORT})
+
+ if [ -n "$REQUEST" ]; then
+ echo "${BLUE_CONTAINER} health check 성공"
+ sed -i "s/${GREEN_CONTAINER}/${BLUE_CONTAINER}/g" $UPSTREAM_CONF
+ echo "3. nginx 설정파일 reload"
+ docker exec nginx service nginx reload
+ echo "4. ${GREEN_CONTAINER} 컨테이너 종료"
+ docker-compose stop moabam-green
+
+ echo "5. ${BLUE_CONTAINER} 배포 성공"
+ break;
+ fi
+
+ if [ $attempt -eq 24 ]; then
+ echo "${BLUE_CONTAINER} 배포 실패 !!"
+
+ docker-compose stop moabam-blue
+ exit 1;
+ fi
+
+ attempt=$((attempt+1))
+ done;
+fi
diff --git a/infra/scripts/deploy-prod.sh b/infra/scripts/deploy-prod.sh
new file mode 100644
index 00000000..b9933fc0
--- /dev/null
+++ b/infra/scripts/deploy-prod.sh
@@ -0,0 +1,107 @@
+#!/bin/bash
+
+# .env 파일 로드
+if [ -f /home/ubuntu/moabam/infra/.env ]; then
+ source /home/ubuntu/moabam/infra/.env
+fi
+
+if [ $(docker ps | grep -c "nginx") -eq 0 ]; then
+ echo "### nginx 시작 ###"
+ docker-compose up -d nginx
+else
+ echo "-------------------------------------------"
+ echo "nginx 이미 실행 중 입니다."
+ echo "-------------------------------------------"
+fi
+
+echo
+echo
+
+if [ $(docker ps | grep -c "redis") -eq 0 ]; then
+ echo "### redis 시작 ###"
+ docker-compose up -d redis
+else
+ echo "-------------------------------------------"
+ echo "redis 이미 실행 중 입니다."
+ echo "-------------------------------------------"
+fi
+
+echo
+echo
+
+echo
+echo "### springboot blue-green 무중단 배포 시작 ###"
+echo
+
+IS_BLUE=$(docker ps | grep ${BLUE_CONTAINER})
+NGINX_CONF="/home/ubuntu/moabam/infra/nginx/nginx.conf"
+UPSTREAM_CONF="/home/ubuntu/moabam/infra/nginx/conf.d/upstream.conf"
+
+if [ -n "$IS_BLUE" ]; then
+ echo "### BLUE => GREEN ###"
+ echo "1. ${GREEN_CONTAINER} 이미지 가져오고 실행"
+ docker-compose pull moabam-green
+ docker-compose up -d moabam-green
+
+ attempt=1
+ while [ $attempt -le 24 ]; do
+ echo "2. ${GREEN_CONTAINER} health check (Attempt: $attempt)"
+ sleep 5
+ REQUEST=$(docker exec nginx curl http://${GREEN_CONTAINER}:${SERVER_PORT})
+
+ if [ -n "$REQUEST" ]; then
+ echo "${GREEN_CONTAINER} health check 성공"
+ sed -i "s/${BLUE_CONTAINER}/${GREEN_CONTAINER}/g" $UPSTREAM_CONF
+ echo "3. nginx 설정파일 reload"
+ docker exec nginx service nginx reload
+ echo "4. ${BLUE_CONTAINER} 컨테이너 종료"
+ docker-compose stop moabam-blue
+
+ echo "5. ${GREEN_CONTAINER} 배포 성공"
+ break;
+ fi
+
+ if [ $attempt -eq 24 ]; then
+ echo "${GREEN_CONTAINER} 배포 실패 !!"
+
+ docker-compose stop moabam-green
+
+ exit 1;
+ fi
+
+ attempt=$((attempt+1))
+ done;
+else
+ echo "### GREEN => BLUE ###"
+ echo "1. ${BLUE_CONTAINER} 이미지 가져오고 실행"
+ docker-compose pull moabam-blue
+ docker-compose up -d moabam-blue
+
+ attempt=1
+ while [ $attempt -le 24 ]; do
+ echo "2. ${BLUE_CONTAINER} health check (Attempt: $attempt)"
+ sleep 5
+ REQUEST=$(docker exec nginx curl http://${BLUE_CONTAINER}:${SERVER_PORT})
+
+ if [ -n "$REQUEST" ]; then
+ echo "${BLUE_CONTAINER} health check 성공"
+ sed -i "s/${GREEN_CONTAINER}/${BLUE_CONTAINER}/g" $UPSTREAM_CONF
+ echo "3. nginx 설정파일 reload"
+ docker exec nginx service nginx reload
+ echo "4. ${GREEN_CONTAINER} 컨테이너 종료"
+ docker-compose stop moabam-green
+
+ echo "5. ${BLUE_CONTAINER} 배포 성공"
+ break;
+ fi
+
+ if [ $attempt -eq 24 ]; then
+ echo "${BLUE_CONTAINER} 배포 실패 !!"
+
+ docker-compose stop moabam-blue
+ exit 1;
+ fi
+
+ attempt=$((attempt+1))
+ done;
+fi
diff --git a/infra/scripts/init-letsencrypt.sh b/infra/scripts/init-letsencrypt.sh
new file mode 100644
index 00000000..1031fd25
--- /dev/null
+++ b/infra/scripts/init-letsencrypt.sh
@@ -0,0 +1,86 @@
+#!/bin/bash
+
+# .env 파일 로드
+if [ -f /home/ubuntu/moabam/infra/.env ]; then
+ source /home/ubuntu/moabam/infra/.env
+fi
+
+if ! [ -x "$(command -v docker-compose)" ]; then
+ echo 'Error: docker-compose is not installed.' >&2
+ exit 1
+fi
+
+domains="${SERVER_DOMAIN}"
+rsa_key_size=4096
+data_path="/home/ubuntu/moabam/infra/nginx/certbot"
+email="${MY_EMAIL}" # Adding a valid address is strongly recommended
+staging=1 # Set to 1 if you're testing your setup to avoid hitting request limits
+
+if [ -d "$data_path" ]; then
+ read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
+ if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
+ exit
+ fi
+fi
+
+
+if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
+ echo "### Downloading recommended TLS parameters ..."
+ mkdir -p "$data_path/conf"
+ sudo chmod 777 "$data_path/conf"
+ curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
+ curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
+ echo
+fi
+
+echo "### Creating dummy certificate for $domains ..."
+path="/etc/letsencrypt/live/$domains"
+sudo mkdir -p "$data_path/conf/live/$domains"
+docker-compose run --rm --entrypoint "\
+ openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
+ -keyout '$path/privkey.pem' \
+ -out '$path/fullchain.pem' \
+ -subj '/CN=localhost'" certbot
+echo
+
+
+echo "### Starting nginx ..."
+docker-compose up --force-recreate -d nginx
+echo
+
+echo "### Deleting dummy certificate for $domains ..."
+docker-compose run --rm --entrypoint "\
+ rm -Rf /etc/letsencrypt/live/$domains && \
+ rm -Rf /etc/letsencrypt/archive/$domains && \
+ rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
+echo
+
+
+echo "### Requesting Let's Encrypt certificate for $domains ..."
+#Join $domains to -d args
+domain_args=""
+for domain in "${domains[@]}"; do
+ domain_args="$domain_args -d $domain"
+done
+
+# Select appropriate email arg
+case "$email" in
+ "") email_arg="--register-unsafely-without-email" ;;
+ *) email_arg="--email $email" ;;
+esac
+
+# Enable staging mode if needed
+if [ $staging != "0" ]; then staging_arg="--staging"; fi
+
+docker-compose run --rm --entrypoint "\
+ certbot certonly --webroot -w /var/www/certbot \
+ $staging_arg \
+ $email_arg \
+ $domain_args \
+ --rsa-key-size $rsa_key_size \
+ --agree-tos \
+ --force-renewal" certbot
+echo
+
+echo "### Reloading nginx ..."
+docker-compose exec nginx nginx -s reload
diff --git a/infra/scripts/init-nginx-converter.sh b/infra/scripts/init-nginx-converter.sh
new file mode 100644
index 00000000..861b2aeb
--- /dev/null
+++ b/infra/scripts/init-nginx-converter.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+# .env 파일 로드
+if [ -f /home/ubuntu/moabam/infra/.env ]; then
+ source /home/ubuntu/moabam/infra/.env
+fi
+
+export SERVER_DOMAIN=${SERVER_DOMAIN}
+export SERVER_PORT=${SERVER_PORT}
+export BLUE_CONTAINER=${BLUE_CONTAINER}
+
+envsubst '$SERVER_DOMAIN' < /home/ubuntu/moabam/infra/nginx/templates/http-server.template > /home/ubuntu/moabam/infra/nginx/conf.d/http-server.conf
+envsubst '$SERVER_DOMAIN' < /home/ubuntu/moabam/infra/nginx/templates/ssl-server.template > /home/ubuntu/moabam/infra/nginx/conf.d/ssl-server.conf
+envsubst '$BLUE_CONTAINER $SERVER_PORT' < /home/ubuntu/moabam/infra/nginx/templates/upstream.template > /home/ubuntu/moabam/infra/nginx/conf.d/upstream.conf
diff --git a/src/docs/asciidoc/coupon.adoc b/src/docs/asciidoc/coupon.adoc
new file mode 100644
index 00000000..222be81b
--- /dev/null
+++ b/src/docs/asciidoc/coupon.adoc
@@ -0,0 +1,112 @@
+== 쿠폰(Coupon)
+
+ 쿠폰에 대해 생성/삭제/조회/발급/사용 기능을 제공합니다.
+
+---
+
+=== 쿠폰 생성
+
+ 관리자가 쿠폰을 생성합니다.
+
+[discrete]
+==== 요청
+
+include::{snippets}/admins/coupons/http-request.adoc[]
+
+[discrete]
+==== 응답
+
+include::{snippets}/admins/coupons/http-response.adoc[]
+
+---
+
+=== 쿠폰 삭제
+
+ 관리자가 쿠폰 ID와 일치하는 쿠폰을 삭제합니다.
+
+[discrete]
+==== 요청
+
+include::{snippets}/admins/coupons/couponId/http-request.adoc[]
+
+[discrete]
+==== 응답
+
+include::{snippets}/admins/coupons/couponId/http-response.adoc[]
+
+---
+
+=== 특정 쿠폰 조회
+
+ 관리자 혹은 사용자가 특정 ID와 일치하는 쿠폰을 조회합니다.
+
+==== 요청
+
+include::{snippets}/coupons/couponId/http-request.adoc[]
+
+[discrete]
+==== 응답
+
+include::{snippets}/coupons/couponId/http-response.adoc[]
+
+---
+
+=== 상태에 따른 쿠폰들을 조회
+
+ 관리자 혹은 사용자가 날짜 상태에 따라 쿠폰들을 조회합니다.
+
+==== 요청
+
+include::{snippets}/coupons/search/http-request.adoc[]
+
+[discrete]
+==== 응답
+
+include::{snippets}/coupons/search/http-response.adoc[]
+
+---
+
+=== 특정 쿠폰에 대해 발급
+
+ 사용자가 발급 가능한 쿠폰을 선착순으로 발급 받습니다.
+
+==== 요청
+
+include::{snippets}/coupons/http-request.adoc[]
+
+[discrete]
+==== 응답
+
+include::{snippets}/coupons/http-response.adoc[]
+
+---
+
+=== 특정 사용자의 쿠폰 보관함을 조회
+
+ 사용자가 자신의 보관함에 있는 쿠폰들을 조회합니다.
+
+==== 요청
+
+include::{snippets}/my-coupons/couponId/http-request.adoc[]
+
+[discrete]
+==== 응답
+
+include::{snippets}/my-coupons/couponId/http-response.adoc[]
+
+---
+
+=== 쿠폰을 사용
+
+ 사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다.
+
+==== 요청
+
+include::{snippets}/my-coupons/couponWalletId/http-request.adoc[]
+
+[discrete]
+==== 응답
+
+include::{snippets}/my-coupons/couponWalletId/http-response.adoc[]
+
+---
diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc
new file mode 100644
index 00000000..ea559aeb
--- /dev/null
+++ b/src/docs/asciidoc/index.adoc
@@ -0,0 +1,76 @@
+= MOABAM API 문서
+:doctype: book
+:icons: font
+:source-highlighter: highlightjs
+:toc: left
+:toc-title: 목차
+:toclevels: 3
+:sectlinks:
+:sectnums:
+
+== 개요
+
+이 API 문서는 'MOABAM' 프로젝트의 산출물입니다.
+
+=== API 서버 경로
+
+[cols="2,5,3"]
+|====
+|환경 |DNS |비고
+|개발(dev) | link:[dev.moabam.com] |
+|운영(prod) | link:[www.moabam.com] |
+|====
+
+[NOTE]
+====
+해당 프로젝트 API 문서는 [특이사항]입니다.
+====
+
+[CAUTION]
+====
+해당 프로젝트 API 문서는 [주의사항]입니다.
+====
+
+=== 응답형식
+
+프로젝트는 다음과 같은 응답형식을 제공합니다.
+
+==== 정상(2XX)
+
+|====
+|응답데이터가 없는 경우|응답데이터가 있는 경우
+
+a|
+[source,json]
+----
+{
+
+}
+----
+
+a|
+[source,json]
+----
+{
+ "name": "Hong-Dosan"
+}
+----
+|====
+
+==== 상태코드(HttpStatus)
+
+응답시 다음과 같은 응답상태 헤더, 응답코드 및 응답메시지를 제공합니다.
+
+[cols="5,5"]
+|====
+|HttpStatus |설명
+
+|`OK(200)` |정상 응답
+|`CREATED(201)` |새로운 리소스 생성
+|`BAD_REQUEST(400)`|요청값 누락, 잘못된 기입
+|`UNAUTHORIZED(401)`|비인증 요청
+|`NOT_FOUND(404)`|요청값 누락, 잘못된 기입, 비인가 접속 등
+|`CONFLICT(409)`|요청값 중복
+|`INTERNAL_SERVER_ERROR(500)`|알 수 없는 서버 에러가 발생했습니다. 관리자에게 문의하세요.
+
+|====
diff --git a/src/docs/asciidoc/notification.adoc b/src/docs/asciidoc/notification.adoc
new file mode 100644
index 00000000..2a2af666
--- /dev/null
+++ b/src/docs/asciidoc/notification.adoc
@@ -0,0 +1,34 @@
+== 알림(Notification)
+
+ 콕 찌르기 알림, FCM Token 저장 기능을 제공합니다.
+
+=== 콕 찌르기 알림
+
+ 1) 특정 방의 사용자가 다른 사용자를 콕 찌릅니다.
+ 2) 서버에서 콕 찌를 대상의 FCM Token 여부를 검증합니다.
+ 3) Firebase 서버에 FCM Push Messaing 알림을 비동기로 요청합니다.
+ 4) Firebase 서버에서 FCM Token으로 식별된 기기에 알림을 보냅니다.
+
+[discrete]
+==== 요청
+
+include::{snippets}/notifications/rooms/roomId/members/memberId/http-request.adoc[]
+
+[discrete]
+==== 응답
+
+include::{snippets}/notifications/rooms/roomId/members/memberId/http-response.adoc[]
+
+=== FCM TOKEN 저장
+
+ 1) 특정 사용자의 FCM-TOKEN을 받아서 REDIS DB에 저장합니다.
+
+[discrete]
+==== 요청
+
+include::{snippets}/notifications/http-request.adoc[]
+
+[discrete]
+==== 응답
+
+include::{snippets}/notifications/http-response.adoc[]
diff --git a/src/main/java/com/moabam/MoabamServerApplication.java b/src/main/java/com/moabam/MoabamServerApplication.java
index 5390acc3..e2dbce1d 100644
--- a/src/main/java/com/moabam/MoabamServerApplication.java
+++ b/src/main/java/com/moabam/MoabamServerApplication.java
@@ -2,12 +2,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+@ConfigurationPropertiesScan
@SpringBootApplication
public class MoabamServerApplication {
public static void main(String[] args) {
SpringApplication.run(MoabamServerApplication.class, args);
}
-
}
diff --git a/src/main/java/com/moabam/admin/application/admin/AdminMapper.java b/src/main/java/com/moabam/admin/application/admin/AdminMapper.java
new file mode 100644
index 00000000..648c2e4d
--- /dev/null
+++ b/src/main/java/com/moabam/admin/application/admin/AdminMapper.java
@@ -0,0 +1,16 @@
+package com.moabam.admin.application.admin;
+
+import com.moabam.admin.domain.admin.Admin;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class AdminMapper {
+
+ public static Admin toAdmin(Long socialId) {
+ return Admin.builder()
+ .socialId(String.valueOf(socialId))
+ .build();
+ }
+}
diff --git a/src/main/java/com/moabam/admin/application/admin/AdminService.java b/src/main/java/com/moabam/admin/application/admin/AdminService.java
new file mode 100644
index 00000000..01243bd7
--- /dev/null
+++ b/src/main/java/com/moabam/admin/application/admin/AdminService.java
@@ -0,0 +1,57 @@
+package com.moabam.admin.application.admin;
+
+import java.util.Optional;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.moabam.admin.domain.admin.Admin;
+import com.moabam.admin.domain.admin.AdminRepository;
+import com.moabam.api.application.auth.mapper.AuthMapper;
+import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse;
+import com.moabam.api.dto.auth.LoginResponse;
+import com.moabam.global.error.exception.BadRequestException;
+import com.moabam.global.error.exception.NotFoundException;
+import com.moabam.global.error.model.ErrorMessage;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AdminService {
+
+ @Value("${admin}")
+ private String adminLoginKey;
+
+ private final AdminRepository adminRepository;
+
+ public void validate(String state) {
+ if (!adminLoginKey.equals(state)) {
+ throw new BadRequestException(ErrorMessage.LOGIN_FAILED_ADMIN_KEY);
+ }
+ }
+
+ @Transactional
+ public LoginResponse signUpOrLogin(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) {
+ return login(authorizationTokenInfoResponse);
+ }
+
+ private LoginResponse login(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) {
+ Optional admin = adminRepository.findBySocialId(String.valueOf(authorizationTokenInfoResponse.id()));
+ Admin loginMember = admin.orElseGet(() -> signUp(authorizationTokenInfoResponse.id()));
+
+ return AuthMapper.toLoginResponse(loginMember, admin.isEmpty());
+ }
+
+ private Admin signUp(Long socialId) {
+ Admin admin = AdminMapper.toAdmin(socialId);
+
+ return adminRepository.save(admin);
+ }
+
+ public Admin findMember(Long id) {
+ return adminRepository.findById(id).orElseThrow(() -> new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND));
+ }
+}
diff --git a/src/main/java/com/moabam/admin/domain/admin/Admin.java b/src/main/java/com/moabam/admin/domain/admin/Admin.java
new file mode 100644
index 00000000..eeff1bc3
--- /dev/null
+++ b/src/main/java/com/moabam/admin/domain/admin/Admin.java
@@ -0,0 +1,53 @@
+package com.moabam.admin.domain.admin;
+
+import static com.moabam.global.common.util.RandomUtils.*;
+import static java.util.Objects.*;
+
+import org.hibernate.annotations.ColumnDefault;
+
+import com.moabam.api.domain.member.Role;
+import com.moabam.global.common.entity.BaseTimeEntity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Admin extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "nickname", unique = true)
+ private String nickname;
+
+ @Column(name = "social_id", nullable = false, unique = true)
+ private String socialId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "role", nullable = false)
+ @ColumnDefault("'ADMIN'")
+ private Role role;
+
+ @Builder
+ private Admin(String socialId) {
+ this.socialId = requireNonNull(socialId);
+ this.nickname = createNickName();
+ this.role = Role.ADMIN;
+ }
+
+ private String createNickName() {
+ return "오목눈이#" + randomStringValues();
+ }
+}
diff --git a/src/main/java/com/moabam/admin/domain/admin/AdminRepository.java b/src/main/java/com/moabam/admin/domain/admin/AdminRepository.java
new file mode 100644
index 00000000..e4878786
--- /dev/null
+++ b/src/main/java/com/moabam/admin/domain/admin/AdminRepository.java
@@ -0,0 +1,10 @@
+package com.moabam.admin.domain.admin;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface AdminRepository extends JpaRepository {
+
+ Optional findBySocialId(String socialId);
+}
diff --git a/src/main/java/com/moabam/admin/presentation/admin/AdminController.java b/src/main/java/com/moabam/admin/presentation/admin/AdminController.java
new file mode 100644
index 00000000..cea5bb99
--- /dev/null
+++ b/src/main/java/com/moabam/admin/presentation/admin/AdminController.java
@@ -0,0 +1,41 @@
+package com.moabam.admin.presentation.admin;
+
+import org.springframework.http.HttpStatus;
+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.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.moabam.admin.application.admin.AdminService;
+import com.moabam.api.application.auth.AuthorizationService;
+import com.moabam.api.dto.auth.AuthorizationCodeResponse;
+import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse;
+import com.moabam.api.dto.auth.AuthorizationTokenResponse;
+import com.moabam.api.dto.auth.LoginResponse;
+
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequestMapping("/admins")
+@RequiredArgsConstructor
+public class AdminController {
+
+ private final AuthorizationService authorizationService;
+ private final AdminService adminService;
+
+ @PostMapping("/login/kakao/oauth")
+ @ResponseStatus(HttpStatus.OK)
+ public LoginResponse authorizationTokenIssue(@RequestBody AuthorizationCodeResponse authorizationCodeResponse,
+ HttpServletResponse httpServletResponse) {
+ adminService.validate(authorizationCodeResponse.state());
+ AuthorizationTokenResponse tokenResponse = authorizationService.requestAdminToken(authorizationCodeResponse);
+ AuthorizationTokenInfoResponse authorizationTokenInfoResponse =
+ authorizationService.requestTokenInfo(tokenResponse);
+ LoginResponse loginResponse = adminService.signUpOrLogin(authorizationTokenInfoResponse);
+ authorizationService.issueServiceToken(httpServletResponse, loginResponse.publicClaim());
+
+ return loginResponse;
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java
new file mode 100644
index 00000000..ed2d0840
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java
@@ -0,0 +1,239 @@
+package com.moabam.api.application.auth;
+
+import java.util.Arrays;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import com.moabam.admin.application.admin.AdminService;
+import com.moabam.api.application.auth.mapper.AuthMapper;
+import com.moabam.api.application.auth.mapper.AuthorizationMapper;
+import com.moabam.api.application.member.MemberService;
+import com.moabam.api.domain.auth.repository.TokenRepository;
+import com.moabam.api.domain.member.Member;
+import com.moabam.api.domain.member.Role;
+import com.moabam.api.dto.auth.AuthorizationCodeRequest;
+import com.moabam.api.dto.auth.AuthorizationCodeResponse;
+import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse;
+import com.moabam.api.dto.auth.AuthorizationTokenRequest;
+import com.moabam.api.dto.auth.AuthorizationTokenResponse;
+import com.moabam.api.dto.auth.LoginResponse;
+import com.moabam.api.dto.auth.TokenSaveValue;
+import com.moabam.api.infrastructure.fcm.FcmService;
+import com.moabam.global.auth.model.AuthMember;
+import com.moabam.global.auth.model.PublicClaim;
+import com.moabam.global.common.util.CookieUtils;
+import com.moabam.global.common.util.GlobalConstant;
+import com.moabam.global.config.AllowOriginConfig;
+import com.moabam.global.config.OAuthConfig;
+import com.moabam.global.config.TokenConfig;
+import com.moabam.global.error.exception.BadRequestException;
+import com.moabam.global.error.exception.UnauthorizedException;
+import com.moabam.global.error.model.ErrorMessage;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AuthorizationService {
+
+ private final FcmService fcmService;
+ private final OAuthConfig oAuthConfig;
+ private final TokenConfig tokenConfig;
+ private final OAuth2AuthorizationServerRequestService oauth2AuthorizationServerRequestService;
+ private final MemberService memberService;
+ private final AdminService adminService;
+ private final JwtProviderService jwtProviderService;
+ private final TokenRepository tokenRepository;
+ private final AllowOriginConfig allowOriginsConfig;
+
+ public void redirectToLoginPage(HttpServletResponse httpServletResponse) {
+ String authorizationCodeUri = getAuthorizationCodeUri();
+ oauth2AuthorizationServerRequestService.loginRequest(httpServletResponse, authorizationCodeUri);
+ }
+
+ public AuthorizationTokenResponse requestAdminToken(AuthorizationCodeResponse authorizationCodeResponse) {
+ validAuthorizationGrant(authorizationCodeResponse.code());
+
+ return issueTokenToAuthorizationServer(authorizationCodeResponse.code(),
+ oAuthConfig.provider().adminRedirectUri());
+ }
+
+ public AuthorizationTokenResponse requestToken(AuthorizationCodeResponse authorizationCodeResponse) {
+ validAuthorizationGrant(authorizationCodeResponse.code());
+
+ return issueTokenToAuthorizationServer(authorizationCodeResponse.code(), oAuthConfig.provider().redirectUri());
+ }
+
+ public AuthorizationTokenInfoResponse requestTokenInfo(AuthorizationTokenResponse authorizationTokenResponse) {
+ String tokenValue = generateTokenValue(authorizationTokenResponse.accessToken());
+ ResponseEntity authorizationTokenInfoResponse =
+ oauth2AuthorizationServerRequestService
+ .tokenInfoRequest(oAuthConfig.provider().tokenInfo(), tokenValue);
+
+ return authorizationTokenInfoResponse.getBody();
+ }
+
+ public LoginResponse signUpOrLogin(HttpServletResponse httpServletResponse,
+ AuthorizationTokenInfoResponse authorizationTokenInfoResponse) {
+ LoginResponse loginResponse = memberService.login(authorizationTokenInfoResponse);
+ issueServiceToken(httpServletResponse, loginResponse.publicClaim());
+
+ return loginResponse;
+ }
+
+ public void issueServiceToken(HttpServletResponse response, PublicClaim publicClaim) {
+ String accessToken = jwtProviderService.provideAccessToken(publicClaim);
+ String refreshToken = jwtProviderService.provideRefreshToken(publicClaim.role());
+ TokenSaveValue tokenSaveRequest = AuthMapper.toTokenSaveValue(refreshToken, null);
+
+ tokenRepository.saveToken(publicClaim.id(), tokenSaveRequest, publicClaim.role());
+
+ String domain = getDomain(publicClaim.role());
+
+ response.addCookie(CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire(), domain));
+ response.addCookie(CookieUtils
+ .tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire(), domain));
+ response.addCookie(CookieUtils
+ .tokenCookie("refresh_token", refreshToken, tokenConfig.getRefreshExpire(), domain));
+ }
+
+ public void validTokenPair(Long id, String oldRefreshToken, Role role) {
+ TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(id, role);
+
+ if (!tokenSaveValue.refreshToken().equals(oldRefreshToken)) {
+ tokenRepository.delete(id, role);
+
+ throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL);
+ }
+ }
+
+ public void logout(AuthMember authMember,
+ HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
+ removeToken(httpServletRequest, httpServletResponse);
+ tokenRepository.delete(authMember.id(), authMember.role());
+ fcmService.deleteTokenByMemberId(authMember.id());
+ }
+
+ public void removeToken(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
+ if (httpServletRequest.getCookies() == null) {
+ return;
+ }
+
+ Arrays.stream(httpServletRequest.getCookies()).forEach(cookie -> {
+ if (cookie.getName().contains("token")) {
+ httpServletResponse.addCookie(CookieUtils.deleteCookie(cookie));
+ }
+ });
+ }
+
+ @Transactional
+ public void unLinkMember(AuthMember authMember) {
+ memberService.validateMemberToDelete(authMember.id());
+ Member member = memberService.findMember(authMember.id());
+ unlinkRequest(member.getSocialId());
+ memberService.delete(member);
+ }
+
+ private String getDomain(Role role) {
+ if (role.equals(Role.ADMIN)) {
+ return allowOriginsConfig.adminDomain();
+ }
+
+ return allowOriginsConfig.domain();
+ }
+
+ private void unlinkRequest(String socialId) {
+ try {
+ oauth2AuthorizationServerRequestService.unlinkMemberRequest(oAuthConfig.provider().unlink(),
+ oAuthConfig.client().adminKey(), unlinkRequestParam(socialId));
+ log.info("회원 탈퇴 성공 : [socialId={}]", socialId);
+ } catch (BadRequestException badRequestException) {
+ log.warn("회원 탈퇴요청 실패 : 카카오 연결 오류");
+ throw new BadRequestException(ErrorMessage.UNLINK_REQUEST_FAIL_ROLLBACK_SUCCESS);
+ }
+ }
+
+ private MultiValueMap unlinkRequestParam(String socialId) {
+ MultiValueMap params = new LinkedMultiValueMap<>();
+ params.add("target_id_type", "user_id");
+ params.add("target_id", socialId);
+
+ return params;
+ }
+
+ private String getAuthorizationCodeUri() {
+ AuthorizationCodeRequest authorizationCodeRequest = AuthorizationMapper.toAuthorizationCodeRequest(oAuthConfig);
+ return generateQueryParamsWith(authorizationCodeRequest);
+ }
+
+ private String generateTokenValue(String token) {
+ return "Bearer" + GlobalConstant.SPACE + token;
+ }
+
+ private String generateQueryParamsWith(AuthorizationCodeRequest authorizationCodeRequest) {
+ UriComponentsBuilder authorizationCodeUri =
+ UriComponentsBuilder.fromUriString(
+ oAuthConfig.provider()
+ .authorizationUri())
+ .queryParam("response_type", "code")
+ .queryParam("client_id", authorizationCodeRequest.clientId())
+ .queryParam("redirect_uri", authorizationCodeRequest.redirectUri());
+
+ if (authorizationCodeRequest.scope() != null && !authorizationCodeRequest.scope().isEmpty()) {
+ String scopes = String.join(",", authorizationCodeRequest.scope());
+ authorizationCodeUri.queryParam("scope", scopes);
+ }
+
+ return authorizationCodeUri.toUriString();
+ }
+
+ private void validAuthorizationGrant(String code) {
+ if (code == null) {
+ throw new BadRequestException(ErrorMessage.GRANT_FAILED);
+ }
+ }
+
+ private AuthorizationTokenResponse issueTokenToAuthorizationServer(String code, String redirectUri) {
+ AuthorizationTokenRequest authorizationTokenRequest =
+ AuthorizationMapper.toAuthorizationTokenRequest(oAuthConfig, code, redirectUri);
+ MultiValueMap uriParams = generateTokenRequest(authorizationTokenRequest);
+ ResponseEntity authorizationTokenResponse =
+ oauth2AuthorizationServerRequestService
+ .requestAuthorizationServer(oAuthConfig.provider().tokenUri(), uriParams);
+
+ return authorizationTokenResponse.getBody();
+ }
+
+ private MultiValueMap generateTokenRequest(AuthorizationTokenRequest authorizationTokenRequest) {
+ MultiValueMap contents = new LinkedMultiValueMap<>();
+ contents.add("grant_type", authorizationTokenRequest.grantType());
+ contents.add("client_id", authorizationTokenRequest.clientId());
+ contents.add("redirect_uri", authorizationTokenRequest.redirectUri());
+ contents.add("code", authorizationTokenRequest.code());
+
+ if (authorizationTokenRequest.clientSecret() != null) {
+ contents.add("client_secret", authorizationTokenRequest.clientSecret());
+ }
+
+ return contents;
+ }
+
+ public void validMemberExist(Long id, Role role) {
+ if (role.equals(Role.ADMIN)) {
+ adminService.findMember(id);
+
+ return;
+ }
+
+ memberService.findMember(id);
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java b/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java
new file mode 100644
index 00000000..52c652f9
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java
@@ -0,0 +1,59 @@
+package com.moabam.api.application.auth;
+
+import java.nio.charset.StandardCharsets;
+import java.security.Key;
+
+import org.json.JSONObject;
+import org.springframework.stereotype.Service;
+
+import com.moabam.api.application.auth.mapper.AuthorizationMapper;
+import com.moabam.api.domain.member.Role;
+import com.moabam.global.auth.model.PublicClaim;
+import com.moabam.global.config.TokenConfig;
+import com.moabam.global.error.exception.UnauthorizedException;
+import com.moabam.global.error.model.ErrorMessage;
+
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.io.Decoders;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class JwtAuthenticationService {
+
+ private final TokenConfig tokenConfig;
+
+ public boolean isTokenExpire(String token, Role role) {
+ try {
+ Key key = getSecret(role);
+
+ Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token);
+ return false;
+ } catch (ExpiredJwtException expiredJwtException) {
+ return true;
+ } catch (Exception exception) {
+ throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL);
+ }
+ }
+
+ private Key getSecret(Role role) {
+ if (role.equals(Role.ADMIN)) {
+ return tokenConfig.getAdminKey();
+ }
+
+ return tokenConfig.getKey();
+ }
+
+ public PublicClaim parseClaim(String token) {
+ String claims = token.split("\\.")[1];
+ byte[] claimsBytes = Decoders.BASE64URL.decode(claims);
+ String decodedClaims = new String(claimsBytes, StandardCharsets.UTF_8);
+ JSONObject jsonObject = new JSONObject(decodedClaims);
+
+ return AuthorizationMapper.toPublicClaim(jsonObject);
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/auth/JwtProviderService.java b/src/main/java/com/moabam/api/application/auth/JwtProviderService.java
new file mode 100644
index 00000000..816985f2
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/auth/JwtProviderService.java
@@ -0,0 +1,63 @@
+package com.moabam.api.application.auth;
+
+import java.security.Key;
+import java.util.Date;
+
+import org.springframework.stereotype.Service;
+
+import com.moabam.api.domain.member.Role;
+import com.moabam.global.auth.model.PublicClaim;
+import com.moabam.global.config.TokenConfig;
+
+import io.jsonwebtoken.JwtBuilder;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class JwtProviderService {
+
+ private final TokenConfig tokenConfig;
+
+ public String provideAccessToken(PublicClaim publicClaim) {
+ return generateIdToken(publicClaim, tokenConfig.getAccessExpire());
+ }
+
+ public String provideRefreshToken(Role role) {
+ return generateCommonInfo(tokenConfig.getRefreshExpire(), role);
+ }
+
+ private String generateIdToken(PublicClaim publicClaim, long expireTime) {
+ return commonInfo(expireTime, publicClaim.role())
+ .claim("id", publicClaim.id())
+ .claim("nickname", publicClaim.nickname())
+ .claim("role", publicClaim.role())
+ .compact();
+ }
+
+ private String generateCommonInfo(long expireTime, Role role) {
+ return commonInfo(expireTime, role).compact();
+ }
+
+ private JwtBuilder commonInfo(long expireTime, Role role) {
+ Date issueDate = new Date();
+ Date expireDate = new Date(issueDate.getTime() + expireTime);
+
+ return Jwts.builder()
+ .setHeaderParam("alg", "HS256")
+ .setHeaderParam("typ", "JWT")
+ .setIssuer(tokenConfig.getIss())
+ .setIssuedAt(issueDate)
+ .setExpiration(expireDate)
+ .signWith(getSecretKey(role), SignatureAlgorithm.HS256);
+ }
+
+ private Key getSecretKey(Role role) {
+ if (role.equals(Role.ADMIN)) {
+ return tokenConfig.getAdminKey();
+ }
+
+ return tokenConfig.getKey();
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestService.java b/src/main/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestService.java
new file mode 100644
index 00000000..44db540c
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestService.java
@@ -0,0 +1,71 @@
+package com.moabam.api.application.auth;
+
+import java.io.IOException;
+
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse;
+import com.moabam.api.dto.auth.AuthorizationTokenResponse;
+import com.moabam.global.common.util.GlobalConstant;
+import com.moabam.global.error.exception.BadRequestException;
+import com.moabam.global.error.handler.RestTemplateResponseHandler;
+import com.moabam.global.error.model.ErrorMessage;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+@Service
+public class OAuth2AuthorizationServerRequestService {
+
+ private final RestTemplate restTemplate;
+
+ public OAuth2AuthorizationServerRequestService() {
+ restTemplate = new RestTemplateBuilder()
+ .errorHandler(new RestTemplateResponseHandler())
+ .build();
+ }
+
+ public void loginRequest(HttpServletResponse httpServletResponse, String authorizationCodeUri) {
+ try {
+ httpServletResponse.setContentType(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8);
+ httpServletResponse.sendRedirect(authorizationCodeUri);
+ } catch (IOException e) {
+ throw new BadRequestException(ErrorMessage.REQUEST_FAILED);
+ }
+ }
+
+ public ResponseEntity requestAuthorizationServer(String tokenUri,
+ MultiValueMap uriParams) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.add(HttpHeaders.CONTENT_TYPE,
+ MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8);
+ HttpEntity> httpEntity = new HttpEntity<>(uriParams, headers);
+
+ return restTemplate.exchange(tokenUri, HttpMethod.POST, httpEntity, AuthorizationTokenResponse.class);
+ }
+
+ public ResponseEntity tokenInfoRequest(String tokenInfoUri, String tokenValue) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.add("Authorization", tokenValue);
+ HttpEntity httpEntity = new HttpEntity<>(headers);
+
+ return restTemplate.exchange(tokenInfoUri, HttpMethod.GET, httpEntity, AuthorizationTokenInfoResponse.class);
+ }
+
+ public void unlinkMemberRequest(String unlinkUri, String adminKey, MultiValueMap params) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.add(HttpHeaders.CONTENT_TYPE,
+ MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8);
+ headers.add("Authorization", "KakaoAK " + adminKey);
+ HttpEntity> httpEntity = new HttpEntity<>(params, headers);
+
+ restTemplate.exchange(unlinkUri, HttpMethod.POST, httpEntity, Void.class);
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java b/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java
new file mode 100644
index 00000000..0fe48a56
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java
@@ -0,0 +1,43 @@
+package com.moabam.api.application.auth.mapper;
+
+import com.moabam.admin.domain.admin.Admin;
+import com.moabam.api.domain.member.Member;
+import com.moabam.api.dto.auth.LoginResponse;
+import com.moabam.api.dto.auth.TokenSaveValue;
+import com.moabam.global.auth.model.PublicClaim;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class AuthMapper {
+
+ public static LoginResponse toLoginResponse(Member member, boolean isSignUp) {
+ return LoginResponse.builder()
+ .publicClaim(PublicClaim.builder()
+ .id(member.getId())
+ .nickname(member.getNickname())
+ .role(member.getRole())
+ .build())
+ .isSignUp(isSignUp)
+ .build();
+ }
+
+ public static LoginResponse toLoginResponse(Admin admin, boolean isSignUp) {
+ return LoginResponse.builder()
+ .publicClaim(PublicClaim.builder()
+ .id(admin.getId())
+ .nickname(admin.getNickname())
+ .role(admin.getRole())
+ .build())
+ .isSignUp(isSignUp)
+ .build();
+ }
+
+ public static TokenSaveValue toTokenSaveValue(String refreshToken, String ip) {
+ return TokenSaveValue.builder()
+ .refreshToken(refreshToken)
+ .loginIp(ip)
+ .build();
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java b/src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java
new file mode 100644
index 00000000..3e566153
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java
@@ -0,0 +1,48 @@
+package com.moabam.api.application.auth.mapper;
+
+import org.json.JSONObject;
+
+import com.moabam.api.domain.member.Role;
+import com.moabam.api.dto.auth.AuthorizationCodeRequest;
+import com.moabam.api.dto.auth.AuthorizationTokenRequest;
+import com.moabam.global.auth.model.AuthMember;
+import com.moabam.global.auth.model.PublicClaim;
+import com.moabam.global.config.OAuthConfig;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class AuthorizationMapper {
+
+ public static AuthorizationCodeRequest toAuthorizationCodeRequest(OAuthConfig oAuthConfig) {
+ return AuthorizationCodeRequest.builder()
+ .clientId(oAuthConfig.client().clientId())
+ .redirectUri(oAuthConfig.provider().redirectUri())
+ .scope(oAuthConfig.client().scope())
+ .build();
+ }
+
+ public static AuthorizationTokenRequest toAuthorizationTokenRequest(OAuthConfig oAuthConfig, String code,
+ String redirectUri) {
+ return AuthorizationTokenRequest.builder()
+ .grantType(oAuthConfig.client().authorizationGrantType())
+ .clientId(oAuthConfig.client().clientId())
+ .redirectUri(redirectUri)
+ .code(code)
+ .clientSecret(oAuthConfig.client().clientSecret())
+ .build();
+ }
+
+ public static PublicClaim toPublicClaim(JSONObject jsonObject) {
+ return PublicClaim.builder()
+ .id(Long.valueOf(jsonObject.get("id").toString()))
+ .nickname(jsonObject.getString("nickname"))
+ .role(jsonObject.getEnum(Role.class, "role"))
+ .build();
+ }
+
+ public static AuthMember toAuthMember(PublicClaim publicClaim) {
+ return new AuthMember(publicClaim.id(), publicClaim.nickname(), publicClaim.role());
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java b/src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java
new file mode 100644
index 00000000..4ef9db71
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java
@@ -0,0 +1,43 @@
+package com.moabam.api.application.auth.mapper;
+
+import static java.util.Objects.*;
+
+import java.util.List;
+
+import org.springframework.http.HttpMethod;
+
+import com.moabam.api.domain.member.Role;
+import com.moabam.global.auth.handler.PathResolver;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class PathMapper {
+
+ public static PathResolver.Path parsePath(String uri) {
+ return parsePath(uri, null, null);
+ }
+
+ public static PathResolver.Path pathWithRole(String uri, List params) {
+ return parsePath(uri, params, null);
+ }
+
+ public static PathResolver.Path pathWithMethod(String uri, List params) {
+ return parsePath(uri, null, params);
+ }
+
+ private static PathResolver.Path parsePath(String uri, List roles, List methods) {
+ PathResolver.Path.PathBuilder pathBuilder = PathResolver.Path.builder().uri(uri);
+
+ if (nonNull(roles)) {
+ pathBuilder.roles(roles);
+ }
+
+ if (nonNull(methods)) {
+ pathBuilder.httpMethods(methods);
+ }
+
+ return pathBuilder.build();
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/bug/BugMapper.java b/src/main/java/com/moabam/api/application/bug/BugMapper.java
new file mode 100644
index 00000000..af91b206
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/bug/BugMapper.java
@@ -0,0 +1,83 @@
+package com.moabam.api.application.bug;
+
+import java.util.List;
+
+import com.moabam.api.application.payment.PaymentMapper;
+import com.moabam.api.domain.bug.Bug;
+import com.moabam.api.domain.bug.BugActionType;
+import com.moabam.api.domain.bug.BugHistory;
+import com.moabam.api.domain.bug.BugType;
+import com.moabam.api.dto.bug.BugHistoryItemResponse;
+import com.moabam.api.dto.bug.BugHistoryResponse;
+import com.moabam.api.dto.bug.BugHistoryWithPayment;
+import com.moabam.api.dto.bug.BugResponse;
+import com.moabam.global.common.util.DateUtils;
+import com.moabam.global.common.util.StreamUtils;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class BugMapper {
+
+ public static BugResponse toBugResponse(Bug bug) {
+ return BugResponse.builder()
+ .morningBug(bug.getMorningBug())
+ .nightBug(bug.getNightBug())
+ .goldenBug(bug.getGoldenBug())
+ .build();
+ }
+
+ public static BugHistoryItemResponse toBugHistoryItemResponse(BugHistoryWithPayment dto) {
+ return BugHistoryItemResponse.builder()
+ .id(dto.id())
+ .bugType(dto.bugType())
+ .actionType(dto.actionType())
+ .quantity(dto.quantity())
+ .date(DateUtils.format(dto.createdAt()))
+ .payment(PaymentMapper.toPaymentResponse(dto.payment()))
+ .build();
+ }
+
+ public static BugHistoryResponse toBugHistoryResponse(List dtoList) {
+ return BugHistoryResponse.builder()
+ .history(StreamUtils.map(dtoList, BugMapper::toBugHistoryItemResponse))
+ .build();
+ }
+
+ public static BugHistory toUseBugHistory(Long memberId, BugType bugType, int quantity) {
+ return BugHistory.builder()
+ .memberId(memberId)
+ .bugType(bugType)
+ .actionType(BugActionType.USE)
+ .quantity(quantity)
+ .build();
+ }
+
+ public static BugHistory toChargeBugHistory(Long memberId, int quantity) {
+ return BugHistory.builder()
+ .memberId(memberId)
+ .bugType(BugType.GOLDEN)
+ .actionType(BugActionType.CHARGE)
+ .quantity(quantity)
+ .build();
+ }
+
+ public static BugHistory toRewardBugHistory(Long memberId, BugType bugType, int quantity) {
+ return BugHistory.builder()
+ .memberId(memberId)
+ .bugType(bugType)
+ .actionType(BugActionType.REWARD)
+ .quantity(quantity)
+ .build();
+ }
+
+ public static BugHistory toCouponBugHistory(Long memberId, BugType bugType, int quantity) {
+ return BugHistory.builder()
+ .memberId(memberId)
+ .bugType(bugType)
+ .actionType(BugActionType.COUPON)
+ .quantity(quantity)
+ .build();
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java
new file mode 100644
index 00000000..f1246d45
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/bug/BugService.java
@@ -0,0 +1,139 @@
+package com.moabam.api.application.bug;
+
+import static com.moabam.api.domain.product.ProductType.*;
+import static com.moabam.global.error.model.ErrorMessage.*;
+import static java.util.Objects.*;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.moabam.api.application.member.MemberService;
+import com.moabam.api.application.payment.PaymentMapper;
+import com.moabam.api.application.product.ProductMapper;
+import com.moabam.api.domain.bug.Bug;
+import com.moabam.api.domain.bug.BugType;
+import com.moabam.api.domain.bug.repository.BugHistoryRepository;
+import com.moabam.api.domain.bug.repository.BugHistorySearchRepository;
+import com.moabam.api.domain.coupon.CouponWallet;
+import com.moabam.api.domain.coupon.repository.CouponWalletSearchRepository;
+import com.moabam.api.domain.member.Member;
+import com.moabam.api.domain.payment.Payment;
+import com.moabam.api.domain.payment.repository.PaymentRepository;
+import com.moabam.api.domain.product.Product;
+import com.moabam.api.domain.product.repository.ProductRepository;
+import com.moabam.api.dto.bug.BugHistoryResponse;
+import com.moabam.api.dto.bug.BugHistoryWithPayment;
+import com.moabam.api.dto.bug.BugResponse;
+import com.moabam.api.dto.product.ProductsResponse;
+import com.moabam.api.dto.product.PurchaseProductRequest;
+import com.moabam.api.dto.product.PurchaseProductResponse;
+import com.moabam.global.error.exception.NotFoundException;
+import com.moabam.global.error.model.ErrorMessage;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class BugService {
+
+ private final MemberService memberService;
+ private final BugHistoryRepository bugHistoryRepository;
+ private final BugHistorySearchRepository bugHistorySearchRepository;
+ private final ProductRepository productRepository;
+ private final PaymentRepository paymentRepository;
+ private final CouponWalletSearchRepository couponWalletSearchRepository;
+
+ public BugResponse getBug(Long memberId) {
+ Bug bug = getByMemberId(memberId);
+
+ return BugMapper.toBugResponse(bug);
+ }
+
+ public BugHistoryResponse getBugHistory(Long memberId) {
+ List history = bugHistorySearchRepository.findByMemberIdWithPayment(memberId);
+
+ return BugMapper.toBugHistoryResponse(history);
+ }
+
+ public ProductsResponse getBugProducts() {
+ List products = productRepository.findAllByType(BUG);
+
+ return ProductMapper.toProductsResponse(products);
+ }
+
+ @Transactional
+ public PurchaseProductResponse purchaseBugProduct(Long memberId, Long productId, PurchaseProductRequest request) {
+ Product product = getProductById(productId);
+ Payment payment = PaymentMapper.toPayment(memberId, product);
+
+ if (!isNull(request.couponWalletId())) {
+ CouponWallet couponWallet = getCouponWallet(request.couponWalletId(), memberId);
+ payment.applyCoupon(couponWallet);
+ }
+ paymentRepository.save(payment);
+
+ return ProductMapper.toPurchaseProductResponse(payment);
+ }
+
+ @Transactional
+ public void use(Member member, BugType bugType, int count) {
+ if (count == 0) {
+ return;
+ }
+
+ Bug bug = member.getBug();
+
+ bug.use(bugType, count);
+ bugHistoryRepository.save(BugMapper.toUseBugHistory(member.getId(), bugType, count));
+ }
+
+ @Transactional
+ public void reward(Member member, BugType bugType, int count) {
+ if (count == 0) {
+ return;
+ }
+
+ Bug bug = member.getBug();
+
+ bug.increase(bugType, count);
+ bugHistoryRepository.save(BugMapper.toRewardBugHistory(member.getId(), bugType, count));
+ }
+
+ @Transactional
+ public void charge(Long memberId, Product bugProduct) {
+ Bug bug = getByMemberId(memberId);
+
+ bug.charge(bugProduct.getQuantity());
+ bugHistoryRepository.save(BugMapper.toChargeBugHistory(memberId, bugProduct.getQuantity()));
+ }
+
+ @Transactional
+ public void applyCoupon(Long memberId, BugType bugType, int count) {
+ if (count == 0) {
+ return;
+ }
+
+ Bug bug = getByMemberId(memberId);
+
+ bug.increase(bugType, count);
+ bugHistoryRepository.save(BugMapper.toCouponBugHistory(memberId, bugType, count));
+ }
+
+ private Bug getByMemberId(Long memberId) {
+ return memberService.findMember(memberId)
+ .getBug();
+ }
+
+ private Product getProductById(Long productId) {
+ return productRepository.findById(productId)
+ .orElseThrow(() -> new NotFoundException(PRODUCT_NOT_FOUND));
+ }
+
+ private CouponWallet getCouponWallet(Long couponWalletId, Long memberId) {
+ return couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId)
+ .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET));
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java
new file mode 100644
index 00000000..3cda6f9a
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java
@@ -0,0 +1,101 @@
+package com.moabam.api.application.coupon;
+
+import java.time.LocalDate;
+import java.util.Optional;
+import java.util.Set;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import com.moabam.api.application.notification.NotificationService;
+import com.moabam.api.domain.coupon.Coupon;
+import com.moabam.api.domain.coupon.CouponWallet;
+import com.moabam.api.domain.coupon.repository.CouponManageRepository;
+import com.moabam.api.domain.coupon.repository.CouponRepository;
+import com.moabam.api.domain.coupon.repository.CouponWalletRepository;
+import com.moabam.global.common.util.ClockHolder;
+import com.moabam.global.error.exception.BadRequestException;
+import com.moabam.global.error.exception.ConflictException;
+import com.moabam.global.error.exception.NotFoundException;
+import com.moabam.global.error.model.ErrorMessage;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CouponManageService {
+
+ private static final String SUCCESS_ISSUE_BODY = "%s 쿠폰 발행을 성공했습니다. 축하드립니다!";
+ private static final String FAIL_ISSUE_BODY = "%s 쿠폰 발행을 실패했습니다. 다음 기회에!";
+ private static final long ISSUE_SIZE = 10;
+
+ private final ClockHolder clockHolder;
+ private final NotificationService notificationService;
+
+ private final CouponRepository couponRepository;
+ private final CouponManageRepository couponManageRepository;
+ private final CouponWalletRepository couponWalletRepository;
+
+ @Scheduled(fixedDelay = 1000)
+ public void issue() {
+ LocalDate now = clockHolder.date();
+ Optional optionalCoupon = couponRepository.findByStartAt(now);
+
+ if (optionalCoupon.isEmpty()) {
+ return;
+ }
+
+ Coupon coupon = optionalCoupon.get();
+ String couponName = coupon.getName();
+ int maxCount = coupon.getMaxCount();
+ int currentCount = couponManageRepository.getCount(couponName);
+
+ if (maxCount <= currentCount) {
+ return;
+ }
+
+ Set membersId = couponManageRepository.rangeQueue(couponName, currentCount, currentCount + ISSUE_SIZE);
+
+ if (membersId.isEmpty()) {
+ return;
+ }
+
+ for (Long memberId : membersId) {
+ couponWalletRepository.save(CouponWallet.create(memberId, coupon));
+ notificationService.sendCouponIssueResult(memberId, couponName, SUCCESS_ISSUE_BODY);
+ }
+
+ couponManageRepository.increase(couponName, membersId.size());
+ }
+
+ public void registerQueue(String couponName, Long memberId) {
+ double registerTime = System.currentTimeMillis();
+ validateRegisterQueue(couponName, memberId);
+ couponManageRepository.addIfAbsentQueue(couponName, memberId, registerTime);
+ }
+
+ public void delete(String couponName) {
+ couponManageRepository.deleteQueue(couponName);
+ couponManageRepository.deleteCount(couponName);
+ }
+
+ private void validateRegisterQueue(String couponName, Long memberId) {
+ LocalDate now = clockHolder.date();
+ Coupon coupon = couponRepository.findByNameAndStartAt(couponName, now)
+ .orElseThrow(() -> new NotFoundException(ErrorMessage.INVALID_COUPON_PERIOD));
+
+ if (couponManageRepository.hasValue(couponName, memberId)) {
+ throw new ConflictException(ErrorMessage.CONFLICT_COUPON_ISSUE);
+ }
+
+ int maxCount = coupon.getMaxCount();
+ int sizeQueue = couponManageRepository.sizeQueue(couponName);
+
+ if (maxCount <= sizeQueue) {
+ notificationService.sendCouponIssueResult(memberId, couponName, FAIL_ISSUE_BODY);
+ throw new BadRequestException(ErrorMessage.INVALID_COUPON_STOCK_END);
+ }
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java
new file mode 100644
index 00000000..a3cbe1c3
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java
@@ -0,0 +1,67 @@
+package com.moabam.api.application.coupon;
+
+import java.util.List;
+
+import com.moabam.api.domain.coupon.Coupon;
+import com.moabam.api.domain.coupon.CouponType;
+import com.moabam.api.domain.coupon.CouponWallet;
+import com.moabam.api.dto.coupon.CouponResponse;
+import com.moabam.api.dto.coupon.CreateCouponRequest;
+import com.moabam.api.dto.coupon.MyCouponResponse;
+import com.moabam.global.common.util.StreamUtils;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class CouponMapper {
+
+ public static Coupon toEntity(Long adminId, CreateCouponRequest coupon) {
+ return Coupon.builder()
+ .name(coupon.name())
+ .description(coupon.description())
+ .type(CouponType.from(coupon.type()))
+ .point(coupon.point())
+ .maxCount(coupon.maxCount())
+ .startAt(coupon.startAt())
+ .openAt(coupon.openAt())
+ .adminId(adminId)
+ .build();
+ }
+
+ // TODO : Admin Table 생성 시, 관리자 명 추가할 예정
+ public static CouponResponse toResponse(Coupon coupon) {
+ return CouponResponse.builder()
+ .id(coupon.getId())
+ .adminId(coupon.getAdminId())
+ .name(coupon.getName())
+ .description(coupon.getDescription())
+ .point(coupon.getPoint())
+ .maxCount(coupon.getMaxCount())
+ .type(coupon.getType())
+ .startAt(coupon.getStartAt())
+ .openAt(coupon.getOpenAt())
+ .build();
+ }
+
+ public static List toResponses(List coupons) {
+ return StreamUtils.map(coupons, CouponMapper::toResponse);
+ }
+
+ public static MyCouponResponse toMyResponse(CouponWallet couponWallet) {
+ Coupon coupon = couponWallet.getCoupon();
+
+ return MyCouponResponse.builder()
+ .walletId(couponWallet.getId())
+ .id(coupon.getId())
+ .name(coupon.getName())
+ .description(coupon.getDescription())
+ .point(coupon.getPoint())
+ .type(coupon.getType())
+ .build();
+ }
+
+ public static List toMyResponses(List couponWallets) {
+ return StreamUtils.map(couponWallets, CouponMapper::toMyResponse);
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java
new file mode 100644
index 00000000..00ec0fa3
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java
@@ -0,0 +1,147 @@
+package com.moabam.api.application.coupon;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.moabam.admin.application.admin.AdminService;
+import com.moabam.api.application.bug.BugService;
+import com.moabam.api.domain.bug.BugType;
+import com.moabam.api.domain.coupon.Coupon;
+import com.moabam.api.domain.coupon.CouponWallet;
+import com.moabam.api.domain.coupon.repository.CouponRepository;
+import com.moabam.api.domain.coupon.repository.CouponSearchRepository;
+import com.moabam.api.domain.coupon.repository.CouponWalletRepository;
+import com.moabam.api.domain.coupon.repository.CouponWalletSearchRepository;
+import com.moabam.api.domain.member.Role;
+import com.moabam.api.dto.coupon.CouponResponse;
+import com.moabam.api.dto.coupon.CouponStatusRequest;
+import com.moabam.api.dto.coupon.CreateCouponRequest;
+import com.moabam.api.dto.coupon.MyCouponResponse;
+import com.moabam.global.common.util.ClockHolder;
+import com.moabam.global.error.exception.BadRequestException;
+import com.moabam.global.error.exception.ConflictException;
+import com.moabam.global.error.exception.NotFoundException;
+import com.moabam.global.error.model.ErrorMessage;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class CouponService {
+
+ private final ClockHolder clockHolder;
+ private final BugService bugService;
+ private final AdminService adminService;
+ private final CouponManageService couponManageService;
+
+ private final CouponRepository couponRepository;
+ private final CouponSearchRepository couponSearchRepository;
+ private final CouponWalletRepository couponWalletRepository;
+ private final CouponWalletSearchRepository couponWalletSearchRepository;
+
+ @Transactional
+ public void create(CreateCouponRequest request, Long adminId, Role role) {
+ validateAdminRole(role);
+ validateConflictName(request.name());
+ validateConflictStartAt(request.startAt());
+ validatePeriod(request.startAt(), request.openAt());
+
+ Coupon coupon = CouponMapper.toEntity(adminId, request);
+
+ couponRepository.save(coupon);
+ }
+
+ @Transactional
+ public void delete(Long couponId, Role role) {
+ validateAdminRole(role);
+
+ Coupon coupon = couponRepository.findById(couponId)
+ .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON));
+
+ couponRepository.delete(coupon);
+ couponManageService.delete(coupon.getName());
+ }
+
+ @Transactional
+ public void use(Long couponWalletId, Long memberId) {
+ CouponWallet couponWallet = couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId)
+ .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET));
+ Coupon coupon = couponWallet.getCoupon();
+ BugType bugType = coupon.getType().getBugType();
+
+ if (coupon.getType().isDiscount()) {
+ throw new BadRequestException(ErrorMessage.INVALID_DISCOUNT_COUPON);
+ }
+
+ bugService.applyCoupon(memberId, bugType, coupon.getPoint());
+ couponWalletRepository.delete(couponWallet);
+ }
+
+ @Transactional
+ public void discount(Long couponWalletId, Long memberId) {
+ CouponWallet couponWallet = couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId)
+ .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET));
+ Coupon coupon = couponWallet.getCoupon();
+
+ if (!coupon.getType().isDiscount()) {
+ throw new BadRequestException(ErrorMessage.INVALID_BUG_COUPON);
+ }
+
+ couponWalletRepository.delete(couponWallet);
+ }
+
+ public CouponResponse getById(Long couponId) {
+ Coupon coupon = couponRepository.findById(couponId)
+ .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON));
+
+ return CouponMapper.toResponse(coupon);
+ }
+
+ public List getAllByStatus(CouponStatusRequest request) {
+ LocalDate now = clockHolder.date();
+ List coupons = couponSearchRepository.findAllByStatus(now, request);
+
+ return CouponMapper.toResponses(coupons);
+ }
+
+ public List getAllByWalletIdAndMemberId(Long couponWalletId, Long memberId) {
+ List couponWallets =
+ couponWalletSearchRepository.findAllByIdAndMemberId(couponWalletId, memberId);
+
+ return CouponMapper.toMyResponses(couponWallets);
+ }
+
+ private void validatePeriod(LocalDate startAt, LocalDate openAt) {
+ LocalDate now = clockHolder.date();
+
+ if (!now.isBefore(startAt)) {
+ throw new BadRequestException(ErrorMessage.INVALID_COUPON_START_AT_PERIOD);
+ }
+
+ if (!openAt.isBefore(startAt)) {
+ throw new BadRequestException(ErrorMessage.INVALID_COUPON_OPEN_AT_PERIOD);
+ }
+ }
+
+ private void validateAdminRole(Role role) {
+ if (!role.equals(Role.ADMIN)) {
+ throw new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND);
+ }
+ }
+
+ private void validateConflictName(String couponName) {
+ if (couponRepository.existsByName(couponName)) {
+ throw new ConflictException(ErrorMessage.CONFLICT_COUPON_NAME);
+ }
+ }
+
+ private void validateConflictStartAt(LocalDate startAt) {
+ if (couponRepository.existsByStartAt(startAt)) {
+ throw new ConflictException(ErrorMessage.CONFLICT_COUPON_START_AT);
+ }
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/image/ImageService.java b/src/main/java/com/moabam/api/application/image/ImageService.java
new file mode 100644
index 00000000..a70a783f
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/image/ImageService.java
@@ -0,0 +1,73 @@
+package com.moabam.api.application.image;
+
+import static com.moabam.global.error.model.ErrorMessage.IMAGE_CONVERT_FAIL;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import com.moabam.api.domain.image.ImageName;
+import com.moabam.api.domain.image.ImageResizer;
+import com.moabam.api.domain.image.ImageType;
+import com.moabam.api.domain.image.NewImage;
+import com.moabam.api.dto.room.CertifyRoomsRequest;
+import com.moabam.api.infrastructure.s3.S3Manager;
+import com.moabam.global.error.exception.BadRequestException;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class ImageService {
+
+ private final S3Manager s3Manager;
+
+ @Transactional
+ public List uploadImages(List extends MultipartFile> multipartFiles, ImageType imageType) {
+
+ List result = new ArrayList<>();
+
+ List imageResizers = multipartFiles.stream()
+ .map(multipartFile -> this.toImageResizer(multipartFile, imageType))
+ .toList();
+
+ imageResizers.forEach(resizer -> {
+ resizer.resizeImageToFixedSize(imageType);
+ result.add(s3Manager.uploadImage(resizer.getResizedImage().getName(), resizer.getResizedImage()));
+ });
+
+ return result;
+ }
+
+ public List getNewImages(CertifyRoomsRequest request) {
+ return request.getCertifyRoomsRequest().stream()
+ .map(certifyRoomRequest -> {
+ try {
+ return NewImage.of(String.valueOf(certifyRoomRequest.getRoutineId()),
+ certifyRoomRequest.getImage().getContentType(), certifyRoomRequest.getImage().getBytes());
+ } catch (IOException e) {
+ throw new BadRequestException(IMAGE_CONVERT_FAIL);
+ }
+ })
+ .toList();
+ }
+
+ @Transactional
+ public void deleteImage(String imageUrl) {
+ s3Manager.deleteImage(imageUrl);
+ }
+
+ private ImageResizer toImageResizer(MultipartFile multipartFile, ImageType imageType) {
+ ImageName imageName = ImageName.of(multipartFile, imageType);
+
+ return ImageResizer.builder()
+ .image(multipartFile)
+ .fileName(imageName.getFileName())
+ .build();
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/item/ItemMapper.java b/src/main/java/com/moabam/api/application/item/ItemMapper.java
new file mode 100644
index 00000000..f2cabfff
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/item/ItemMapper.java
@@ -0,0 +1,44 @@
+package com.moabam.api.application.item;
+
+import java.util.List;
+
+import com.moabam.api.domain.item.Inventory;
+import com.moabam.api.domain.item.Item;
+import com.moabam.api.dto.item.ItemResponse;
+import com.moabam.api.dto.item.ItemsResponse;
+import com.moabam.global.common.util.StreamUtils;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class ItemMapper {
+
+ public static ItemResponse toItemResponse(Item item) {
+ return ItemResponse.builder()
+ .id(item.getId())
+ .type(item.getType().name())
+ .category(item.getCategory().name())
+ .name(item.getName())
+ .image(item.getAwakeImage())
+ .level(item.getUnlockLevel())
+ .bugPrice(item.getBugPrice())
+ .goldenBugPrice(item.getGoldenBugPrice())
+ .build();
+ }
+
+ public static ItemsResponse toItemsResponse(Long itemId, List- purchasedItems, List
- notPurchasedItems) {
+ return ItemsResponse.builder()
+ .defaultItemId(itemId)
+ .purchasedItems(StreamUtils.map(purchasedItems, ItemMapper::toItemResponse))
+ .notPurchasedItems(StreamUtils.map(notPurchasedItems, ItemMapper::toItemResponse))
+ .build();
+ }
+
+ public static Inventory toInventory(Long memberId, Item item) {
+ return Inventory.builder()
+ .memberId(memberId)
+ .item(item)
+ .build();
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/item/ItemService.java b/src/main/java/com/moabam/api/application/item/ItemService.java
new file mode 100644
index 00000000..b0f7f0f9
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/item/ItemService.java
@@ -0,0 +1,92 @@
+package com.moabam.api.application.item;
+
+import static com.moabam.global.error.model.ErrorMessage.*;
+
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.moabam.api.application.bug.BugService;
+import com.moabam.api.application.member.MemberService;
+import com.moabam.api.domain.item.Inventory;
+import com.moabam.api.domain.item.Item;
+import com.moabam.api.domain.item.ItemType;
+import com.moabam.api.domain.item.repository.InventoryRepository;
+import com.moabam.api.domain.item.repository.InventorySearchRepository;
+import com.moabam.api.domain.item.repository.ItemRepository;
+import com.moabam.api.domain.item.repository.ItemSearchRepository;
+import com.moabam.api.domain.member.Member;
+import com.moabam.api.dto.item.ItemsResponse;
+import com.moabam.api.dto.item.PurchaseItemRequest;
+import com.moabam.global.error.exception.ConflictException;
+import com.moabam.global.error.exception.NotFoundException;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class ItemService {
+
+ private final MemberService memberService;
+ private final BugService bugService;
+ private final ItemRepository itemRepository;
+ private final ItemSearchRepository itemSearchRepository;
+ private final InventoryRepository inventoryRepository;
+ private final InventorySearchRepository inventorySearchRepository;
+
+ public ItemsResponse getItems(Long memberId, ItemType type) {
+ Item defaultItem = getDefaultInventory(memberId, type).getItem();
+ List
- purchasedItems = inventorySearchRepository.findItems(memberId, type);
+ List
- notPurchasedItems = itemSearchRepository.findNotPurchasedItems(memberId, type);
+
+ return ItemMapper.toItemsResponse(defaultItem.getId(), purchasedItems, notPurchasedItems);
+ }
+
+ @Transactional
+ public void purchaseItem(Long memberId, Long itemId, PurchaseItemRequest request) {
+ Item item = getItem(itemId);
+ Member member = memberService.findMember(memberId);
+
+ validateAlreadyPurchased(memberId, itemId);
+ item.validatePurchasable(request.bugType(), member.getLevel());
+
+ int price = item.getPrice(request.bugType());
+
+ bugService.use(member, request.bugType(), price);
+ inventoryRepository.save(ItemMapper.toInventory(memberId, item));
+ }
+
+ @Transactional
+ public void selectItem(Long memberId, Long itemId) {
+ Member member = memberService.findMember(memberId);
+ Inventory inventory = getInventory(memberId, itemId);
+
+ inventorySearchRepository.findDefault(memberId, inventory.getItemType())
+ .ifPresent(Inventory::deselect);
+ inventory.select(member);
+ }
+
+ private Item getItem(Long itemId) {
+ return itemRepository.findById(itemId)
+ .orElseThrow(() -> new NotFoundException(ITEM_NOT_FOUND));
+ }
+
+ private Inventory getInventory(Long memberId, Long itemId) {
+ return inventorySearchRepository.findOne(memberId, itemId)
+ .orElseThrow(() -> new NotFoundException(INVENTORY_NOT_FOUND));
+ }
+
+ private Inventory getDefaultInventory(Long memberId, ItemType type) {
+ return inventorySearchRepository.findDefault(memberId, type)
+ .orElseThrow(() -> new NotFoundException(DEFAULT_INVENTORY_NOT_FOUND));
+ }
+
+ private void validateAlreadyPurchased(Long memberId, Long itemId) {
+ inventorySearchRepository.findOne(memberId, itemId)
+ .ifPresent(inventory -> {
+ throw new ConflictException(INVENTORY_CONFLICT);
+ });
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/member/BadgeService.java b/src/main/java/com/moabam/api/application/member/BadgeService.java
new file mode 100644
index 00000000..e89dc5dd
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/member/BadgeService.java
@@ -0,0 +1,32 @@
+package com.moabam.api.application.member;
+
+import java.util.Optional;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.moabam.api.domain.member.Badge;
+import com.moabam.api.domain.member.BadgeType;
+import com.moabam.api.domain.member.repository.BadgeRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class BadgeService {
+
+ private final BadgeRepository badgeRepository;
+
+ public void createBadge(Long memberId, long certifyCount) {
+ Optional badgeType = BadgeType.getBadgeFrom(certifyCount);
+
+ if (badgeType.isEmpty()
+ || badgeRepository.existsByMemberIdAndType(memberId, badgeType.get())) {
+ return;
+ }
+
+ Badge badge = MemberMapper.toBadge(memberId, badgeType.get());
+ badgeRepository.save(badge);
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/member/MemberMapper.java b/src/main/java/com/moabam/api/application/member/MemberMapper.java
new file mode 100644
index 00000000..7d80c808
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/member/MemberMapper.java
@@ -0,0 +1,118 @@
+package com.moabam.api.application.member;
+
+import static com.moabam.global.common.util.GlobalConstant.*;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import com.moabam.api.domain.bug.Bug;
+import com.moabam.api.domain.item.Inventory;
+import com.moabam.api.domain.item.Item;
+import com.moabam.api.domain.item.ItemType;
+import com.moabam.api.domain.member.Badge;
+import com.moabam.api.domain.member.BadgeType;
+import com.moabam.api.domain.member.Member;
+import com.moabam.api.dto.member.BadgeResponse;
+import com.moabam.api.dto.member.MemberInfo;
+import com.moabam.api.dto.member.MemberInfoResponse;
+import com.moabam.api.dto.member.MemberInfoSearchResponse;
+import com.moabam.api.dto.ranking.RankingInfo;
+import com.moabam.api.dto.ranking.UpdateRanking;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class MemberMapper {
+
+ public static Member toMember(Long socialId) {
+ return Member.builder()
+ .socialId(String.valueOf(socialId))
+ .bug(Bug.builder().build())
+ .build();
+ }
+
+ public static UpdateRanking toUpdateRanking(Member member) {
+ return UpdateRanking.builder()
+ .rankingInfo(toRankingInfo(member))
+ .score(member.getTotalCertifyCount())
+ .build();
+ }
+
+ public static MemberInfoSearchResponse toMemberInfoSearchResponse(List memberInfos) {
+ MemberInfo infos = memberInfos.get(0);
+ List badgeTypes = memberInfos.stream()
+ .map(MemberInfo::badges)
+ .filter(Objects::nonNull)
+ .toList();
+
+ return MemberInfoSearchResponse.builder()
+ .nickname(infos.nickname())
+ .profileImage(infos.profileImage())
+ .morningImage(infos.morningImage())
+ .nightImage(infos.nightImage())
+ .intro(infos.intro())
+ .totalCertifyCount(infos.totalCertifyCount())
+ .badges(new HashSet<>(badgeTypes))
+ .goldenBug(infos.goldenBug())
+ .morningBug(infos.morningBug())
+ .nightBug(infos.nightBug())
+ .build();
+ }
+
+ public static MemberInfoResponse toMemberInfoResponse(MemberInfoSearchResponse memberInfoSearchResponse) {
+ long certifyCount = memberInfoSearchResponse.totalCertifyCount();
+
+ return MemberInfoResponse.builder()
+ .nickname(memberInfoSearchResponse.nickname())
+ .profileImage(memberInfoSearchResponse.profileImage())
+ .intro(memberInfoSearchResponse.intro())
+ .level(certifyCount / LEVEL_DIVISOR)
+ .exp(certifyCount % LEVEL_DIVISOR)
+ .birds(defaultSkins(memberInfoSearchResponse.morningImage(), memberInfoSearchResponse.nightImage()))
+ .badges(badgedNames(memberInfoSearchResponse.badges()))
+ .goldenBug(memberInfoSearchResponse.goldenBug())
+ .morningBug(memberInfoSearchResponse.morningBug())
+ .nightBug(memberInfoSearchResponse.nightBug())
+ .build();
+ }
+
+ public static Inventory toInventory(Long memberId, Item item) {
+ return Inventory.builder()
+ .memberId(memberId)
+ .item(item)
+ .isDefault(true)
+ .build();
+ }
+
+ public static RankingInfo toRankingInfo(Member member) {
+ return RankingInfo.builder()
+ .memberId(member.getId())
+ .nickname(member.getNickname())
+ .image(member.getProfileImage())
+ .build();
+ }
+
+ public static Badge toBadge(Long memberId, BadgeType badgeType) {
+ return Badge.builder()
+ .type(badgeType)
+ .memberId(memberId)
+ .build();
+ }
+
+ private static List badgedNames(Set badgeTypes) {
+ return BadgeType.memberBadgeMap(badgeTypes);
+ }
+
+ private static Map defaultSkins(String morningImage, String nightImage) {
+ Map birdsSkin = new HashMap<>();
+ birdsSkin.put(ItemType.MORNING.name(), morningImage);
+ birdsSkin.put(ItemType.NIGHT.name(), nightImage);
+
+ return birdsSkin;
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java
new file mode 100644
index 00000000..ef3a761c
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/member/MemberService.java
@@ -0,0 +1,206 @@
+package com.moabam.api.application.member;
+
+import static com.moabam.global.error.model.ErrorMessage.*;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.moabam.api.application.auth.mapper.AuthMapper;
+import com.moabam.api.application.ranking.RankingService;
+import com.moabam.api.domain.item.Inventory;
+import com.moabam.api.domain.item.Item;
+import com.moabam.api.domain.item.repository.InventoryRepository;
+import com.moabam.api.domain.item.repository.ItemRepository;
+import com.moabam.api.domain.member.Member;
+import com.moabam.api.domain.member.repository.MemberRepository;
+import com.moabam.api.domain.member.repository.MemberSearchRepository;
+import com.moabam.api.domain.room.Participant;
+import com.moabam.api.domain.room.repository.ParticipantRepository;
+import com.moabam.api.domain.room.repository.ParticipantSearchRepository;
+import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse;
+import com.moabam.api.dto.auth.LoginResponse;
+import com.moabam.api.dto.member.MemberInfo;
+import com.moabam.api.dto.member.MemberInfoResponse;
+import com.moabam.api.dto.member.MemberInfoSearchResponse;
+import com.moabam.api.dto.member.ModifyMemberRequest;
+import com.moabam.api.dto.ranking.RankingInfo;
+import com.moabam.api.dto.ranking.UpdateRanking;
+import com.moabam.api.infrastructure.fcm.FcmService;
+import com.moabam.global.auth.model.AuthMember;
+import com.moabam.global.common.util.BaseDataCode;
+import com.moabam.global.common.util.ClockHolder;
+import com.moabam.global.error.exception.BadRequestException;
+import com.moabam.global.error.exception.ConflictException;
+import com.moabam.global.error.exception.NotFoundException;
+
+import io.micrometer.common.util.StringUtils;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class MemberService {
+
+ private final RankingService rankingService;
+ private final FcmService fcmService;
+ private final MemberRepository memberRepository;
+ private final InventoryRepository inventoryRepository;
+ private final ItemRepository itemRepository;
+ private final MemberSearchRepository memberSearchRepository;
+ private final ParticipantSearchRepository participantSearchRepository;
+ private final ParticipantRepository participantRepository;
+ private final ClockHolder clockHolder;
+
+ public Member findMember(Long memberId) {
+ return memberSearchRepository.findMember(memberId)
+ .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND));
+ }
+
+ @Transactional
+ public LoginResponse login(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) {
+ Optional member = memberRepository.findBySocialId(String.valueOf(authorizationTokenInfoResponse.id()));
+ Member loginMember = member.orElseGet(() -> signUp(authorizationTokenInfoResponse.id()));
+
+ return AuthMapper.toLoginResponse(loginMember, member.isEmpty());
+ }
+
+ public List getRoomMembers(List memberIds) {
+ return memberRepository.findAllById(memberIds);
+ }
+
+ public void validateMemberToDelete(Long memberId) {
+ List participants = memberSearchRepository.findParticipantByMemberId(memberId);
+
+ if (!participants.isEmpty()) {
+ throw new NotFoundException(MEMBER_NOT_FOUND);
+ }
+ }
+
+ @Transactional
+ public void delete(Member member) {
+ List participants = participantSearchRepository.findAllByMemberIdParticipant(member.getId());
+
+ if (!participants.isEmpty()) {
+ throw new BadRequestException(NEED_TO_EXIT_ALL_ROOMS);
+ }
+
+ member.delete(clockHolder.times());
+ memberRepository.flush();
+ memberRepository.delete(member);
+ rankingService.removeRanking(MemberMapper.toRankingInfo(member));
+ fcmService.deleteTokenByMemberId(member.getId());
+ }
+
+ public MemberInfoResponse searchInfo(AuthMember authMember, Long memberId) {
+ Long searchId = authMember.id();
+ boolean isMe = confirmMe(searchId, memberId);
+
+ if (!isMe) {
+ searchId = memberId;
+ }
+ MemberInfoSearchResponse memberInfoSearchResponse = findMemberInfo(searchId, isMe);
+
+ return MemberMapper.toMemberInfoResponse(memberInfoSearchResponse);
+ }
+
+ @Transactional
+ public void modifyInfo(AuthMember authMember, ModifyMemberRequest modifyMemberRequest, String newProfileUri) {
+ validateNickname(modifyMemberRequest.nickname());
+ Member member = memberSearchRepository.findMember(authMember.id())
+ .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND));
+
+ RankingInfo beforeInfo = MemberMapper.toRankingInfo(member);
+ member.changeNickName(modifyMemberRequest.nickname());
+
+ boolean nickNameChanged = member.changeNickName(modifyMemberRequest.nickname());
+ member.changeIntro(modifyMemberRequest.intro());
+ member.changeProfileUri(newProfileUri);
+ memberRepository.save(member);
+
+ RankingInfo afterInfo = MemberMapper.toRankingInfo(member);
+ rankingService.changeInfos(beforeInfo, afterInfo);
+
+ if (nickNameChanged) {
+ changeNickname(authMember.id(), modifyMemberRequest.nickname());
+ }
+ }
+
+ public UpdateRanking getRankingInfo(AuthMember authMember) {
+ Member member = findMember(authMember.id());
+
+ return MemberMapper.toUpdateRanking(member);
+ }
+
+ @Scheduled(cron = "0 11 * * * *")
+ public void updateAllRanking() {
+ List members = memberSearchRepository.findAllMembers();
+ List updateRankings = members.stream()
+ .map(MemberMapper::toUpdateRanking)
+ .toList();
+
+ rankingService.updateScores(updateRankings);
+ }
+
+ private void changeNickname(Long memberId, String changedName) {
+ List participants = participantSearchRepository.findAllRoomMangerByMemberId(memberId);
+
+ for (Participant participant : participants) {
+ participant.getRoom().changeManagerNickname(changedName);
+ }
+ }
+
+ private void validateNickname(String nickname) {
+ if (Objects.isNull(nickname)) {
+ return;
+ }
+ if (StringUtils.isEmpty(nickname) && memberRepository.existsByNickname(nickname)) {
+ throw new ConflictException(NICKNAME_CONFLICT);
+ }
+ }
+
+ private Member signUp(Long socialId) {
+ Member member = MemberMapper.toMember(socialId);
+ Member savedMember = memberRepository.save(member);
+ saveMyEgg(savedMember);
+ rankingService.addRanking(MemberMapper.toRankingInfo(member), member.getTotalCertifyCount());
+
+ return savedMember;
+ }
+
+ private void saveMyEgg(Member member) {
+ List
- items = getBasicEggs();
+ List inventories = items.stream()
+ .map(item -> MemberMapper.toInventory(member.getId(), item))
+ .toList();
+ inventoryRepository.saveAll(inventories);
+ }
+
+ private List
- getBasicEggs() {
+ List
- items = itemRepository.findAllById(List.of(BaseDataCode.MORNING_EGG, BaseDataCode.NIGHT_EGG));
+
+ if (items.isEmpty()) {
+ throw new BadRequestException(BASIC_SKIN_NOT_FOUND);
+ }
+
+ return items;
+ }
+
+ private MemberInfoSearchResponse findMemberInfo(Long searchId, boolean isMe) {
+ List memberInfos = memberSearchRepository.findMemberAndBadges(searchId, isMe);
+
+ if (memberInfos.isEmpty()) {
+ throw new BadRequestException(MEMBER_NOT_FOUND);
+ }
+
+ return MemberMapper.toMemberInfoSearchResponse(memberInfos);
+ }
+
+ private boolean confirmMe(Long myId, Long memberId) {
+ return Objects.isNull(memberId) || myId.equals(memberId);
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java
new file mode 100644
index 00000000..3d8c859b
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java
@@ -0,0 +1,99 @@
+package com.moabam.api.application.notification;
+
+import static com.moabam.global.common.util.GlobalConstant.*;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.moabam.api.application.member.MemberService;
+import com.moabam.api.application.room.RoomService;
+import com.moabam.api.domain.notification.repository.NotificationRepository;
+import com.moabam.api.domain.room.Participant;
+import com.moabam.api.domain.room.repository.ParticipantSearchRepository;
+import com.moabam.api.infrastructure.fcm.FcmService;
+import com.moabam.global.common.util.ClockHolder;
+import com.moabam.global.error.exception.ConflictException;
+import com.moabam.global.error.exception.NotFoundException;
+import com.moabam.global.error.model.ErrorMessage;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class NotificationService {
+
+ private static final String COMMON_TITLE = "모아밤";
+ private static final String KNOCK_BODY = "[%s] - [%s]님이 콕콕콕!";
+ private static final String CERTIFY_TIME_BODY = "[%s] - 5분 후 인증 시간입니다!";
+
+ private final ClockHolder clockHolder;
+ private final FcmService fcmService;
+ private final RoomService roomService;
+ private final MemberService memberService;
+
+ private final NotificationRepository notificationRepository;
+ private final ParticipantSearchRepository participantSearchRepository;
+
+ @Transactional
+ public void sendKnock(Long roomId, Long targetId, Long memberId) {
+ validateConflictKnock(roomId, targetId, memberId);
+ String fcmToken = fcmService.findTokenByMemberId(targetId)
+ .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_FCM_TOKEN));
+
+ String roomTitle = roomService.findRoom(roomId).getTitle();
+ String memberNickname = memberService.findMember(memberId).getNickname();
+ String notificationTitle = roomId.toString();
+
+ String notificationBody = String.format(KNOCK_BODY, roomTitle, memberNickname);
+ fcmService.sendAsync(fcmToken, notificationTitle, notificationBody);
+ notificationRepository.saveKnock(roomId, targetId, memberId);
+ }
+
+ public void sendCouponIssueResult(Long memberId, String couponName, String body) {
+ String fcmToken = fcmService.findTokenByMemberId(memberId).orElse(null);
+ String notificationBody = String.format(body, couponName);
+ fcmService.sendAsync(fcmToken, COMMON_TITLE, notificationBody);
+ }
+
+ @Scheduled(cron = "0 55 * * * *")
+ public void sendCertificationTime() {
+ int certificationTime = (clockHolder.times().getHour() + ONE_HOUR) % HOURS_IN_A_DAY;
+ List participants = participantSearchRepository.findAllByRoomCertifyTime(certificationTime);
+
+ participants.parallelStream().forEach(participant -> {
+ String roomTitle = participant.getRoom().getTitle();
+ String notificationTitle = participant.getRoom().getId().toString();
+ String notificationBody = String.format(CERTIFY_TIME_BODY, roomTitle);
+ String fcmToken = fcmService.findTokenByMemberId(participant.getMemberId()).orElse(null);
+ fcmService.sendAsync(fcmToken, notificationTitle, notificationBody);
+ });
+ }
+
+ public List getMyKnockStatusInRoom(Long memberId, Long roomId, List participants) {
+ List filteredParticipants = participants.stream()
+ .filter(participant -> !participant.getMemberId().equals(memberId))
+ .toList();
+
+ Predicate knockPredicate = targetId ->
+ notificationRepository.existsKnockByKey(roomId, targetId, memberId);
+
+ Map> knockStatus = filteredParticipants.stream()
+ .map(Participant::getMemberId)
+ .collect(Collectors.partitioningBy(knockPredicate));
+
+ return knockStatus.get(true);
+ }
+
+ private void validateConflictKnock(Long roomId, Long targetId, Long memberId) {
+ if (notificationRepository.existsKnockByKey(roomId, targetId, memberId)) {
+ throw new ConflictException(ErrorMessage.CONFLICT_KNOCK);
+ }
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/payment/PaymentMapper.java b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java
new file mode 100644
index 00000000..e98b64eb
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java
@@ -0,0 +1,49 @@
+package com.moabam.api.application.payment;
+
+import java.util.Optional;
+
+import com.moabam.api.domain.payment.Order;
+import com.moabam.api.domain.payment.Payment;
+import com.moabam.api.domain.product.Product;
+import com.moabam.api.dto.payment.ConfirmTossPaymentResponse;
+import com.moabam.api.dto.payment.PaymentResponse;
+import com.moabam.api.dto.payment.RequestConfirmPaymentResponse;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class PaymentMapper {
+
+ public static Payment toPayment(Long memberId, Product product) {
+ Order order = Order.builder()
+ .name(product.getName())
+ .build();
+
+ return Payment.builder()
+ .memberId(memberId)
+ .product(product)
+ .order(order)
+ .totalAmount(product.getPrice())
+ .build();
+ }
+
+ public static PaymentResponse toPaymentResponse(Payment payment) {
+ return Optional.ofNullable(payment)
+ .map(p -> PaymentResponse.builder()
+ .id(p.getId())
+ .orderName(p.getOrder().getName())
+ .discountAmount(p.getDiscountAmount())
+ .totalAmount(p.getTotalAmount())
+ .build())
+ .orElse(null);
+ }
+
+ public static RequestConfirmPaymentResponse toRequestConfirmPaymentResponse(Payment payment,
+ ConfirmTossPaymentResponse response) {
+ return RequestConfirmPaymentResponse.builder()
+ .payment(payment)
+ .paymentKey(response.paymentKey())
+ .build();
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/payment/PaymentService.java b/src/main/java/com/moabam/api/application/payment/PaymentService.java
new file mode 100644
index 00000000..80b388a8
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/payment/PaymentService.java
@@ -0,0 +1,74 @@
+package com.moabam.api.application.payment;
+
+import static com.moabam.global.error.model.ErrorMessage.*;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.moabam.api.application.bug.BugService;
+import com.moabam.api.application.coupon.CouponService;
+import com.moabam.api.domain.payment.Payment;
+import com.moabam.api.domain.payment.repository.PaymentRepository;
+import com.moabam.api.domain.payment.repository.PaymentSearchRepository;
+import com.moabam.api.dto.payment.ConfirmPaymentRequest;
+import com.moabam.api.dto.payment.ConfirmTossPaymentResponse;
+import com.moabam.api.dto.payment.PaymentRequest;
+import com.moabam.api.dto.payment.RequestConfirmPaymentResponse;
+import com.moabam.api.infrastructure.payment.TossPaymentService;
+import com.moabam.global.error.exception.NotFoundException;
+import com.moabam.global.error.exception.TossPaymentException;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class PaymentService {
+
+ private final BugService bugService;
+ private final CouponService couponService;
+ private final TossPaymentService tossPaymentService;
+ private final PaymentRepository paymentRepository;
+ private final PaymentSearchRepository paymentSearchRepository;
+
+ @Transactional
+ public void request(Long memberId, Long paymentId, PaymentRequest request) {
+ Payment payment = getById(paymentId);
+ payment.validateByMember(memberId);
+ payment.request(request.orderId());
+ }
+
+ @Transactional
+ public RequestConfirmPaymentResponse requestConfirm(Long memberId, ConfirmPaymentRequest request) {
+ Payment payment = getByOrderId(request.orderId());
+ payment.validateInfo(memberId, request.amount());
+
+ try {
+ ConfirmTossPaymentResponse response = tossPaymentService.confirm(request);
+ return PaymentMapper.toRequestConfirmPaymentResponse(payment, response);
+ } catch (TossPaymentException exception) {
+ payment.fail(request.paymentKey());
+ throw exception;
+ }
+ }
+
+ @Transactional
+ public void confirm(Long memberId, Payment payment, String paymentKey) {
+ payment.confirm(paymentKey);
+
+ if (payment.isCouponApplied()) {
+ couponService.discount(payment.getCouponWalletId(), memberId);
+ }
+ bugService.charge(memberId, payment.getProduct());
+ }
+
+ private Payment getById(Long paymentId) {
+ return paymentRepository.findById(paymentId)
+ .orElseThrow(() -> new NotFoundException(PAYMENT_NOT_FOUND));
+ }
+
+ private Payment getByOrderId(String orderId) {
+ return paymentSearchRepository.findByOrderId(orderId)
+ .orElseThrow(() -> new NotFoundException(PAYMENT_NOT_FOUND));
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/product/ProductMapper.java b/src/main/java/com/moabam/api/application/product/ProductMapper.java
new file mode 100644
index 00000000..b847d9c1
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/product/ProductMapper.java
@@ -0,0 +1,41 @@
+package com.moabam.api.application.product;
+
+import java.util.List;
+
+import com.moabam.api.domain.payment.Payment;
+import com.moabam.api.domain.product.Product;
+import com.moabam.api.dto.product.ProductResponse;
+import com.moabam.api.dto.product.ProductsResponse;
+import com.moabam.api.dto.product.PurchaseProductResponse;
+import com.moabam.global.common.util.StreamUtils;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public final class ProductMapper {
+
+ public static ProductResponse toProductResponse(Product product) {
+ return ProductResponse.builder()
+ .id(product.getId())
+ .type(product.getType().name())
+ .name(product.getName())
+ .price(product.getPrice())
+ .quantity(product.getQuantity())
+ .build();
+ }
+
+ public static ProductsResponse toProductsResponse(List products) {
+ return ProductsResponse.builder()
+ .products(StreamUtils.map(products, ProductMapper::toProductResponse))
+ .build();
+ }
+
+ public static PurchaseProductResponse toPurchaseProductResponse(Payment payment) {
+ return PurchaseProductResponse.builder()
+ .paymentId(payment.getId())
+ .orderName(payment.getOrder().getName())
+ .price(payment.getTotalAmount())
+ .build();
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/ranking/RankingMapper.java b/src/main/java/com/moabam/api/application/ranking/RankingMapper.java
new file mode 100644
index 00000000..817ccdc8
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/ranking/RankingMapper.java
@@ -0,0 +1,39 @@
+package com.moabam.api.application.ranking;
+
+import java.util.List;
+
+import com.moabam.api.dto.ranking.RankingInfo;
+import com.moabam.api.dto.ranking.TopRankingInfo;
+import com.moabam.api.dto.ranking.TopRankingResponse;
+import com.moabam.api.dto.ranking.UpdateRanking;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class RankingMapper {
+
+ public static TopRankingInfo topRankingResponse(int rank, long score, RankingInfo rankInfo) {
+ return TopRankingInfo.builder()
+ .rank(rank)
+ .score(score)
+ .nickname(rankInfo.nickname())
+ .image(rankInfo.image())
+ .memberId(rankInfo.memberId())
+ .build();
+ }
+
+ public static TopRankingInfo topRankingResponse(int rank, UpdateRanking updateRanking) {
+ return TopRankingInfo.builder()
+ .rank(rank + 1)
+ .score(updateRanking.score())
+ .nickname(updateRanking.rankingInfo().nickname())
+ .image(updateRanking.rankingInfo().image())
+ .memberId(updateRanking.rankingInfo().memberId())
+ .build();
+ }
+
+ public static TopRankingResponse topRankingResponses(TopRankingInfo myRanking, List topRankings) {
+ return TopRankingResponse.builder().topRankings(topRankings).myRanking(myRanking).build();
+ }
+}
diff --git a/src/main/java/com/moabam/api/application/ranking/RankingService.java b/src/main/java/com/moabam/api/application/ranking/RankingService.java
new file mode 100644
index 00000000..651093f4
--- /dev/null
+++ b/src/main/java/com/moabam/api/application/ranking/RankingService.java
@@ -0,0 +1,86 @@
+package com.moabam.api.application.ranking;
+
+import static java.util.Objects.*;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.moabam.api.dto.ranking.RankingInfo;
+import com.moabam.api.dto.ranking.TopRankingInfo;
+import com.moabam.api.dto.ranking.TopRankingResponse;
+import com.moabam.api.dto.ranking.UpdateRanking;
+import com.moabam.api.infrastructure.redis.ZSetRedisRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class RankingService {
+
+ private static final String RANKING = "Ranking";
+ private static final int START_INDEX = 0;
+ private static final int LIMIT_INDEX = 9;
+
+ private final ObjectMapper objectMapper;
+ private final ZSetRedisRepository zSetRedisRepository;
+
+ public void addRanking(RankingInfo rankingInfo, Long totalCertifyCount) {
+ zSetRedisRepository.add(RANKING, rankingInfo, totalCertifyCount);
+ }
+
+ public void updateScores(List updateRankings) {
+ updateRankings.forEach(
+ updateRanking -> zSetRedisRepository.add(RANKING, updateRanking.rankingInfo(), updateRanking.score()));
+ }
+
+ public void changeInfos(RankingInfo before, RankingInfo after) {
+ zSetRedisRepository.changeMember(RANKING, before, after);
+ }
+
+ public void removeRanking(RankingInfo rankingInfo) {
+ zSetRedisRepository.delete(RANKING, rankingInfo);
+ }
+
+ public TopRankingResponse getMemberRanking(UpdateRanking myRankingInfo) {
+ List topRankings = getTopRankings();
+ Long myRanking = zSetRedisRepository.reverseRank(RANKING, myRankingInfo.rankingInfo());
+
+ Optional myTopRanking = topRankings.stream()
+ .filter(topRankingInfo -> Objects.equals(topRankingInfo.memberId(), myRankingInfo.rankingInfo().memberId()))
+ .findFirst();
+
+ if (myTopRanking.isPresent()) {
+ myRanking = (long)myTopRanking.get().rank();
+ }
+
+ TopRankingInfo myRankingInfoResponse = RankingMapper.topRankingResponse(myRanking.intValue(), myRankingInfo);
+
+ return RankingMapper.topRankingResponses(myRankingInfoResponse, topRankings);
+ }
+
+ private List getTopRankings() {
+ Set> topRankings = zSetRedisRepository.rangeJson(RANKING, START_INDEX,
+ LIMIT_INDEX);
+
+ Set scoreSet = new HashSet<>();
+ List topRankingInfo = new ArrayList<>();
+
+ for (ZSetOperations.TypedTuple