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() {} }