diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f27098d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+.git
+.gradle
+build
+.idea
+*.iml
+.env
\ No newline at end of file
diff --git a/.github/workflows/backend-manual-build.yml b/.github/workflows/backend-manual-build.yml
new file mode 100644
index 0000000..7b3ed5d
--- /dev/null
+++ b/.github/workflows/backend-manual-build.yml
@@ -0,0 +1,42 @@
+name: Backend Manual Build
+
+on:
+ workflow_dispatch:
+
+jobs:
+ backend-manual-build:
+ runs-on: ubuntu-latest
+
+ env:
+ DB_URL: ${{ secrets.DB_URL }}
+ DB_USERNAME: ${{ secrets.DB_USERNAME }}
+ DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
+ JWT_SECRET: ${{ secrets.JWT_SECRET }}
+ JWT_ACCESS_TOKEN_VALIDITY_SECONDS: ${{ secrets.JWT_ACCESS_TOKEN_VALIDITY_SECONDS }}
+ JWT_REFRESH_TOKEN_VALIDITY_SECONDS: ${{ secrets.JWT_REFRESH_TOKEN_VALIDITY_SECONDS }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Cache Gradle
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Build with Gradle
+ run: ./gradlew clean build
diff --git a/.github/workflows/code-style-test.yml b/.github/workflows/code-style-test.yml
new file mode 100644
index 0000000..b389c45
--- /dev/null
+++ b/.github/workflows/code-style-test.yml
@@ -0,0 +1,43 @@
+name: Java Code Style Check
+
+on:
+ pull_request:
+ branches: ['**']
+ push:
+ branches: ['**']
+ workflow_dispatch:
+
+jobs:
+ style-check:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v3
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Run Spotless and Checkstyle
+ run: |
+ set -e
+ ./gradlew spotlessCheck --no-daemon
+ ./gradlew checkstyleMain checkstyleTest --no-daemon
+ shell: bash
+
+ - name: Show summary
+ run: |
+ if [ $? -eq 0 ]; then
+ echo "๐ Code style checks passed!"
+ else
+ echo "โ Code style violations detected!"
+ echo "Please run './gradlew spotlessApply' locally and fix Checkstyle issues."
+ exit 1
+ fi
+ shell: bash
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..c92e12d
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,44 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches: ["main", "develop"]
+ pull_request:
+ branches: ["main", "develop"]
+ schedule:
+ - cron: '0 8 * * 0'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+ strategy:
+ fail-fast: false
+ matrix:
+ language: ['java']
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up JDK
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '21'
+ cache: 'gradle'
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
new file mode 100644
index 0000000..015f665
--- /dev/null
+++ b/.github/workflows/dependency-review.yml
@@ -0,0 +1,23 @@
+name: Dependency Review
+
+on:
+ pull_request:
+ branches: ["main", "develop"]
+
+permissions:
+ contents: read
+
+jobs:
+ dependency-review:
+ name: Audit dependencies
+ runs-on: ubuntu-latest
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Review dependencies
+ uses: actions/dependency-review-action@v4
+ with:
+ fail-on-severity: critical
diff --git a/.github/workflows/dockercompose-test.yml b/.github/workflows/dockercompose-test.yml
new file mode 100644
index 0000000..b3c5d53
--- /dev/null
+++ b/.github/workflows/dockercompose-test.yml
@@ -0,0 +1,127 @@
+name: Docker Compose Test
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+ paths:
+ - 'docker-compose.yml'
+ - 'Dockerfile.backend'
+ - 'src/**'
+ push:
+ branches:
+ - main
+ - develop
+ workflow_dispatch:
+
+jobs:
+ docker-compose-test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Build with Gradle
+ run: ./gradlew bootJar
+
+ - name: Create .env file from secrets
+ run: |
+ cat > .env << EOF
+ # Database Configuration
+ DB_PASSWORD=${{ secrets.DB_PASSWORD }}
+ DB_USERNAME=${{ secrets.DB_USERNAME }}
+ DB_URL=${{ secrets.DB_URL }}
+
+ # JWT Configuration
+ JWT_SECRET=${{ secrets.JWT_SECRET }}
+ JWT_ACCESS_TOKEN_VALIDITY_SECONDS=${{ secrets.JWT_ACCESS_TOKEN_VALIDITY_SECONDS }}
+ JWT_REFRESH_TOKEN_VALIDITY_SECONDS=${{ secrets.JWT_REFRESH_TOKEN_VALIDITY_SECONDS }}
+ EOF
+
+ - name: Start Docker Compose
+ run: docker compose up -d
+
+ - name: Check running containers
+ run: docker compose ps
+
+ - name: Wait for services to be ready
+ run: |
+ echo "โณ Waiting for MySQL to be ready..."
+ timeout 30 bash -c 'until docker compose exec -T db mysqladmin ping \
+ -h localhost -uroot -p"${{ secrets.DB_PASSWORD }}" --silent 2>/dev/null; do sleep 2; done'
+ echo "โ
MySQL is ready!"
+
+ echo "โณ Waiting for Valkey to be ready..."
+ timeout 30 bash -c 'until docker compose exec -T keystore valkey-cli ping 2>/dev/null | grep -q PONG; do sleep 2; done'
+ echo "โ
Valkey is ready!"
+
+ echo "โณ Waiting for application to be ready..."
+ timeout 90 bash -c 'until curl -f http://localhost:8080/actuator/health 2>/dev/null; do sleep 3; done'
+ echo "โ
Application is ready!"
+
+ - name: Health check
+ run: |
+ response=$(curl -s http://localhost:8080/actuator/health)
+ echo "Health check response: $response"
+ if echo "$response" | grep -q '"status":"UP"'; then
+ echo "โ
Health check passed!"
+ else
+ echo "โ Health check failed!"
+ exit 1
+ fi
+
+ - name: Test MySQL connection
+ env:
+ DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
+ DB_USERNAME: ${{ secrets.DB_USERNAME }}
+ run: |
+ echo "Testing MySQL connection..."
+ docker compose exec -T db mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" -e "SELECT 1;" && \
+ echo "โ
MySQL connection successful!" || \
+ (echo "โ MySQL connection failed!" && exit 1)
+
+ - name: Test Valkey connection
+ run: |
+ echo "Testing Valkey connection..."
+ result=$(docker compose exec -T keystore valkey-cli ping)
+ if [ "$result" = "PONG" ]; then
+ echo "โ
Valkey connection successful!"
+ else
+ echo "โ Valkey connection failed!"
+ exit 1
+ fi
+
+ - name: Show container logs on failure
+ if: failure()
+ run: |
+ echo "=== Application Logs ==="
+ docker compose logs app
+ echo "=== Database Logs ==="
+ docker compose logs db
+ echo "=== Valkey Logs ==="
+ docker compose logs keystore
+
+ - name: Stop Docker Compose
+ if: always()
+ run: docker compose down -v
+
+ - name: Test Summary
+ if: success()
+ run: |
+ echo "๐ All Docker Compose tests passed!"
+ echo "โ
MySQL connection verified"
+ echo "โ
Valkey connection verified"
+ echo "โ
Application health check passed"
+ echo "โ
All services running correctly"
\ No newline at end of file
diff --git a/BACKEND_DEPLOYMENT.md b/BACKEND_DEPLOYMENT.md
new file mode 100644
index 0000000..48ab1c8
--- /dev/null
+++ b/BACKEND_DEPLOYMENT.md
@@ -0,0 +1,61 @@
+## ๊ฐ์
+๋น ๋ฌธ์๋ Bravest ๋ฐฑ์๋๋ฅผ ์ปจํ
์ด๋ ์ด๋ฏธ์ง๋ก ํจํค์งํ๋ `Dockerfile.backend`์ ๋ํด ์ค๋ช
ํฉ๋๋ค.
+
+์์ฑ์ @semi-yu
+
+## ์์ธ
+
+### ์ฌ์ ์ง์
+
+์๋ฐ ์ ํ๋ฆฌ์ผ์ด์
์ ๋ฐฐํฌ ์ ์ ๋จผ์ ๋ฐ์ดํธ์ฝ๋(JAR)๋ก ์ปดํ์ผ๋์ด์ผ ํฉ๋๋ค. ์ด ๊ณผ์ ์์ ์์กด์ฑ ํด์ ๋ฑ์ ์ํด Gradle์ด ๊ด์ฌํ์ง๋ง, ์ด ํ๋ก์ ํธ์์๋ `Dockerfile.backend`๊ฐ `./gradlew bootJar`๋ฅผ ์คํํ๋๋ก ์ค์ ๋์ด ์์ผ๋ฏ๋ก ๋ณ๋๋ก ์ ๊ฒฝ ์ธ ํ์๋ ์์ต๋๋ค.
+
+๋ค๋ง, ์ด ์ปดํ์ผ(๋น๋) ๊ณผ์ ์ ์ ์ฉ Dockerfile๋ก ๋ฐ๋ก ๋ถ๋ฆฌํ๋ฉด ์ค์ ์ด ์ค๋ณต๋๊ฑฐ๋ ๋น๋ ํ์ดํ๋ผ์ธ์ด ๋ณต์กํด์ง ์ ์์ต๋๋ค. ๋ฐ๋ผ์ ํ๋์ Dockerfile์์ **๋น๋ ์คํ
์ด์ง์ ์คํ ์คํ
์ด์ง๋ฅผ ๋ชจ๋ ์ ์ํ๋ ๋ฉํฐ ์คํ
์ด์ง ๋น๋**๋ฅผ ์ฌ์ฉํฉ๋๋ค. ์ด๋ฅผ ํตํด ์ดํ๋ฆฌ์ผ์ด์
JAR ํ์ผ์ ์์ฑํ ๋ค, ์คํ์ ํ์ํ JDK(๋ด๋ถ์ JRE ํฌํจ)๋ง ํฌํจํ๋ ์ปจํ
์ด๋ ์ด๋ฏธ์ง๋ฅผ ๋ฐ๋ก ๋ง๋ค ์ ์์ผ๋ฉฐ, ๊ฒฐ๊ณผ ์ด๋ฏธ์ง ํฌ๊ธฐ ๋ํ ์ค์ผ ์ ์์ต๋๋ค.
+
+### ๊ธฐํ ํต์ ์ฌํญ
+`Dockerfile.backend`๋ ์ฌ๋ฌ๋ถ์ด ์์ฒญํ์ ๋๋ก 21 ๋ฒ์ ์ JDK๋ฅผ ์ฌ์ฉํ๋๋ก ์ ์ํ์์ต๋๋ค. ๋ฐฐํฌํ์ Temurin์ ์ฌ์ฉํฉ๋๋ค.
+### ์คํ
+
+ํํธ, ๋ฉํฐ ์คํ
์ด์ง ๋น๋๋ **์ด๋ฏธ์ง ๋น๋ ์์ ์ ๊ตฌ์กฐ**์๋ง ์ํฅ์ ์ค ๋ฟ, ์ดํ ์ปจํ
์ด๋๋ฅผ ์คํํ๋ ๊ณผ์ ์์ฒด์๋ ํน๋ณํ ์ฐจ์ด๋ฅผ ๋ง๋ค์ง ์์ต๋๋ค. ์๋ ์ ์ฐจ๋ฅผ ๋ฐ๋ฅด๋ฉด ๋ฉ๋๋ค.
+
+#### ์ด๋ฏธ์ง๋ฅผ ๋น๋ํ๊ธฐ ์ํด์โฆ
+
+- ์์
๋๋ ํ ๋ฆฌ๋ฅผ `Dockerfile.backend`๊ฐ ์กด์ฌํ๋ ํ๋ก์ ํธ ๋ฃจํธ๋ก ์ด๋ํ ๋ค, ๋ค์ ๋ช
๋ น์ด๋ฅผ ์คํํฉ๋๋ค:
+
+```bash
+docker build \
+ -f Dockerfile.backend \
+ -t bravest-backend \
+ .
+```
+์ ๋ช
๋ น์ด๋ `Dockerfile.backend`๋ฅผ ์ฐธ์กฐํ์ฌ ๋ฐฑ์๋ ์ ํ๋ฆฌ์ผ์ด์
์ด๋ฏธ์ง๋ฅผ ๋น๋ํฉ๋๋ค.
+
+#### ์ปจํ
์ด๋ ์ธ์คํด์ค๋ฅผ ์คํํ๊ธฐ ์ํด์...
+
+- `.env` ํ์ผ์ `Dockerfile.backend`๊ฐ ์กด์ฌํ๋ ๊ฒฝ๋ก์ ๋๊ณ , ๋ค์ ๋ช
๋ น์ด๋ฅผ ์คํํฉ๋๋ค:
+```bash
+docker run -d \
+ --name bravest-backend \
+ -p 8080:8080 \
+ --env-file .env \
+ bravest-backend
+```
+- ์ ๋ช
๋ น์ด๋ ๋น๋๋ bravest-backend ์ด๋ฏธ์ง๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์ปจํ
์ด๋ ์ธ์คํด์ค๋ฅผ ์คํํฉ๋๋ค.
+- ์ปจํ
์ด๋ ์ธ์คํด์ค๊ฐ ์ธ๋ถ DB์ ์ ์ํ๊ธฐ ์ํด ํ์ํ ์ ์ ์ ๋ณด๋ `.env` ํ์ผ์ ์ ์๋์ด ์์ด์ผ ํ๋ฉฐ, `--env-file .env` ์ต์
์ ํตํด ์ปจํ
์ด๋์ ์ฃผ์
๋ฉ๋๋ค.
+ - ์ด๋ฅผ ํตํด ์ค์ํ ์ ๋ณด๋ฅผ ๋ฐฐํฌ ํ๊ฒฝ์ ํ์ผ ์์คํ
์ ์ ์ฅํ์ง ์๊ณ ํ๊ฒฝ ๋ณ์๋ก ์ฃผ์
ํ ์ ์์ต๋๋ค.
+ - ํด๋น ํ์ผ์ ์ป์ผ๋ ค๋ฉด ํ์์๊ฒ ์ฐ๋ฝํด์ฃผ์ธ์.
+#### ํ์ฌ๋...
+- ์คํ๋ง ๋ถํธ๋ก ์์ฑ๋ ๋ฐฑ์๋์ Dockerfile๋ง ์ ์๋์ด ์์ต๋๋ค.
+- ๊ฐ๊น์ด ์์ผ ๋ด์, ์ ์ฒด ์๋น์ค๋ฅผ ์ ์ํ๋ `docker-compose.yml`๋ฅผ ์ ์ํ๊ฒ ์ต๋๋ค.
+### ์์ฝ
+
+#### ๋น๋ ๋ฐ ์คํ
+```bash
+# ๋น๋
+docker build -f Dockerfile.backend -t bravest-backend .
+
+# ์คํ
+docker run -d --name bravest-backend -p 8080:8080 --env-file .env bravest-backend
+
+```
+- ์ฌ์ ์กฐ๊ฑด
+ - ํ๋ก์ ํธ ๋ฃจํธ์ `.env` ํ์ผ์ด ์์ด์ผ ํฉ๋๋ค. ํ์ผ์ด ์๋ค๋ฉด ํ์์๊ฒ ์์ฒญํ์ธ์.
\ No newline at end of file
diff --git a/Dockerfile.backend b/Dockerfile.backend
new file mode 100644
index 0000000..e635dcb
--- /dev/null
+++ b/Dockerfile.backend
@@ -0,0 +1,31 @@
+# ๋น๋์ ์คํ์ ๋์์ ์ํํฉ๋๋ค.
+
+# ๋น๋ ๋จ๊ณ
+FROM eclipse-temurin:21-jdk-jammy AS build
+WORKDIR /workspace
+
+COPY gradlew .
+COPY gradle ./gradle
+
+COPY build.gradle settings.gradle ./
+
+RUN chmod +x gradlew
+
+RUN ./gradlew --no-daemon dependencies || true
+
+COPY . .
+
+RUN ./gradlew --no-daemon bootJar
+
+# ์คํ ๋จ๊ณ
+FROM eclipse-temurin:21-jdk-jammy
+
+WORKDIR /app
+
+COPY --from=build /workspace/build/libs/*.jar app.jar
+
+ENV JAVA_OPTS="-Dspring.profiles.active=prod"
+
+EXPOSE 8080
+
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
diff --git a/build.gradle b/build.gradle
index 327e68a..922a33a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,6 +2,8 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.5.7'
id 'io.spring.dependency-management' version '1.1.7'
+ id 'com.diffplug.spotless' version '6.25.0'
+ id 'checkstyle'
}
group = 'opensource'
@@ -28,18 +30,66 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
- compileOnly 'org.projectlombok:lombok'
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ implementation 'org.springframework.boot:spring-boot-starter-websocket'
+ implementation 'me.paulschwarz:spring-dotenv:4.0.0'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ implementation 'com.mysql:mysql-connector-j'
+ // Swagger (springdoc)
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14'
+ // JWT (jjwt 0.12.x)
+ implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
+
+
+ // Lombok
+ compileOnly 'org.projectlombok:lombok'
+ annotationProcessor 'org.projectlombok:lombok'
+
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.security:spring-security-test'
+
+ //env
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
- //swagger ์ธํ
์
๋๋ค
- implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
}
tasks.named('test') {
useJUnitPlatform()
}
+
+tasks.register("checkstyle") {
+ dependsOn("checkstyleMain", "checkstyleTest")
+}
+
+checkstyle {
+ toolVersion = "10.12.0"
+
+ configFile = file("${rootDir}/config/checkstyle/google_checks.xml")
+}
+
+tasks.withType(Checkstyle).configureEach {
+ reports {
+ xml.required.set(true)
+ html.required.set(true)
+ }
+}
+
+spotless {
+ java {
+ eclipse().configFile file("${rootDir}/config/checkstyle/checkstyle_eclipse_format.xml")
+ target 'src/**/*.java'
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
+}
\ No newline at end of file
diff --git a/compose.yaml b/compose.yaml
deleted file mode 100644
index 4d2047e..0000000
--- a/compose.yaml
+++ /dev/null
@@ -1,10 +0,0 @@
-services:
- mysql:
- image: 'mysql:latest'
- environment:
- - 'MYSQL_DATABASE=mydatabase'
- - 'MYSQL_PASSWORD=secret'
- - 'MYSQL_ROOT_PASSWORD=verysecret'
- - 'MYSQL_USER=myuser'
- ports:
- - '3306'
diff --git a/config/checkstyle/checkstyle_eclipse_format.xml b/config/checkstyle/checkstyle_eclipse_format.xml
new file mode 100644
index 0000000..b13dbcb
--- /dev/null
+++ b/config/checkstyle/checkstyle_eclipse_format.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/config/checkstyle/google_checks.xml b/config/checkstyle/google_checks.xml
new file mode 100644
index 0000000..1013d86
--- /dev/null
+++ b/config/checkstyle/google_checks.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..5d4c6f4
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,48 @@
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile.backend
+ env_file:
+ - .env
+ container_name: bravest-backend
+ ports:
+ - "8080:8080"
+ networks:
+ - bravest-net
+ depends_on:
+ db:
+ condition: service_healthy
+ keystore:
+ condition: service_started
+
+ db:
+ image: mysql:8.0
+ env_file:
+ - .env
+ container_name: bravest-db
+ ports:
+ - "3306:3306"
+ networks:
+ - bravest-net
+ environment:
+ MYSQL_DATABASE: db
+ MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
+ healthcheck:
+ test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpw" ]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ start_period: 10s
+
+ keystore:
+ image: valkey/valkey:9.0.0
+ container_name: bravest-keystore
+ ports:
+ - "6379:6379"
+ networks:
+ - bravest-net
+
+networks:
+ bravest-net:
+ driver: bridge
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 0000000..498fcc3
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,4 @@
+FROM ubuntu:latest
+LABEL authors="user"
+
+ENTRYPOINT ["top", "-b"]
\ No newline at end of file
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
new file mode 100644
index 0000000..0d1ca75
--- /dev/null
+++ b/docker/docker-compose.yml
@@ -0,0 +1,19 @@
+version: "3.8"
+
+services:
+ mysql:
+ image: mysql:8
+ container_name: mydb
+ ports:
+ - "3306:3306"
+ environment:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: mydb
+ MYSQL_USER: myuser
+ MYSQL_PASSWORD: mypassword
+ volumes:
+ - db-data:/var/lib/mysql
+ restart: always
+
+volumes:
+ db-data:
\ No newline at end of file
diff --git a/src/main/java/opensource/bravest/BravestApplication.java b/src/main/java/opensource/bravest/BravestApplication.java
index 58c22c0..15912ca 100644
--- a/src/main/java/opensource/bravest/BravestApplication.java
+++ b/src/main/java/opensource/bravest/BravestApplication.java
@@ -6,8 +6,7 @@
@SpringBootApplication
public class BravestApplication {
- public static void main(String[] args) {
- SpringApplication.run(BravestApplication.class, args);
- }
-
+ public static void main(String[] args) {
+ SpringApplication.run(BravestApplication.class, args);
+ }
}
diff --git a/src/main/java/opensource/bravest/config/SwaggerConfig.java b/src/main/java/opensource/bravest/config/SwaggerConfig.java
index c207f19..91fb3e6 100644
--- a/src/main/java/opensource/bravest/config/SwaggerConfig.java
+++ b/src/main/java/opensource/bravest/config/SwaggerConfig.java
@@ -13,15 +13,10 @@ public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
- return new OpenAPI()
- .components(new Components())
- .info(apiInfo());
+ return new OpenAPI().components(new Components()).info(apiInfo());
}
private Info apiInfo() {
- return new Info()
- .title("Bravest")
- .description("์คํ์์ค ํ๋ก์ ํธ Bravest api ๋ช
์ธ์์
๋๋ค.")
- .version("1.0.0");
+ return new Info().title("Bravest").description("์คํ์์ค ํ๋ก์ ํธ Bravest api ๋ช
์ธ์์
๋๋ค.").version("1.0.0");
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/opensource/bravest/domain/chatList/controller/ChatListController.java b/src/main/java/opensource/bravest/domain/chatList/controller/ChatListController.java
new file mode 100644
index 0000000..8c51ad4
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/chatList/controller/ChatListController.java
@@ -0,0 +1,58 @@
+package opensource.bravest.domain.chatList.controller;
+
+import static opensource.bravest.domain.chatList.dto.ChatListDto.ChatListCreateRequest;
+import static opensource.bravest.domain.chatList.dto.ChatListDto.ChatListResponse;
+import static opensource.bravest.domain.chatList.dto.ChatListDto.ChatListUpdateRequest;
+
+import jakarta.validation.Valid;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.domain.chatList.service.ChatListService;
+import opensource.bravest.global.apiPayload.ApiResponse;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/chatlists")
+@RequiredArgsConstructor
+public class ChatListController {
+
+ private final ChatListService chatListService;
+
+ @PostMapping
+ public ApiResponse createChatList(@Valid @RequestBody ChatListCreateRequest request) {
+ ChatListResponse response = chatListService.createChatList(request);
+ return ApiResponse.onSuccess(response);
+ }
+
+ @GetMapping("/room/{roomId}")
+ public ApiResponse> getChatListsByRoomId(@PathVariable Long roomId) {
+ List response = chatListService.getChatListsByRoomId(roomId);
+ return ApiResponse.onSuccess(response);
+ }
+
+ @GetMapping("/{id}")
+ public ApiResponse getChatListById(@PathVariable Long id) {
+ ChatListResponse response = chatListService.getChatListById(id);
+ return ApiResponse.onSuccess(response);
+ }
+
+ @PutMapping("/{id}")
+ public ApiResponse updateChatList(@PathVariable Long id,
+ @Valid @RequestBody ChatListUpdateRequest request) {
+ ChatListResponse response = chatListService.updateChatList(id, request);
+ return ApiResponse.onSuccess(response);
+ }
+
+ @DeleteMapping("/{id}")
+ public ApiResponse deleteChatList(@PathVariable Long id) {
+ chatListService.deleteChatList(id);
+ return ApiResponse.onSuccess(null);
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/chatList/dto/ChatListDto.java b/src/main/java/opensource/bravest/domain/chatList/dto/ChatListDto.java
new file mode 100644
index 0000000..ff012ac
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/chatList/dto/ChatListDto.java
@@ -0,0 +1,47 @@
+package opensource.bravest.domain.chatList.dto;
+
+import java.time.LocalDateTime;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+import opensource.bravest.domain.chatList.entity.ChatList;
+
+public class ChatListDto {
+
+ // 1. ์์ด๋์ด ์์ฑ ์์ฒญ DTO (Create Request)
+ @Getter
+ @Setter
+ public static class ChatListCreateRequest {
+
+ private Long roomId;
+
+ private String content;
+
+ private Long registeredBy;
+ }
+
+ // 2. ์์ด๋์ด ์์ ์์ฒญ DTO (Update Request)
+ @Getter
+ @Setter
+ public static class ChatListUpdateRequest {
+
+ // ์์ด๋์ด ๋ด์ฉ ์์ ๋ง ๊ฐ์
+ private String content;
+ }
+
+ @Getter
+ @Builder
+ public static class ChatListResponse {
+ private Long id;
+ private Long roomId;
+ private String content;
+ private Long registeredBy;
+ private LocalDateTime createdAt;
+
+ public static ChatListResponse fromEntity(ChatList chatList) {
+ return ChatListResponse.builder().id(chatList.getId()).roomId(chatList.getRoomId())
+ .content(chatList.getContent()).registeredBy(chatList.getRegisteredBy().getId())
+ .createdAt(chatList.getCreatedAt()).build();
+ }
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/chatList/entity/ChatList.java b/src/main/java/opensource/bravest/domain/chatList/entity/ChatList.java
new file mode 100644
index 0000000..d4a93d8
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/chatList/entity/ChatList.java
@@ -0,0 +1,65 @@
+package opensource.bravest.domain.chatList.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+import org.hibernate.annotations.CreationTimestamp;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Table(name = "chat_list")
+public class ChatList {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "room_id", nullable = false)
+ private AnonymousRoom room;
+
+ @NotNull
+ @Column(length = 255)
+ private String content;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "profile_id", nullable = false)
+ private AnonymousProfile registeredBy;
+
+ @CreationTimestamp
+ private LocalDateTime createdAt;
+
+ @Builder
+ public ChatList(AnonymousRoom room, String content, AnonymousProfile registeredBy) {
+ this.room = room;
+ this.content = content;
+ this.registeredBy = registeredBy;
+ }
+
+ public void updateContent(String content) {
+ this.content = content;
+ }
+
+ public Long getRoomId() {
+ return this.room.getId();
+ }
+
+ public Long getProfileId() {
+ return this.registeredBy.getId();
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/chatList/repository/ChatListRepository.java b/src/main/java/opensource/bravest/domain/chatList/repository/ChatListRepository.java
new file mode 100644
index 0000000..b005041
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/chatList/repository/ChatListRepository.java
@@ -0,0 +1,12 @@
+package opensource.bravest.domain.chatList.repository;
+
+import java.util.List;
+import opensource.bravest.domain.chatList.entity.ChatList;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+public interface ChatListRepository extends JpaRepository {
+
+ @Query("SELECT c FROM ChatList c WHERE c.room.id = :roomId ORDER BY c.createdAt DESC")
+ List findAllByRoomId(Long roomId);
+}
diff --git a/src/main/java/opensource/bravest/domain/chatList/service/ChatListService.java b/src/main/java/opensource/bravest/domain/chatList/service/ChatListService.java
new file mode 100644
index 0000000..4443986
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/chatList/service/ChatListService.java
@@ -0,0 +1,72 @@
+package opensource.bravest.domain.chatList.service;
+
+import static opensource.bravest.domain.chatList.dto.ChatListDto.ChatListCreateRequest;
+import static opensource.bravest.domain.chatList.dto.ChatListDto.ChatListResponse;
+import static opensource.bravest.domain.chatList.dto.ChatListDto.ChatListUpdateRequest;
+import static opensource.bravest.global.apiPayload.code.status.ErrorStatus._CHATLIST_NOT_FOUND;
+import static opensource.bravest.global.apiPayload.code.status.ErrorStatus._CHATROOM_NOT_FOUND;
+import static opensource.bravest.global.apiPayload.code.status.ErrorStatus._USER_NOT_FOUND;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.domain.chatList.entity.ChatList;
+import opensource.bravest.domain.chatList.repository.ChatListRepository;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+import opensource.bravest.domain.profile.repository.AnonymousProfileRepository;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+import opensource.bravest.domain.room.repository.AnonymousRoomRepository;
+import opensource.bravest.global.exception.CustomException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class ChatListService {
+
+ private final ChatListRepository chatListRepository;
+ private final AnonymousRoomRepository anonymousRoomRepository;
+ private final AnonymousProfileRepository anonymousProfileRepository;
+
+ @Transactional
+ public ChatListResponse createChatList(ChatListCreateRequest request) {
+ AnonymousRoom room = anonymousRoomRepository.findById(request.getRoomId())
+ .orElseThrow(() -> new CustomException(_CHATROOM_NOT_FOUND));
+
+ AnonymousProfile profile = anonymousProfileRepository.findById(request.getRegisteredBy())
+ .orElseThrow(() -> new CustomException(_USER_NOT_FOUND));
+
+ ChatList chatList = ChatList.builder().room(room).registeredBy(profile).content(request.getContent()).build();
+
+ ChatList savedList = chatListRepository.save(chatList);
+ return ChatListResponse.fromEntity(savedList);
+ }
+
+ public List getChatListsByRoomId(Long roomId) {
+ List chatLists = chatListRepository.findAllByRoomId(roomId);
+ return chatLists.stream().map(ChatListResponse::fromEntity).collect(Collectors.toList());
+ }
+
+ public ChatListResponse getChatListById(Long id) {
+ ChatList chatList = chatListRepository.findById(id).orElseThrow(() -> new CustomException(_CHATLIST_NOT_FOUND));
+ return ChatListResponse.fromEntity(chatList);
+ }
+
+ @Transactional
+ public ChatListResponse updateChatList(Long id, ChatListUpdateRequest request) {
+ ChatList chatList = chatListRepository.findById(id).orElseThrow(() -> new CustomException(_CHATLIST_NOT_FOUND));
+
+ chatList.updateContent(request.getContent());
+
+ return ChatListResponse.fromEntity(chatList);
+ }
+
+ @Transactional
+ public void deleteChatList(Long id) {
+ if (!chatListRepository.existsById(id)) {
+ throw new CustomException(_CHATLIST_NOT_FOUND);
+ }
+ chatListRepository.deleteById(id);
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/message/controller/ChatMessageController.java b/src/main/java/opensource/bravest/domain/message/controller/ChatMessageController.java
new file mode 100644
index 0000000..a80df92
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/message/controller/ChatMessageController.java
@@ -0,0 +1,33 @@
+package opensource.bravest.domain.message.controller;
+
+import static opensource.bravest.domain.message.dto.MessageDto.MessageRequest;
+import static opensource.bravest.domain.message.dto.MessageDto.MessageResponse;
+
+import java.security.Principal;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import opensource.bravest.domain.message.service.ChatMessageService;
+import opensource.bravest.global.apiPayload.ApiResponse;
+import org.springframework.messaging.handler.annotation.MessageMapping;
+import org.springframework.messaging.handler.annotation.SendTo;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.stereotype.Controller;
+
+@Slf4j
+@Controller
+@RequiredArgsConstructor
+public class ChatMessageController {
+ private final ChatMessageService chatMessageService;
+ private final SimpMessagingTemplate messagingTemplate;
+
+ @MessageMapping("/send")
+ @SendTo("/subs/chat-rooms")
+ public void receiveMessage(MessageRequest request, Principal principal) {
+ Long id = Long.parseLong(principal.getName());
+ MessageResponse response = chatMessageService.send(request, id);
+
+ // ํน์ ์ฑํ
๋ฐฉ ๊ตฌ๋
์๋ค์๊ฒ ๋ฉ์์ง ์ ์ก
+ messagingTemplate.convertAndSend("/subs/chat-rooms/" + request.getChatRoomId(),
+ ApiResponse.onSuccess(response));
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/message/dto/MessageDto.java b/src/main/java/opensource/bravest/domain/message/dto/MessageDto.java
new file mode 100644
index 0000000..f44edbb
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/message/dto/MessageDto.java
@@ -0,0 +1,40 @@
+package opensource.bravest.domain.message.dto;
+
+import java.time.LocalDateTime;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.domain.message.entity.ChatMessage;
+
+public class MessageDto {
+
+ @Getter
+ public static class SendMessageRequest {
+ private String content;
+ }
+
+ @Getter
+ @RequiredArgsConstructor
+ public static class MessageResponse {
+ private final String senderName; // ์ต๋ช
๋๋ค์
+ private final String content;
+ private final LocalDateTime createdAt;
+
+ public static MessageResponse from(ChatMessage chatMessage) {
+ return new MessageResponse(chatMessage.getSender().getAnonymousName(), chatMessage.getContent(),
+ chatMessage.getCreatedAt());
+ }
+ }
+
+ @Getter
+ @RequiredArgsConstructor
+ public static class MessageRequest {
+ private final Long chatRoomId;
+ private final String content;
+ }
+
+ @Getter
+ @RequiredArgsConstructor
+ public static class ChatReadRequest {
+ private final Long chatRoomId;
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/message/entity/ChatMessage.java b/src/main/java/opensource/bravest/domain/message/entity/ChatMessage.java
new file mode 100644
index 0000000..2544637
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/message/entity/ChatMessage.java
@@ -0,0 +1,34 @@
+package opensource.bravest.domain.message.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+import lombok.*;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class ChatMessage {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ // ์ด๋ ๋ฐฉ์ ๋ฉ์์ง์ธ์ง
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "room_id", nullable = false)
+ private AnonymousRoom room;
+
+ // ๋๊ฐ ๋ณด๋๋์ง (์ต๋ช
ํ๋กํ ๊ธฐ์ค)
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "anonymous_profile_id", nullable = false)
+ private AnonymousProfile sender;
+
+ @Column(nullable = false, length = 1000)
+ private String content;
+
+ private LocalDateTime createdAt;
+}
diff --git a/src/main/java/opensource/bravest/domain/message/repository/ChatMessageRepository.java b/src/main/java/opensource/bravest/domain/message/repository/ChatMessageRepository.java
new file mode 100644
index 0000000..4621641
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/message/repository/ChatMessageRepository.java
@@ -0,0 +1,12 @@
+package opensource.bravest.domain.message.repository;
+
+import java.util.List;
+import opensource.bravest.domain.message.entity.ChatMessage;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ChatMessageRepository extends JpaRepository {
+
+ // ๋ฐฉ ๊ธฐ์ค์ผ๋ก ์ต๊ทผ ๋ฉ์์ง ๋ชฉ๋ก
+ List findByRoomOrderByCreatedAtAsc(AnonymousRoom room);
+}
diff --git a/src/main/java/opensource/bravest/domain/message/service/ChatMessageService.java b/src/main/java/opensource/bravest/domain/message/service/ChatMessageService.java
new file mode 100644
index 0000000..a5a64de
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/message/service/ChatMessageService.java
@@ -0,0 +1,55 @@
+package opensource.bravest.domain.message.service;
+
+import static opensource.bravest.domain.message.dto.MessageDto.MessageRequest;
+import static opensource.bravest.domain.message.dto.MessageDto.MessageResponse;
+import static opensource.bravest.global.apiPayload.code.status.ErrorStatus._CHATROOM_NOT_FOUND;
+import static opensource.bravest.global.apiPayload.code.status.ErrorStatus._USER_NOT_FOUND;
+
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.domain.message.entity.ChatMessage;
+import opensource.bravest.domain.message.repository.ChatMessageRepository;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+import opensource.bravest.domain.profile.repository.AnonymousProfileRepository;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+import opensource.bravest.domain.room.repository.AnonymousRoomRepository;
+import opensource.bravest.global.exception.CustomException;
+import org.springframework.stereotype.Service;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class ChatMessageService {
+
+ private final AnonymousProfileRepository memberRepository;
+ private final AnonymousRoomRepository chatRoomRepository;
+ private final ChatMessageRepository chatMessageRepository;
+
+ // ๋ฉ์์ง ์ ์ก
+ public MessageResponse send(MessageRequest request, Long id) {
+ AnonymousProfile sender = memberRepository.findById(id).orElseThrow(() -> new CustomException(_USER_NOT_FOUND));
+
+ AnonymousRoom chatRoom = chatRoomRepository.findById(request.getChatRoomId())
+ .orElseThrow(() -> new CustomException(_CHATROOM_NOT_FOUND));
+
+ ChatMessage chatMessage = ChatMessage.builder().room(chatRoom).sender(sender).content(request.getContent())
+ .build();
+
+ chatMessageRepository.save(chatMessage);
+
+ return MessageResponse.from(chatMessage);
+ }
+
+ @Transactional
+ public void readMessages(Long chatRoomId, Long memberId) {
+ AnonymousRoom chatRoom = chatRoomRepository.findById(chatRoomId)
+ .orElseThrow(() -> new CustomException(_CHATROOM_NOT_FOUND));
+
+ // if (!Objects.equals(chatRoom.getMember1().getId(), memberId) &&
+ // !Objects.equals(chatRoom.getMember2().getId(),
+ // memberId)) {
+ // throw new BaseException(ChatExceptionType.CHAT_ROOM_ACCESS_DENIED);
+ // }
+ // messageReceiptRepository.bulkUpdateStatusToRead(chatRoomId, memberId);
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/profile/controller/AnonymousProfileController.java b/src/main/java/opensource/bravest/domain/profile/controller/AnonymousProfileController.java
new file mode 100644
index 0000000..02dcb41
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/profile/controller/AnonymousProfileController.java
@@ -0,0 +1,35 @@
+package opensource.bravest.domain.profile.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.domain.profile.dto.AnonymousProfileResponse;
+import opensource.bravest.domain.profile.dto.CreateAnonymousProfileRequest;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+import opensource.bravest.domain.profile.service.AnonymousProfileService;
+import opensource.bravest.global.apiPayload.ApiResponse;
+import opensource.bravest.global.apiPayload.code.status.SuccessStatus;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/anonymous-profiles")
+public class AnonymousProfileController {
+
+ private final AnonymousProfileService anonymousProfileService;
+
+ @Operation(summary = "์ต๋ช
ํ๋กํ ์์ฑ", description = "ํน์ ์ฑํ
๋ฐฉ์ ๋ํ ์๋ก์ด ์ต๋ช
ํ๋กํ์ ์์ฑํฉ๋๋ค.")
+ @PostMapping("/rooms/{roomId}")
+ public ApiResponse createAnonymousProfile(@PathVariable Long roomId,
+ @RequestBody CreateAnonymousProfileRequest request) {
+ AnonymousProfile profile = anonymousProfileService.createAnonymousProfile(roomId, request);
+ AnonymousProfileResponse response = AnonymousProfileResponse.from(profile);
+ return ApiResponse.of(SuccessStatus._CREATED, SuccessStatus._CREATED.getMessage(), response);
+ }
+
+ @DeleteMapping("/{profileId}")
+ @Operation(summary = "์ต๋ช
ํ๋กํ ์ญ์ ", description = "ID๋ก ํน์ ์ต๋ช
ํ๋กํ์ ์ญ์ ํฉ๋๋ค.")
+ public ApiResponse> deleteAnonymousProfile(@PathVariable Long profileId) {
+ anonymousProfileService.deleteAnonymousProfile(profileId);
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(), null);
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/profile/dto/AnonymousProfileResponse.java b/src/main/java/opensource/bravest/domain/profile/dto/AnonymousProfileResponse.java
new file mode 100644
index 0000000..de5ba8c
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/profile/dto/AnonymousProfileResponse.java
@@ -0,0 +1,20 @@
+package opensource.bravest.domain.profile.dto;
+
+import lombok.Builder;
+import lombok.Getter;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+
+@Getter
+@Builder
+public class AnonymousProfileResponse {
+ private Long id;
+ private Long roomId;
+ private String nickname;
+
+ // ํ์ํ ํ๋๋ง
+
+ public static AnonymousProfileResponse from(AnonymousProfile profile) {
+ return AnonymousProfileResponse.builder().id(profile.getId()).roomId(profile.getRoom().getId())
+ .nickname(profile.getAnonymousName()).build();
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/profile/dto/CreateAnonymousProfileRequest.java b/src/main/java/opensource/bravest/domain/profile/dto/CreateAnonymousProfileRequest.java
new file mode 100644
index 0000000..4b06c9e
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/profile/dto/CreateAnonymousProfileRequest.java
@@ -0,0 +1,11 @@
+package opensource.bravest.domain.profile.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+public class CreateAnonymousProfileRequest {
+ private Long realUserId;
+ private String anonymousName;
+}
diff --git a/src/main/java/opensource/bravest/domain/profile/entity/AnonymousProfile.java b/src/main/java/opensource/bravest/domain/profile/entity/AnonymousProfile.java
new file mode 100644
index 0000000..1c95fdd
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/profile/entity/AnonymousProfile.java
@@ -0,0 +1,30 @@
+package opensource.bravest.domain.profile.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class AnonymousProfile {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ // ์ด๋ค ๋ฐฉ์ ์ํ ์ต๋ช
ํ๋กํ์ธ์ง
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "room_id", nullable = false)
+ private AnonymousRoom room;
+
+ // ์ค์ ์ ์ PK (User ํ
์ด๋ธ ์๋ค๋ฉด JWT์ userId ๊ธฐ์ค์ผ๋ก)
+ @Column(nullable = false)
+ private Long realUserId;
+
+ // ๋ฐฉ ์์์ ๋ณด์ฌ์ค ์ต๋ช
๋๋ค์ (์: BlueTiger12)
+ @Column(nullable = false, length = 50)
+ private String anonymousName;
+}
diff --git a/src/main/java/opensource/bravest/domain/profile/repository/AnonymousProfileRepository.java b/src/main/java/opensource/bravest/domain/profile/repository/AnonymousProfileRepository.java
new file mode 100644
index 0000000..103c514
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/profile/repository/AnonymousProfileRepository.java
@@ -0,0 +1,12 @@
+package opensource.bravest.domain.profile.repository;
+
+import java.util.Optional;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface AnonymousProfileRepository extends JpaRepository {
+
+ // ๊ฐ์ ๋ฐฉ + ๊ฐ์ ์ค์ ์ ์ ๋ผ๋ฉด ์ต๋ช
ํ๋กํ ํ๋๋ง ์ฌ์ฉ
+ Optional findByRoomAndRealUserId(AnonymousRoom room, Long realUserId);
+}
diff --git a/src/main/java/opensource/bravest/domain/profile/service/AnonymousProfileService.java b/src/main/java/opensource/bravest/domain/profile/service/AnonymousProfileService.java
new file mode 100644
index 0000000..6547577
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/profile/service/AnonymousProfileService.java
@@ -0,0 +1,46 @@
+package opensource.bravest.domain.profile.service;
+
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.domain.profile.dto.CreateAnonymousProfileRequest;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+import opensource.bravest.domain.profile.repository.AnonymousProfileRepository;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+import opensource.bravest.domain.room.repository.AnonymousRoomRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AnonymousProfileService {
+
+ private final AnonymousProfileRepository anonymousProfileRepository;
+ private final AnonymousRoomRepository anonymousRoomRepository;
+
+ @Transactional
+ public AnonymousProfile createAnonymousProfile(Long roomId, CreateAnonymousProfileRequest request) {
+ AnonymousRoom room = anonymousRoomRepository.findById(roomId)
+ .orElseThrow(() -> new RuntimeException("๋ฐฉ์ ์ฐพ์ ์ ์์.๋ฟก"));
+
+ // ์ค๋ณต ํ๋กํ ์ฒดํฌ
+ Optional existingProfile = anonymousProfileRepository.findByRoomAndRealUserId(room,
+ request.getRealUserId());
+ if (existingProfile.isPresent()) {
+ throw new RuntimeException("์ด๋ฏธ ๋ฐฉ์ ์กด์ฌํ๋ ์ ์ ์. ๋ค๋ฅธ๊ฑธ๋ก ์ ์ํ์
.");
+ }
+
+ AnonymousProfile newProfile = AnonymousProfile.builder().room(room).realUserId(request.getRealUserId())
+ .anonymousName(request.getAnonymousName()).build();
+
+ return anonymousProfileRepository.save(newProfile);
+ }
+
+ @Transactional
+ public void deleteAnonymousProfile(Long profileId) {
+ if (!anonymousProfileRepository.existsById(profileId)) {
+ throw new RuntimeException("์๋ ์ฌ์ฉ์์. ๋~ ๋๊ตฌ์ผ!");
+ }
+ anonymousProfileRepository.deleteById(profileId);
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/room/controller/RoomController.java b/src/main/java/opensource/bravest/domain/room/controller/RoomController.java
new file mode 100644
index 0000000..d0faf85
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/room/controller/RoomController.java
@@ -0,0 +1,69 @@
+package opensource.bravest.domain.room.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.domain.room.dto.RoomDto;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+import opensource.bravest.domain.room.service.RoomService;
+import opensource.bravest.global.apiPayload.ApiResponse;
+import opensource.bravest.global.apiPayload.code.status.SuccessStatus;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/rooms")
+public class RoomController {
+
+ private final RoomService roomService;
+
+ @PostMapping
+ @Operation(summary = "์ฑํ
๋ฐฉ ์์ฑ", description = "์๋ก์ด ์ฑํ
๋ฐฉ์ ์์ฑํฉ๋๋ค.")
+ public ApiResponse createRoom(@RequestBody RoomDto.CreateRoomRequest request) {
+ AnonymousRoom room = roomService.createRoom(request);
+ return ApiResponse.of(SuccessStatus._CREATED, SuccessStatus._CREATED.getMessage(),
+ RoomDto.RoomResponse.builder().id(room.getId()).roomCode(room.getRoomCode())
+ .title(room.getTitle()).createdAt(room.getCreatedAt()).build());
+ }
+
+ @GetMapping("/{roomId}")
+ @Operation(summary = "์ฑํ
๋ฐฉ ์กฐํ", description = "ID๋ก ํน์ ์ฑํ
๋ฐฉ์ ์ ๋ณด๋ฅผ ์กฐํํฉ๋๋ค.")
+ public ApiResponse getRoom(@PathVariable Long roomId) {
+ AnonymousRoom room = roomService.getRoom(roomId);
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(),
+ RoomDto.RoomResponse.builder().id(room.getId()).roomCode(room.getRoomCode())
+ .title(room.getTitle()).createdAt(room.getCreatedAt()).build());
+ }
+
+ @PutMapping("/{roomId}")
+ @Operation(summary = "์ฑํ
๋ฐฉ ์ ๋ณด ์์ ", description = "ID๋ก ํน์ ์ฑํ
๋ฐฉ์ ์ ๋ณด๋ฅผ ์์ ํฉ๋๋ค.")
+ public ApiResponse updateRoom(@PathVariable Long roomId,
+ @RequestBody RoomDto.UpdateRoomRequest request) {
+ AnonymousRoom room = roomService.updateRoom(roomId, request);
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(),
+ RoomDto.RoomResponse.builder().id(room.getId()).roomCode(room.getRoomCode())
+ .title(room.getTitle()).createdAt(room.getCreatedAt()).build());
+ }
+
+ @DeleteMapping("/{roomId}")
+ @Operation(summary = "์ฑํ
๋ฐฉ ์ญ์ ", description = "ID๋ก ํน์ ์ฑํ
๋ฐฉ์ ์ญ์ ํฉ๋๋ค.")
+ public ApiResponse> deleteRoom(@PathVariable Long roomId) {
+ roomService.deleteRoom(roomId);
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(), null);
+ }
+
+ @GetMapping("/{roomId}/invite-code")
+ @Operation(summary = "์ด๋ ์ฝ๋ ์กฐํ", description = "ID๋ก ํน์ ์ฑํ
๋ฐฉ์ ์ด๋ ์ฝ๋๋ฅผ ์กฐํํฉ๋๋ค.")
+ public ApiResponse getInviteCode(@PathVariable Long roomId) {
+ String inviteCode = roomService.getInviteCode(roomId);
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(), inviteCode);
+ }
+
+ @PostMapping("/join")
+ @Operation(summary = "์ด๋ ์ฝ๋๋ก ์ฑํ
๋ฐฉ ์ฐธ์ฌ", description = "์ด๋ ์ฝ๋๋ฅผ ์ฌ์ฉํ์ฌ ํน์ ์ฑํ
๋ฐฉ์ ์ฐธ์ฌํฉ๋๋ค.")
+ public ApiResponse joinRoom(@RequestBody RoomDto.JoinRoomRequest request) {
+ AnonymousRoom room = roomService.joinRoom(request.getRoomCode());
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(),
+ RoomDto.RoomResponse.builder().id(room.getId()).roomCode(room.getRoomCode())
+ .title(room.getTitle()).createdAt(room.getCreatedAt()).build());
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/room/dto/RoomDto.java b/src/main/java/opensource/bravest/domain/room/dto/RoomDto.java
new file mode 100644
index 0000000..e0e02dc
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/room/dto/RoomDto.java
@@ -0,0 +1,39 @@
+package opensource.bravest.domain.room.dto;
+
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+public class RoomDto {
+
+ @Getter
+ @NoArgsConstructor
+ public static class CreateRoomRequest {
+ private String title;
+ }
+
+ @Getter
+ @NoArgsConstructor
+ public static class UpdateRoomRequest {
+ private String title;
+ }
+
+ @Getter
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class RoomResponse {
+ private Long id;
+ private String roomCode;
+ private String title;
+ private LocalDateTime createdAt;
+ }
+
+ @Getter
+ @NoArgsConstructor
+ public static class JoinRoomRequest {
+ private String roomCode;
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/room/entity/AnonymousRoom.java b/src/main/java/opensource/bravest/domain/room/entity/AnonymousRoom.java
new file mode 100644
index 0000000..ce9b74e
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/room/entity/AnonymousRoom.java
@@ -0,0 +1,38 @@
+package opensource.bravest.domain.room.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.*;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class AnonymousRoom {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ // ์น๊ตฌ๋ค์๊ฒ ๊ณต์ ํ๋ ์ฝ๋ (์: ABC123)
+ @Column(nullable = false, unique = true, length = 20)
+ private String roomCode;
+
+ // ๋ฐฉ ์ ๋ชฉ (์ ํ)
+ @Column(nullable = false, length = 100)
+ private String title;
+
+ @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true)
+ @Builder.Default
+ private List profiles = new ArrayList<>();
+
+ private LocalDateTime createdAt;
+
+ public void updateTitle(String title) {
+ this.title = title;
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/room/repository/AnonymousRoomRepository.java b/src/main/java/opensource/bravest/domain/room/repository/AnonymousRoomRepository.java
new file mode 100644
index 0000000..d58c838
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/room/repository/AnonymousRoomRepository.java
@@ -0,0 +1,12 @@
+package opensource.bravest.domain.room.repository;
+
+import java.util.Optional;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface AnonymousRoomRepository extends JpaRepository {
+
+ Optional findByRoomCode(String roomCode);
+
+ boolean existsByRoomCode(String roomCode);
+}
diff --git a/src/main/java/opensource/bravest/domain/room/service/RoomService.java b/src/main/java/opensource/bravest/domain/room/service/RoomService.java
new file mode 100644
index 0000000..5ea299b
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/room/service/RoomService.java
@@ -0,0 +1,63 @@
+package opensource.bravest.domain.room.service;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.domain.room.dto.RoomDto;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+import opensource.bravest.domain.room.repository.AnonymousRoomRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class RoomService {
+
+ private final AnonymousRoomRepository anonymousRoomRepository;
+
+ @Transactional
+ public AnonymousRoom createRoom(RoomDto.CreateRoomRequest request) {
+ String roomCode = generateUniqueRoomCode();
+ AnonymousRoom room = AnonymousRoom.builder().title(request.getTitle()).roomCode(roomCode)
+ .createdAt(LocalDateTime.now()).build();
+ return anonymousRoomRepository.save(room);
+ }
+
+ public AnonymousRoom getRoom(Long roomId) {
+ return anonymousRoomRepository.findById(roomId).orElseThrow(() -> new RuntimeException("Room not found"));
+ }
+
+ @Transactional
+ public AnonymousRoom updateRoom(Long roomId, RoomDto.UpdateRoomRequest request) {
+ AnonymousRoom room = getRoom(roomId);
+ room.updateTitle(request.getTitle());
+ return room;
+ }
+
+ @Transactional
+ public void deleteRoom(Long roomId) {
+ if (!anonymousRoomRepository.existsById(roomId)) {
+ throw new RuntimeException("Room not found");
+ }
+ anonymousRoomRepository.deleteById(roomId);
+ }
+
+ public String getInviteCode(Long roomId) {
+ AnonymousRoom room = getRoom(roomId);
+ return room.getRoomCode();
+ }
+
+ public AnonymousRoom joinRoom(String roomCode) {
+ return anonymousRoomRepository.findByRoomCode(roomCode)
+ .orElseThrow(() -> new RuntimeException("Room not found with code: " + roomCode));
+ }
+
+ private String generateUniqueRoomCode() {
+ String roomCode;
+ do {
+ roomCode = UUID.randomUUID().toString().substring(0, 6).toUpperCase();
+ } while (anonymousRoomRepository.existsByRoomCode(roomCode));
+ return roomCode;
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/vote/controller/VoteController.java b/src/main/java/opensource/bravest/domain/vote/controller/VoteController.java
new file mode 100644
index 0000000..bf40633
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/vote/controller/VoteController.java
@@ -0,0 +1,62 @@
+package opensource.bravest.domain.vote.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.domain.vote.dto.VoteDto;
+import opensource.bravest.domain.vote.entity.Vote;
+import opensource.bravest.domain.vote.service.VoteService;
+import opensource.bravest.global.apiPayload.ApiResponse;
+import opensource.bravest.global.apiPayload.code.status.SuccessStatus;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/votes")
+public class VoteController {
+
+ private final VoteService voteService;
+
+ @PostMapping
+ @Operation(summary = "ํฌํ ์์ฑ", description = "์๋ก์ด ํฌํ๋ฅผ ์์ฑํฉ๋๋ค.")
+ public ApiResponse createVote(@RequestBody VoteDto.CreateVoteRequest request) {
+ Vote vote = voteService.createVote(request);
+ // The response DTO needs to be built manually
+ VoteDto.VoteResponse responseDto = voteService.getVoteResult(vote.getId());
+ return ApiResponse.of(SuccessStatus._CREATED, SuccessStatus._CREATED.getMessage(), responseDto);
+ }
+
+ @GetMapping("/{voteId}")
+ @Operation(summary = "ํฌํ ์กฐํ", description = "ID๋ก ํน์ ํฌํ์ ์ ๋ณด๋ฅผ ์กฐํํฉ๋๋ค.")
+ public ApiResponse getVote(@PathVariable Long voteId) {
+ VoteDto.VoteResponse responseDto = voteService.getVoteResult(voteId);
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(), responseDto);
+ }
+
+ @PostMapping("/{voteId}/cast")
+ @Operation(summary = "ํฌํ ์ฐธ์ฌ", description = "ํน์ ํฌํ ํญ๋ชฉ์ ํฌํํฉ๋๋ค.")
+ public ApiResponse> castVote(@PathVariable Long voteId, @RequestBody VoteDto.CastVoteRequest request) {
+ voteService.castVote(voteId, request);
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(), null);
+ }
+
+ @PostMapping("/{voteId}/end")
+ @Operation(summary = "ํฌํ ์ข
๋ฃ", description = "ํน์ ํฌํ๋ฅผ ์ข
๋ฃํฉ๋๋ค.")
+ public ApiResponse> endVote(@PathVariable Long voteId) {
+ voteService.endVote(voteId);
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(), null);
+ }
+
+ @GetMapping("/{voteId}/result")
+ @Operation(summary = "ํฌํ ๊ฒฐ๊ณผ ์กฐํ", description = "์ข
๋ฃ๋ ํฌํ์ ๊ฒฐ๊ณผ๋ฅผ ์กฐํํฉ๋๋ค.")
+ public ApiResponse getVoteResult(@PathVariable Long voteId) {
+ VoteDto.VoteResponse responseDto = voteService.getVoteResult(voteId);
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(), responseDto);
+ }
+
+ @DeleteMapping("/{voteId}")
+ @Operation(summary = "ํฌํ ์ญ์ ", description = "ID๋ก ํน์ ํฌํ๋ฅผ ์ญ์ ํฉ๋๋ค.")
+ public ApiResponse> deleteVote(@PathVariable Long voteId) {
+ voteService.deleteVote(voteId);
+ return ApiResponse.of(SuccessStatus._OK, SuccessStatus._OK.getMessage(), null);
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/vote/dto/VoteDto.java b/src/main/java/opensource/bravest/domain/vote/dto/VoteDto.java
new file mode 100644
index 0000000..7c31382
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/vote/dto/VoteDto.java
@@ -0,0 +1,42 @@
+package opensource.bravest.domain.vote.dto;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+public class VoteDto {
+
+ @Getter
+ @NoArgsConstructor
+ public static class CreateVoteRequest {
+ private Long roomId;
+ private List messages;
+ }
+
+ @Getter
+ @NoArgsConstructor
+ public static class CastVoteRequest {
+ private Long voteOptionId;
+ private Long anonymousProfileId;
+ }
+
+ @Getter
+ @Builder
+ public static class VoteResponse {
+ private Long id;
+ private String title;
+ private boolean isActive;
+ private LocalDateTime createdAt;
+ private List options;
+ }
+
+ @Getter
+ @Builder
+ public static class VoteOptionResponse {
+ private Long id;
+ private String messageContent;
+ private int voteCount;
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/vote/entity/UserVote.java b/src/main/java/opensource/bravest/domain/vote/entity/UserVote.java
new file mode 100644
index 0000000..21c4b9e
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/vote/entity/UserVote.java
@@ -0,0 +1,29 @@
+package opensource.bravest.domain.vote.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class UserVote {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "vote_id", nullable = false)
+ private Vote vote;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "vote_option_id", nullable = false)
+ private VoteOption voteOption;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "anonymous_profile_id", nullable = false)
+ private AnonymousProfile voter;
+}
diff --git a/src/main/java/opensource/bravest/domain/vote/entity/Vote.java b/src/main/java/opensource/bravest/domain/vote/entity/Vote.java
new file mode 100644
index 0000000..cb115db
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/vote/entity/Vote.java
@@ -0,0 +1,39 @@
+package opensource.bravest.domain.vote.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.*;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class Vote {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "room_id", nullable = false)
+ private AnonymousRoom room;
+
+ @Column(nullable = false, length = 100)
+ private String title;
+
+ @Builder.Default
+ @OneToMany(mappedBy = "vote", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List options = new ArrayList<>();
+
+ private boolean isActive;
+
+ private LocalDateTime createdAt;
+
+ public void endVote() {
+ this.isActive = false;
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/vote/entity/VoteOption.java b/src/main/java/opensource/bravest/domain/vote/entity/VoteOption.java
new file mode 100644
index 0000000..50a9ea0
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/vote/entity/VoteOption.java
@@ -0,0 +1,30 @@
+package opensource.bravest.domain.vote.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class VoteOption {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "vote_id", nullable = false)
+ private Vote vote;
+
+ @Column(name = "message_content", nullable = false)
+ private String messageContent;
+
+ @Column(nullable = false)
+ private int voteCount;
+
+ public void incrementVoteCount() {
+ this.voteCount++;
+ }
+}
diff --git a/src/main/java/opensource/bravest/domain/vote/repository/UserVoteRepository.java b/src/main/java/opensource/bravest/domain/vote/repository/UserVoteRepository.java
new file mode 100644
index 0000000..9ead3ee
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/vote/repository/UserVoteRepository.java
@@ -0,0 +1,11 @@
+package opensource.bravest.domain.vote.repository;
+
+import java.util.Optional;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+import opensource.bravest.domain.vote.entity.UserVote;
+import opensource.bravest.domain.vote.entity.Vote;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface UserVoteRepository extends JpaRepository {
+ Optional findByVoteAndVoter(Vote vote, AnonymousProfile voter);
+}
diff --git a/src/main/java/opensource/bravest/domain/vote/repository/VoteOptionRepository.java b/src/main/java/opensource/bravest/domain/vote/repository/VoteOptionRepository.java
new file mode 100644
index 0000000..ccc2c49
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/vote/repository/VoteOptionRepository.java
@@ -0,0 +1,6 @@
+package opensource.bravest.domain.vote.repository;
+
+import opensource.bravest.domain.vote.entity.VoteOption;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface VoteOptionRepository extends JpaRepository {}
diff --git a/src/main/java/opensource/bravest/domain/vote/repository/VoteRepository.java b/src/main/java/opensource/bravest/domain/vote/repository/VoteRepository.java
new file mode 100644
index 0000000..8dcfded
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/vote/repository/VoteRepository.java
@@ -0,0 +1,6 @@
+package opensource.bravest.domain.vote.repository;
+
+import opensource.bravest.domain.vote.entity.Vote;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface VoteRepository extends JpaRepository {}
diff --git a/src/main/java/opensource/bravest/domain/vote/service/VoteService.java b/src/main/java/opensource/bravest/domain/vote/service/VoteService.java
new file mode 100644
index 0000000..0a05107
--- /dev/null
+++ b/src/main/java/opensource/bravest/domain/vote/service/VoteService.java
@@ -0,0 +1,101 @@
+package opensource.bravest.domain.vote.service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.domain.profile.entity.AnonymousProfile;
+import opensource.bravest.domain.profile.repository.AnonymousProfileRepository;
+import opensource.bravest.domain.room.entity.AnonymousRoom;
+import opensource.bravest.domain.room.repository.AnonymousRoomRepository;
+import opensource.bravest.domain.vote.dto.VoteDto;
+import opensource.bravest.domain.vote.entity.UserVote;
+import opensource.bravest.domain.vote.entity.Vote;
+import opensource.bravest.domain.vote.entity.VoteOption;
+import opensource.bravest.domain.vote.repository.UserVoteRepository;
+import opensource.bravest.domain.vote.repository.VoteRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class VoteService {
+
+ private final VoteRepository voteRepository;
+ private final UserVoteRepository userVoteRepository;
+ private final AnonymousRoomRepository anonymousRoomRepository;
+ private final AnonymousProfileRepository anonymousProfileRepository;
+
+ @Transactional
+ public Vote createVote(VoteDto.CreateVoteRequest request) {
+ AnonymousRoom room = anonymousRoomRepository.findById(request.getRoomId())
+ .orElseThrow(() -> new RuntimeException("Room not found"));
+
+ Vote vote = Vote.builder().room(room).title(room.getTitle()).isActive(true).createdAt(LocalDateTime.now())
+ .build();
+
+ List options = request.getMessages().stream()
+ .map(message -> VoteOption.builder().vote(vote).messageContent(message).voteCount(0).build())
+ .collect(Collectors.toList());
+
+ vote.getOptions().addAll(options);
+
+ return voteRepository.save(vote);
+ }
+
+ @Transactional
+ public void castVote(Long voteId, VoteDto.CastVoteRequest request) {
+ Vote vote = voteRepository.findById(voteId).orElseThrow(() -> new RuntimeException("Vote not found"));
+ if (!vote.isActive()) {
+ throw new RuntimeException("Vote is not active");
+ }
+
+ AnonymousProfile voter = anonymousProfileRepository.findById(request.getAnonymousProfileId())
+ .orElseThrow(() -> new RuntimeException("AnonymousProfile not found"));
+
+ if (userVoteRepository.findByVoteAndVoter(vote, voter).isPresent()) {
+ throw new RuntimeException("User has already voted");
+ }
+
+ VoteOption voteOption = vote.getOptions().stream()
+ .filter(option -> option.getId().equals(request.getVoteOptionId())).findFirst()
+ .orElseThrow(() -> new RuntimeException("VoteOption not found"));
+
+ voteOption.incrementVoteCount();
+
+ UserVote userVote = UserVote.builder().vote(vote).voteOption(voteOption).voter(voter).build();
+ userVoteRepository.save(userVote);
+ }
+
+ @Transactional
+ public void endVote(Long voteId) {
+ Vote vote = voteRepository.findById(voteId).orElseThrow(() -> new RuntimeException("Vote not found"));
+ vote.endVote();
+ }
+
+ public VoteDto.VoteResponse getVoteResult(Long voteId) {
+ Vote vote = voteRepository.findById(voteId).orElseThrow(() -> new RuntimeException("Vote not found"));
+
+ return buildVoteResponse(vote);
+ }
+
+ @Transactional
+ public void deleteVote(Long voteId) {
+ if (!voteRepository.existsById(voteId)) {
+ throw new RuntimeException("Vote not found");
+ }
+ voteRepository.deleteById(voteId);
+ }
+
+ private VoteDto.VoteResponse buildVoteResponse(Vote vote) {
+ List optionResponses = vote.getOptions().stream()
+ .map(option -> VoteDto.VoteOptionResponse.builder().id(option.getId())
+ .messageContent(option.getMessageContent()).voteCount(option.getVoteCount())
+ .build())
+ .collect(Collectors.toList());
+
+ return VoteDto.VoteResponse.builder().id(vote.getId()).title(vote.getTitle()).isActive(vote.isActive())
+ .createdAt(vote.getCreatedAt()).options(optionResponses).build();
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/apiPayload/ApiResponse.java b/src/main/java/opensource/bravest/global/apiPayload/ApiResponse.java
new file mode 100644
index 0000000..859bbaa
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/apiPayload/ApiResponse.java
@@ -0,0 +1,43 @@
+package opensource.bravest.global.apiPayload;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import opensource.bravest.global.apiPayload.code.BaseCode;
+import opensource.bravest.global.apiPayload.code.BaseErrorCode;
+import opensource.bravest.global.apiPayload.code.ErrorReasonDto;
+import opensource.bravest.global.apiPayload.code.status.SuccessStatus;
+
+@Getter
+@AllArgsConstructor
+@JsonPropertyOrder({"isSuccess", "code", "message", "data"})
+public class ApiResponse {
+ @JsonProperty("isSuccess")
+ private final boolean isSuccess;
+
+ private final String code;
+ private final String message;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private T data;
+
+ public static ApiResponse onSuccess(T data) {
+ return new ApiResponse<>(true, SuccessStatus._OK.getCode(), SuccessStatus._OK.getMessage(), data);
+ }
+
+ public static ApiResponse of(BaseCode code, String message, T data) {
+ return new ApiResponse<>(true, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(),
+ data);
+ }
+
+ public static ApiResponse onFailure(BaseErrorCode errorCode, T data) {
+ ErrorReasonDto reason = errorCode.getReasonHttpStatus();
+ return new ApiResponse<>(reason.getIsSuccess(), reason.getCode(), reason.getMessage(), data);
+ }
+
+ public static ApiResponse onFailure(String code, String message, T data) {
+ return new ApiResponse<>(false, code, message, data);
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/apiPayload/code/BaseCode.java b/src/main/java/opensource/bravest/global/apiPayload/code/BaseCode.java
new file mode 100644
index 0000000..81444ee
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/apiPayload/code/BaseCode.java
@@ -0,0 +1,7 @@
+package opensource.bravest.global.apiPayload.code;
+
+public interface BaseCode {
+ ReasonDto getReason();
+
+ ReasonDto getReasonHttpStatus();
+}
diff --git a/src/main/java/opensource/bravest/global/apiPayload/code/BaseErrorCode.java b/src/main/java/opensource/bravest/global/apiPayload/code/BaseErrorCode.java
new file mode 100644
index 0000000..6607ad0
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/apiPayload/code/BaseErrorCode.java
@@ -0,0 +1,7 @@
+package opensource.bravest.global.apiPayload.code;
+
+public interface BaseErrorCode {
+ ErrorReasonDto getReason();
+
+ ErrorReasonDto getReasonHttpStatus();
+}
diff --git a/src/main/java/opensource/bravest/global/apiPayload/code/ErrorReasonDto.java b/src/main/java/opensource/bravest/global/apiPayload/code/ErrorReasonDto.java
new file mode 100644
index 0000000..2172a3c
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/apiPayload/code/ErrorReasonDto.java
@@ -0,0 +1,14 @@
+package opensource.bravest.global.apiPayload.code;
+
+import lombok.Builder;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@Builder
+public class ErrorReasonDto {
+ private HttpStatus httpStatus;
+ private final Boolean isSuccess;
+ private final String message;
+ private final String code;
+}
diff --git a/src/main/java/opensource/bravest/global/apiPayload/code/ReasonDto.java b/src/main/java/opensource/bravest/global/apiPayload/code/ReasonDto.java
new file mode 100644
index 0000000..e8ba0a0
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/apiPayload/code/ReasonDto.java
@@ -0,0 +1,14 @@
+package opensource.bravest.global.apiPayload.code;
+
+import lombok.Builder;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@Builder
+public class ReasonDto {
+ private HttpStatus httpStatus;
+ private final Boolean isSuccess;
+ private final String code;
+ private final String message;
+}
diff --git a/src/main/java/opensource/bravest/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/opensource/bravest/global/apiPayload/code/status/ErrorStatus.java
new file mode 100644
index 0000000..3b7b59e
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/apiPayload/code/status/ErrorStatus.java
@@ -0,0 +1,41 @@
+package opensource.bravest.global.apiPayload.code.status;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import opensource.bravest.global.apiPayload.code.BaseErrorCode;
+import opensource.bravest.global.apiPayload.code.ErrorReasonDto;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum ErrorStatus implements BaseErrorCode {
+ _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "์๋ฒ ์๋ฌ, ๊ด๋ฆฌ์์๊ฒ ๋ฌธ์ ๋ฐ๋๋๋ค."), _BAD_REQUEST(
+ HttpStatus.BAD_REQUEST, "COMMON400",
+ "์๋ชป๋ ์์ฒญ์
๋๋ค."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "์ธ์ฆ์ด ํ์ํฉ๋๋ค."), _FORBIDDEN(
+ HttpStatus.FORBIDDEN, "COMMON403",
+ "๊ธ์ง๋ ์์ฒญ์
๋๋ค."), _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404",
+ "์์ฒญํ ๋ฆฌ์์ค๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค."), _FAMILY_NOT_FOUND(HttpStatus.NOT_FOUND,
+ "FAMILY404", "์ ํจํ์ง ์์ ์ด๋ ์ฝ๋์
๋๋ค."), _USER_NOT_FOUND(
+ HttpStatus.NOT_FOUND, "USER404",
+ "์ฌ์ฉ์๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค."), _CHATROOM_NOT_FOUND(
+ HttpStatus.NOT_FOUND,
+ "USER404",
+ "์ฑํ
๋ฐฉ์ ์ฐพ์ ์ ์์ต๋๋ค."), _CHATLIST_NOT_FOUND(
+ HttpStatus.NOT_FOUND,
+ "USER404",
+ "๋ฆฌ์คํธ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค."),;
+
+ private final HttpStatus httpStatus;
+ private final String code;
+ private final String message;
+
+ @Override
+ public ErrorReasonDto getReason() {
+ return ErrorReasonDto.builder().isSuccess(false).message(message).code(code).build();
+ }
+
+ @Override
+ public ErrorReasonDto getReasonHttpStatus() {
+ return ErrorReasonDto.builder().httpStatus(httpStatus).isSuccess(false).code(code).message(message).build();
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/apiPayload/code/status/SuccessStatus.java b/src/main/java/opensource/bravest/global/apiPayload/code/status/SuccessStatus.java
new file mode 100644
index 0000000..25d02ed
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/apiPayload/code/status/SuccessStatus.java
@@ -0,0 +1,26 @@
+package opensource.bravest.global.apiPayload.code.status;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import opensource.bravest.global.apiPayload.code.BaseCode;
+import opensource.bravest.global.apiPayload.code.ReasonDto;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum SuccessStatus implements BaseCode {
+ _OK(HttpStatus.OK, "COMMON2000", "์ฑ๊ณต์
๋๋ค."), _CREATED(HttpStatus.CREATED, "COMMON201", "์์ฑ๋์์ต๋๋ค."),;
+ private final HttpStatus httpStatus;
+ private final String code;
+ private final String message;
+
+ @Override
+ public ReasonDto getReason() {
+ return ReasonDto.builder().isSuccess(true).message(message).code(code).build();
+ }
+
+ @Override
+ public ReasonDto getReasonHttpStatus() {
+ return ReasonDto.builder().httpStatus(httpStatus).isSuccess(true).code(code).message(message).build();
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/config/OpenApiConfig.java b/src/main/java/opensource/bravest/global/config/OpenApiConfig.java
new file mode 100644
index 0000000..c791d78
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/config/OpenApiConfig.java
@@ -0,0 +1,31 @@
+package opensource.bravest.global.config;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.ExternalDocumentation;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+ private static final String SECURITY_SCHEME_NAME = "bearerAuth";
+
+ @Bean
+ public OpenAPI baseOpenAPI() {
+ return new OpenAPI()
+ // 1) ์ ์ญ์ผ๋ก "์ด API๋ ์ด ์ธ์ฆ ๋ฐฉ์์ ์ด๋ค" ์ ์ธ
+ .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME))
+ // 2) JWT Bearer ์คํค๋ง ์ ์
+ .components(new Components().addSecuritySchemes(SECURITY_SCHEME_NAME,
+ new SecurityScheme().name(SECURITY_SCHEME_NAME).type(SecurityScheme.Type.HTTP)
+ .scheme("bearer").bearerFormat("JWT")))
+ .info(new Info().title("openSource Bravest API").description("openSource Bravest ๋ฐฑ์๋ API ๋ฌธ์")
+ .version("v1.0.0").license(new License().name("MIT")))
+ .externalDocs(new ExternalDocumentation().description("README"));
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/config/SecurityConfig.java b/src/main/java/opensource/bravest/global/config/SecurityConfig.java
new file mode 100644
index 0000000..e087085
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/config/SecurityConfig.java
@@ -0,0 +1,117 @@
+package opensource.bravest.global.config;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.global.apiPayload.code.status.ErrorStatus;
+import opensource.bravest.global.security.jwt.JwtAuthenticationFilter;
+import opensource.bravest.global.security.jwt.JwtTokenProvider;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.*;
+
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private final JwtTokenProvider jwtTokenProvider;
+
+ // Swagger
+ private static final String[] SWAGGER = {"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html"};
+
+ // ๋ก๊ทธ์ธ/ํ ํฐ ๊ตํ/๋ฆฌ๋ค์ด๋ ํธ/ํฌ์ค์ฒดํฌ ๋ฑ ๊ณต๊ฐ ๊ฒฝ๋ก
+ private static final String[] PUBLIC = {"/", "/actuator/health", "/api/auth/**", // ์นด์นด์ค ์ฝ๋ ๊ตํ API ๋ฑ
+ "/oauth2/**", "/login/**", "/login/oauth2/**", "/api/test/auth/**", "/rooms/**", "/chatlists/**",
+ "/anonymous-profiles/**", "/votes/**", "/ws-connect/**", "/chat-test", "/pub/**", "/sub/**"};
+
+ // ์ ์ ๋ฆฌ์์ค
+ private static final String[] STATIC = {"/favicon.ico", "/assets/**", "/css/**", "/js/**", "/images/**"};
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ // Jwt ํํฐ์์ ๊ฑด๋๋ธ(์คํต) ๊ฒฝ๋ก ํจํด ํตํฉ
+ List skip = new ArrayList<>();
+ addAll(skip, SWAGGER);
+ addAll(skip, PUBLIC);
+ addAll(skip, STATIC);
+
+ JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(jwtTokenProvider, skip);
+
+ http
+ // REST API ๊ธฐ๋ณธ ์ธํ
+ .csrf(csrf -> csrf.disable()).cors(Customizer.withDefaults())
+ .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .httpBasic(basic -> basic.disable()).formLogin(form -> form.disable())
+ .logout(lo -> lo.disable()).requestCache(cache -> cache.disable())
+
+ // ๊ถํ ๊ท์น
+ .authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // CORS
+ // preflight
+ // ํ์ฉ
+ .requestMatchers(SWAGGER).permitAll().requestMatchers(PUBLIC).permitAll()
+ .requestMatchers(STATIC).permitAll().anyRequest().authenticated())
+
+ // ์ธ์ฆ/์ธ๊ฐ ์คํจ ๊ณตํต ์๋ต(JSON) - ApiResponse ํ์
+ .exceptionHandling(ex -> ex.authenticationEntryPoint((req, res, ex1) -> {
+ ErrorStatus errorStatus = ErrorStatus._UNAUTHORIZED;
+ res.setStatus(errorStatus.getReasonHttpStatus().getHttpStatus().value());
+ res.setContentType("application/json;charset=UTF-8");
+ try (PrintWriter w = res.getWriter()) {
+ w.write(String.format(
+ "{\"isSuccess\":false,\"code\":\"%s\",\"message\":\"%s\",\"data\":null}",
+ errorStatus.getCode(), errorStatus.getMessage()));
+ }
+ }).accessDeniedHandler((req, res, ex2) -> {
+ ErrorStatus errorStatus = ErrorStatus._FORBIDDEN;
+ res.setStatus(errorStatus.getReasonHttpStatus().getHttpStatus().value());
+ res.setContentType("application/json;charset=UTF-8");
+ try (PrintWriter w = res.getWriter()) {
+ w.write(String.format(
+ "{\"isSuccess\":false,\"code\":\"%s\",\"message\":\"%s\",\"data\":null}",
+ errorStatus.getCode(), errorStatus.getMessage()));
+ }
+ }))
+
+ // JWT ํํฐ ๋ฑ๋ก(UsernamePasswordAuthenticationFilter ์)
+ .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+
+ private static void addAll(List target, String[] arr) {
+ for (String s : arr)
+ target.add(s);
+ }
+
+ // CORS (๊ฐ๋ฐ์ฉ: ํ์ ์ ๋๋ฉ์ธ ๊ณ ์ /์ถ์)
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration c = new CorsConfiguration();
+ c.setAllowedOrigins(List.of("http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5173"));
+ c.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
+ c.setAllowedHeaders(List.of("*"));
+ c.setExposedHeaders(List.of("Authorization", "Location"));
+ c.setAllowCredentials(true);
+ c.setMaxAge(3600L);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", c);
+ return source;
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/config/ValkeyConfig.java b/src/main/java/opensource/bravest/global/config/ValkeyConfig.java
new file mode 100644
index 0000000..2be6ffa
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/config/ValkeyConfig.java
@@ -0,0 +1,15 @@
+package opensource.bravest.global.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+@Configuration
+public class ValkeyConfig {
+
+ @Bean
+ public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
+ return new StringRedisTemplate(connectionFactory);
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/config/WebSocketConfig.java b/src/main/java/opensource/bravest/global/config/WebSocketConfig.java
new file mode 100644
index 0000000..449d017
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/config/WebSocketConfig.java
@@ -0,0 +1,34 @@
+package opensource.bravest.global.config;
+
+import lombok.RequiredArgsConstructor;
+import opensource.bravest.global.handler.StompHandler;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.messaging.simp.config.ChannelRegistration;
+import org.springframework.messaging.simp.config.MessageBrokerRegistry;
+import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
+import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
+import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
+
+@Configuration
+@RequiredArgsConstructor
+@EnableWebSocketMessageBroker
+public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
+
+ private final StompHandler stompHandler;
+
+ @Override
+ public void registerStompEndpoints(StompEndpointRegistry registry) {
+ registry.addEndpoint("/ws-connect").setAllowedOriginPatterns("*").withSockJS();
+ }
+
+ @Override
+ public void configureMessageBroker(MessageBrokerRegistry registry) {
+ registry.enableSimpleBroker("/subs");
+ registry.setApplicationDestinationPrefixes("/pubs");
+ }
+
+ @Override
+ public void configureClientInboundChannel(ChannelRegistration registration) {
+ registration.interceptors(stompHandler);
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/exception/CustomException.java b/src/main/java/opensource/bravest/global/exception/CustomException.java
new file mode 100644
index 0000000..1b8ab2e
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/exception/CustomException.java
@@ -0,0 +1,16 @@
+package opensource.bravest.global.exception;
+
+import lombok.Getter;
+import opensource.bravest.global.apiPayload.code.BaseErrorCode;
+
+/** ์๋น์ค/๋๋ฉ์ธ ๋ ์ด์ด์์ ํ์คํ๋ ์๋ฌ์ฝ๋๋ฅผ ๋์ง๊ธฐ ์ํ ์์ธ */
+@Getter
+public class CustomException extends RuntimeException {
+
+ private final BaseErrorCode errorCode;
+
+ public CustomException(BaseErrorCode errorCode) {
+ super(errorCode.getReason().getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/exception/GlobalExceptionHandler.java b/src/main/java/opensource/bravest/global/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..be2b96b
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/exception/GlobalExceptionHandler.java
@@ -0,0 +1,52 @@
+package opensource.bravest.global.exception;
+
+import lombok.extern.slf4j.Slf4j;
+import opensource.bravest.global.apiPayload.ApiResponse;
+import opensource.bravest.global.apiPayload.code.status.ErrorStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(CustomException.class)
+ public ResponseEntity> handleCustomException(CustomException e) {
+ log.warn("CustomException: {}", e.getMessage());
+ return ResponseEntity.status(e.getErrorCode().getReasonHttpStatus().getHttpStatus())
+ .body(ApiResponse.onFailure(e.getErrorCode(), null));
+ }
+
+ @ExceptionHandler(RuntimeException.class)
+ public ResponseEntity> handleRuntimeException(RuntimeException e) {
+ String message = e.getMessage();
+
+ // ๋ฉ์์ง์ ๋ฐ๋ผ ์ ์ ํ ์๋ฌ ์ฝ๋ ๊ฒฐ์
+ if (message != null) {
+ if (message.contains("์ ํจํ์ง ์์ ์ด๋ ์ฝ๋") || message.contains("๊ฐ์กฑ์ ์ฐพ์ ์ ์์ต๋๋ค")) {
+ log.warn("Family not found: {}", message);
+ return ResponseEntity.status(ErrorStatus._FAMILY_NOT_FOUND.getReasonHttpStatus().getHttpStatus())
+ .body(ApiResponse.onFailure(ErrorStatus._FAMILY_NOT_FOUND, null));
+ }
+
+ if (message.contains("์ฌ์ฉ์๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค")) {
+ log.warn("User not found: {}", message);
+ return ResponseEntity.status(ErrorStatus._USER_NOT_FOUND.getReasonHttpStatus().getHttpStatus())
+ .body(ApiResponse.onFailure(ErrorStatus._USER_NOT_FOUND, null));
+ }
+ }
+
+ // ๊ธฐ๋ณธ๊ฐ: 500 Internal Server Error
+ log.error("RuntimeException: ", e);
+ return ResponseEntity.status(ErrorStatus._INTERNAL_SERVER_ERROR.getReasonHttpStatus().getHttpStatus())
+ .body(ApiResponse.onFailure(ErrorStatus._INTERNAL_SERVER_ERROR, null));
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity> handleException(Exception e) {
+ log.error("Unexpected exception: ", e);
+ return ResponseEntity.status(ErrorStatus._INTERNAL_SERVER_ERROR.getReasonHttpStatus().getHttpStatus())
+ .body(ApiResponse.onFailure(ErrorStatus._INTERNAL_SERVER_ERROR, null));
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/handler/StompHandler.java b/src/main/java/opensource/bravest/global/handler/StompHandler.java
new file mode 100644
index 0000000..ff026ed
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/handler/StompHandler.java
@@ -0,0 +1,142 @@
+package opensource.bravest.global.handler;
+
+import java.security.Principal;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import opensource.bravest.domain.profile.repository.AnonymousProfileRepository;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.messaging.Message;
+import org.springframework.messaging.MessageChannel;
+import org.springframework.messaging.simp.stomp.StompCommand;
+import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
+import org.springframework.messaging.support.ChannelInterceptor;
+import org.springframework.messaging.support.MessageHeaderAccessor;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class StompHandler implements ChannelInterceptor {
+
+ private final AnonymousProfileRepository anonymousProfileRepository;
+ private final StringRedisTemplate redisTemplate;
+
+ private static final String USER_SUB_KEY_PREFIX = "ws:subs:user:"; // + anonymousId
+ private static final String METRIC_TOTAL_SUB = "ws:metrics:sub:total";
+ private static final String METRIC_DUP_SUB = "ws:metrics:sub:duplicate";
+
+ @Override
+ public Message> preSend(Message> message, MessageChannel channel) {
+ StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
+
+ if (accessor == null) {
+ return message;
+ }
+
+ StompCommand command = accessor.getCommand();
+
+ // 1) CONNECT: anonymousId๋ฅผ Principal๋ก ์ค์
+ if (StompCommand.CONNECT.equals(command)) {
+ String anonymousId = accessor.getFirstNativeHeader("anonymousId");
+ if (anonymousId == null || anonymousId.isBlank()) {
+ log.warn("STOMP CONNECT: anonymousId missing");
+ throw new IllegalArgumentException("anonymousId header is required");
+ }
+
+ anonymousProfileRepository.findById(Long.valueOf(anonymousId)).ifPresentOrElse(member -> {
+ Authentication auth = new UsernamePasswordAuthenticationToken(anonymousId, null,
+ List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ accessor.setUser(auth);
+ log.info("STOMP CONNECT: anonymousId={} principal set", anonymousId);
+ }, () -> {
+ log.warn("STOMP CONNECT: invalid anonymousId={}", anonymousId);
+ throw new IllegalArgumentException("Invalid anonymousId");
+ });
+ }
+
+ // 2) SUBSCRIBE: Redis๋ฅผ ์ฌ์ฉํด anonymousId ๊ธฐ์ค ์ค๋ณต ๊ตฌ๋
๋ฐฉ์ง + ๋ฉํธ๋ฆญ ๊ธฐ๋ก
+ if (StompCommand.SUBSCRIBE.equals(command)) {
+ Principal user = accessor.getUser();
+ if (user == null) {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null) {
+ accessor.setUser(auth);
+ user = auth;
+ }
+ }
+
+ String destination = accessor.getDestination();
+
+ if (user != null && destination != null) {
+ String anonymousId = user.getName();
+ String key = USER_SUB_KEY_PREFIX + anonymousId;
+
+ log.info("[SUBSCRIBE] handling: anonymousId={}, destination={}, key={}", anonymousId, destination, key);
+
+ try {
+ Long total = redisTemplate.opsForValue().increment(METRIC_TOTAL_SUB);
+ Long added = redisTemplate.opsForSet().add(key, destination);
+ redisTemplate.expire(key, java.time.Duration.ofHours(1));
+
+ log.info("[SUBSCRIBE] redis result: total={}, added={}", total, added);
+
+ if (added != null && added == 0L) {
+ Long dup = redisTemplate.opsForValue().increment(METRIC_DUP_SUB);
+ log.warn("[SUBSCRIBE] duplicate detected: anonymousId={}, dest={}, dupCount={}", anonymousId,
+ destination, dup);
+ return null;
+ }
+
+ log.info("[SUBSCRIBE] stored in Redis: key={}, member={}", key, destination);
+
+ } catch (Exception e) {
+ log.error("Redis error while handling SUBSCRIBE", e);
+ }
+ } else {
+ log.warn("[SUBSCRIBE] skipped: user or destination is null (user={}, dest={})", user, destination);
+ }
+ }
+
+ // 3) SEND: Principal ๋น์ด ์์ผ๋ฉด SecurityContext์์ ๋ณต๊ตฌ
+ if (StompCommand.SEND.equals(command)) {
+ Principal user = accessor.getUser();
+ if (user == null) {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null) {
+ accessor.setUser(auth);
+ }
+ }
+ }
+
+ // 4) DISCONNECT: ์ ์ ๋ณ ๊ตฌ๋
ํค๋ฅผ ์ ๋ฆฌํ ์ง ์ฌ๋ถ (์ต์
)
+ // - ์ ์ฒด ๋ฐฉ ์ ์ฒด ์ ์ ์๊ฐ ํฌ์ง ์๋ค๋ฉด TTL๋ง์ผ๋ก๋ ์ถฉ๋ถ.
+ if (StompCommand.DISCONNECT.equals(command)) {
+ Principal user = accessor.getUser();
+ if (user == null) {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null) {
+ user = auth;
+ }
+ }
+ if (user != null) {
+ String anonymousId = user.getName();
+ String key = USER_SUB_KEY_PREFIX + anonymousId;
+ try {
+ // ์์ ํ ์ ๋ฆฌํ๊ณ ์ถ์ผ๋ฉด delete
+ redisTemplate.delete(key);
+ log.info("DISCONNECT: cleared subscriptions for anonymousId={}", anonymousId);
+ } catch (Exception e) {
+ log.error("Redis error while handling DISCONNECT", e);
+ }
+ }
+ }
+
+ return message;
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/opensource/bravest/global/security/jwt/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..89e00b7
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/security/jwt/JwtAuthenticationFilter.java
@@ -0,0 +1,71 @@
+package opensource.bravest.global.security.jwt;
+
+import io.jsonwebtoken.Claims;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/**
+ * JWT๊ฐ ํ์ํ ๋ณดํธ ๊ฒฝ๋ก์๋ง ๋์ํ๋๋ก ๋ง๋ ํํฐ. - ํ์ดํธ๋ฆฌ์คํธ(permitAll) ๊ฒฝ๋ก์ OPTIONS ํ๋ฆฌํ๋ผ์ดํธ๋ ํํฐ๋ฅผ ๊ฑด๋๋. - ํ ํฐ์ด ์ ํจํ๋ฉด SecurityContext ์ค์ , ์๋๋ฉด ์ฒด์ธ
+ * ์งํ (401์ EntryPoint๊ฐ ์ฒ๋ฆฌ)
+ */
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtTokenProvider jwtTokenProvider;
+ private final List skipPatterns; // ํํฐ๋ฅผ ์คํตํ ๊ฒฝ๋ก ํจํด๋ค(ant style)
+ private final AntPathMatcher matcher = new AntPathMatcher();
+
+ public JwtAuthenticationFilter(JwtTokenProvider provider, Collection skipPatterns) {
+ this.jwtTokenProvider = provider;
+ this.skipPatterns = skipPatterns == null ? List.of() : List.copyOf(skipPatterns);
+ }
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ // 1) CORS preflight๋ ํญ์ ์คํต
+ if ("OPTIONS".equalsIgnoreCase(request.getMethod()))
+ return true;
+
+ // 2) ํ์ดํธ๋ฆฌ์คํธ ํจํด์ ์คํต
+ String path = request.getServletPath();
+ for (String p : skipPatterns) {
+ if (matcher.match(p, path))
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+
+ String header = request.getHeader(HttpHeaders.AUTHORIZATION);
+
+ if (header != null && header.startsWith("Bearer ")) {
+ String token = header.substring(7);
+ try {
+ Claims claims = jwtTokenProvider.parseClaims(token);
+ String subject = claims.getSubject();
+ if (subject != null && SecurityContextHolder.getContext().getAuthentication() == null) {
+ // ํ์ ์ roles/authorities๋ฅผ claims์์ ๊บผ๋ด์ ๋ฃ์ด๋ ๋จ
+ var auth = new UsernamePasswordAuthenticationToken(subject, null, Collections.emptyList());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ }
+ } catch (Exception ignored) {
+ // ์ ํจํ์ง ์์ผ๋ฉด ๊ทธ๋ฅ ํต๊ณผ -> ์ต์ข
์ ์ผ๋ก EntryPoint๊ฐ 401 ์๋ต ์ฒ๋ฆฌ
+ }
+ }
+
+ chain.doFilter(request, response);
+ }
+}
diff --git a/src/main/java/opensource/bravest/global/security/jwt/JwtTokenProvider.java b/src/main/java/opensource/bravest/global/security/jwt/JwtTokenProvider.java
new file mode 100644
index 0000000..6640e7a
--- /dev/null
+++ b/src/main/java/opensource/bravest/global/security/jwt/JwtTokenProvider.java
@@ -0,0 +1,79 @@
+package opensource.bravest.global.security.jwt;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Map;
+import javax.crypto.SecretKey;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+@Component
+public class JwtTokenProvider {
+
+ @Value("${jwt.secret}")
+ private String secret;
+
+ @Value("${jwt.access-token-validity-seconds}")
+ private long accessValidity;
+
+ @Value("${jwt.refresh-token-validity-seconds}")
+ private long refreshValidity;
+
+ private SecretKey key;
+
+ @PostConstruct
+ void init() {
+ if (secret == null || secret.isBlank()) {
+ throw new IllegalStateException("jwt.secret is not configured. Check your application.yml / env.");
+ }
+
+ byte[] keyBytes;
+ try {
+ // secret์ด Base64๋ฉด ์ฌ๊ธฐ์ ์ ์ ๋์ฝ๋ฉ
+ keyBytes = Decoders.BASE64.decode(secret);
+ } catch (IllegalArgumentException e) {
+ // Base64 ์๋๋ฉด ๊ทธ๋ฅ ๋ฌธ์์ด ๋ฐ์ดํธ๋ก ์ฌ์ฉ
+ keyBytes = secret.getBytes(StandardCharsets.UTF_8);
+ }
+
+ this.key = Keys.hmacShaKeyFor(keyBytes);
+ }
+
+ public String createAccessToken(String subject, Map claims) {
+ Instant now = Instant.now();
+ return Jwts.builder().subject(subject).claims(claims).issuedAt(Date.from(now))
+ .expiration(Date.from(now.plusSeconds(accessValidity))).signWith(key).compact();
+ }
+
+ public String createRefreshToken(String subject) {
+ Instant now = Instant.now();
+ return Jwts.builder().subject(subject).issuedAt(Date.from(now))
+ .expiration(Date.from(now.plusSeconds(refreshValidity))).signWith(key).compact();
+ }
+
+ public Long getIdFromToken(String token) {
+ Claims claims = Jwts.parser().verifyWith(key) // init()์์ ๋ง๋ key ์ฌ์ฌ์ฉ
+ .build().parseSignedClaims(token).getPayload();
+
+ return claims.get("id", Long.class);
+ }
+
+ public boolean validateToken(String token) {
+ try {
+ Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public Claims parseClaims(String token) {
+ return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
+ }
+}
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 087fee5..eeec720 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -14,9 +14,19 @@ spring:
show_sql: true
dialect: org.hibernate.dialect.MySQLDialect
+ data:
+ redis:
+ host: bravest-keystore
+ port: 6379
+ database: 0
+
server:
port: 8080
servlet:
encoding:
charset: UTF-8
force: true
+jwt:
+ secret: ${JWT_SECRET}
+ access-token-validity-seconds: ${JWT_ACCESS_TOKEN_VALIDITY_SECONDS}
+ refresh-token-validity-seconds: ${JWT_REFRESH_TOKEN_VALIDITY_SECONDS}
\ No newline at end of file
diff --git a/src/test/java/opensource/bravest/BravestApplicationTests.java b/src/test/java/opensource/bravest/BravestApplicationTests.java
index 6494944..ca77007 100644
--- a/src/test/java/opensource/bravest/BravestApplicationTests.java
+++ b/src/test/java/opensource/bravest/BravestApplicationTests.java
@@ -6,8 +6,6 @@
@SpringBootTest
class BravestApplicationTests {
- @Test
- void contextLoads() {
- }
-
+ @Test
+ void contextLoads() {}
}