diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000000..1bfeef0105
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,205 @@
+name: Deploy Server
+
+on:
+ push:
+ branches:
+ - develop-back
+ - main
+permissions:
+ contents: read
+
+jobs:
+ setup-env:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Create env file
+ run: |
+ echo DB_ENDPOINT=${{ secrets.DB_ENDPOINT }} >> .env
+ echo DB_NAME=${{ secrets.DB_NAME }} >> .env
+ echo MYSQL_USERNAME=${{ secrets.MYSQL_USERNAME }} >> .env
+ echo MYSQL_PASSWORD=${{ secrets.MYSQL_PASSWORD }} >> .env
+ echo JWT_SECRET=${{ secrets.JWT_SECRET }} >> .env
+ echo JWT_ACCESS_EXPIRATION_TIME=${{ secrets.JWT_ACCESS_EXPIRATION_TIME }} >> .env
+ echo JWT_REFRESH_EXPIRATION_TIME=${{ secrets.JWT_REFRESH_EXPIRATION_TIME }} >> .env
+ echo HMAC_SECRET=${{ secrets.HMAC_SECRET }} >> .env
+ echo HMAC_ALGORITHM=${{ secrets.HMAC_ALGORITHM }} >> .env
+ echo DeepL_API_KEY=${{ secrets.DeepL_API_KEY }} >> .env
+ echo TEST_KEY=${{ secrets.TEST_KEY }} >> .env
+ echo Azure_API_KEY=${{ secrets.Azure_API_KEY }} >> .env
+ echo REDIS_HOST=${{ secrets.REDIS_HOST }} >> .env
+ echo REDIS_PORT=${{ secrets.REDIS_PORT }} >> .env
+ echo S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY }} >> .env
+ echo S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY }} >> .env
+ echo S3_SECRET_KEY=${{ secrets.SECRET_KEY_BASE }} >> .env
+ echo S3_SECRET_KEY=${{ secrets.SERVER_NAME }} >> .env
+
+ - name: Copy .env to EC2
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.AWS_HOST }}
+ username: ubuntu
+ key: ${{ secrets.AWS_KEY }}
+ source: "/github/workspace/.env"
+ target: "/home/ubuntu/capstone"
+
+ - name: Copy docker-compose.yaml to EC2
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.AWS_HOST }}
+ username: ubuntu
+ key: ${{ secrets.AWS_KEY }}
+ source: "/github/workspace/back/docker-compose.yml"
+ target: "/home/ubuntu/capstone"
+
+ - name: action-slack
+ uses: 8398a7/action-slack@v3
+ with:
+ status: ${{ job.status }}
+ author_name: PetBuddy Github
+ fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ if: always()
+
+ build-spring:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Build and push Spring
+ uses: docker/build-push-action@v5
+ with:
+ context: ./back
+ push: true
+ tags: ${{ secrets.DOCKER_REPO }}/spring:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: action-slack
+ uses: 8398a7/action-slack@v3
+ with:
+ status: ${{ job.status }}
+ author_name: PetBuddy Github
+ fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ if: always()
+
+ build-nginx:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Build and push Nginx
+ uses: docker/build-push-action@v5
+ with:
+ context: ./back/nginx
+ push: true
+ tags: ${{ secrets.DOCKER_REPO }}/nginx:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: action-slack
+ uses: 8398a7/action-slack@v3
+ with:
+ status: ${{ job.status }}
+ author_name: PetBuddy Github
+ fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ if: always()
+
+ build-ruby:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Build and push Ruby on Rails
+ uses: docker/build-push-action@v5
+ with:
+ context: ./back-chat
+ push: true
+ tags: ${{ secrets.DOCKER_REPO }}/ruby:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: action-slack
+ uses: 8398a7/action-slack@v3
+ with:
+ status: ${{ job.status }}
+ author_name: PetBuddy Github
+ fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ if: always()
+
+ deploy:
+ runs-on : ubuntu-latest
+ needs: [build-spring, build-nginx, build-ruby]
+
+ steps:
+ - name: executing remote ssh commands using password
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.AWS_HOST }}
+ username: ubuntu
+ key: ${{ secrets.AWS_KEY }}
+ script: |
+ if [ "$(sudo docker ps -qa)" ]; then
+ sudo docker rm -f $(sudo docker ps -qa)
+ fi
+
+ sudo docker pull ${{ secrets.DOCKER_REPO }}/spring:latest
+ sudo docker pull ${{ secrets.DOCKER_REPO }}/nginx:latest
+ sudo docker pull ${{ secrets.DOCKER_REPO }}/ruby:latest
+
+ sudo docker compose -f capstone/docker-compose.yml up -d
+ sudo docker image prune -f
+
+ - name: action-slack
+ uses: 8398a7/action-slack@v3
+ with:
+ status: ${{ job.status }}
+ author_name: PetBuddy Github
+ fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ if: always()
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000..9bd38c3eb0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+.env
\ No newline at end of file
diff --git a/back-chat/.dockerignore b/back-chat/.dockerignore
new file mode 100644
index 0000000000..f165105256
--- /dev/null
+++ b/back-chat/.dockerignore
@@ -0,0 +1,4 @@
+.idea
+.gitignore
+.env
+env.bashrc
\ No newline at end of file
diff --git a/back-chat/.gitattributes b/back-chat/.gitattributes
new file mode 100644
index 0000000000..dff662b537
--- /dev/null
+++ b/back-chat/.gitattributes
@@ -0,0 +1,8 @@
+# See https://git-scm.com/docs/gitattributes for more about git attribute files.
+
+# Mark the database schema as having been generated.
+db/schema.rb linguist-generated
+
+
+# Mark any vendored files as having been vendored.
+vendor/* linguist-vendored
diff --git a/back-chat/.gitignore b/back-chat/.gitignore
new file mode 100644
index 0000000000..80c1260d18
--- /dev/null
+++ b/back-chat/.gitignore
@@ -0,0 +1,30 @@
+# See https://help.github.com/articles/ignoring-files for more about ignoring files.
+#
+# If you find yourself ignoring temporary files generated by your text editor
+# or operating system, you probably want to add a global ignore instead:
+# git config --global core.excludesfile '~/.gitignore_global'
+
+# Ignore bundler config.
+/.bundle
+
+# Ignore all logfiles and tempfiles.
+/log/*
+/tmp/*
+!/log/.keep
+!/tmp/.keep
+
+# Ignore pidfiles, but keep the directory.
+/tmp/pids/*
+!/tmp/pids/
+!/tmp/pids/.keep
+
+# Ignore uploaded files in development.
+/storage/*
+!/storage/.keep
+.byebug_history
+
+# Ignore master key for decrypting credentials and more.
+/config/master.key
+
+.env
+env.bashrc
diff --git a/back-chat/.idea/.gitignore b/back-chat/.idea/.gitignore
new file mode 100644
index 0000000000..c3f502a199
--- /dev/null
+++ b/back-chat/.idea/.gitignore
@@ -0,0 +1,8 @@
+# 디폴트 무시된 파일
+/shelf/
+/workspace.xml
+# 에디터 기반 HTTP 클라이언트 요청
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/back-chat/.idea/back-chat.iml b/back-chat/.idea/back-chat.iml
new file mode 100644
index 0000000000..aa9a004445
--- /dev/null
+++ b/back-chat/.idea/back-chat.iml
@@ -0,0 +1,532 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ file://$MODULE_DIR$/../back-chat\app
+
+
+ file://$MODULE_DIR$/../back-chat\app/assets
+
+
+ file://$MODULE_DIR$/../back-chat\app/channels
+
+
+ file://$MODULE_DIR$/../back-chat\app/controllers
+
+
+ file://$MODULE_DIR$/../back-chat\app/helpers
+
+
+ file://$MODULE_DIR$/../back-chat\app/mailers
+
+
+ file://$MODULE_DIR$/../back-chat\app/models
+
+
+ file://$MODULE_DIR$/../back-chat\app/views
+
+
+ file://$MODULE_DIR$/../back-chat\config
+
+
+ file://$MODULE_DIR$/../back-chat\config/cable.yml
+
+
+ file://$MODULE_DIR$/../back-chat\config/database.yml
+
+
+ file://$MODULE_DIR$/../back-chat\config/environment.rb
+
+
+ file://$MODULE_DIR$/../back-chat\config/environments
+
+
+ file://$MODULE_DIR$/../back-chat\config/initializers
+
+
+ file://$MODULE_DIR$/../back-chat\config/locales
+
+
+ file://$MODULE_DIR$/../back-chat\config/routes
+
+
+ file://$MODULE_DIR$/../back-chat\config/routes.rb
+
+
+ file://$MODULE_DIR$/../back-chat\config
+
+
+ file://$MODULE_DIR$/../back-chat\db
+
+
+ file://$MODULE_DIR$/../back-chat\db/migrate
+
+
+ file://$MODULE_DIR$/../back-chat\db/seeds.rb
+
+
+ file://$MODULE_DIR$/../back-chat\lib
+
+
+ file://$MODULE_DIR$/../back-chat\lib/assets
+
+
+ file://$MODULE_DIR$/../back-chat\lib/tasks
+
+
+ file://$MODULE_DIR$/../back-chat\lib/templates
+
+
+ file://$MODULE_DIR$/../back-chat\log/development.log
+
+
+ file://$MODULE_DIR$/../back-chat\public
+
+
+ file://$MODULE_DIR$/../back-chat\public/javascripts
+
+
+ file://$MODULE_DIR$/../back-chat\public/stylesheets
+
+
+ file://$MODULE_DIR$/../back-chat\tmp
+
+
+ file://$MODULE_DIR$/../back-chat\vendor
+
+
+ file://$MODULE_DIR$/../back-chat\vendor/assets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/back-chat/.idea/dataSources.xml b/back-chat/.idea/dataSources.xml
new file mode 100644
index 0000000000..ece28653f5
--- /dev/null
+++ b/back-chat/.idea/dataSources.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ mysql.8
+ true
+ com.mysql.cj.jdbc.Driver
+ jdbc:mysql://capstone-spring-mysql.c3cyseycs3gd.ap-northeast-2.rds.amazonaws.com:3306/capstone
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/back-chat/.idea/modules.xml b/back-chat/.idea/modules.xml
new file mode 100644
index 0000000000..0165ee4f1d
--- /dev/null
+++ b/back-chat/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/back-chat/.idea/vcs.xml b/back-chat/.idea/vcs.xml
new file mode 100644
index 0000000000..6c0b863585
--- /dev/null
+++ b/back-chat/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/back-chat/.ruby-version b/back-chat/.ruby-version
new file mode 100644
index 0000000000..4efbd8f759
--- /dev/null
+++ b/back-chat/.ruby-version
@@ -0,0 +1 @@
+ruby-3.0.2
diff --git a/back-chat/= b/back-chat/=
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/Dockerfile b/back-chat/Dockerfile
new file mode 100644
index 0000000000..a6e2c40c42
--- /dev/null
+++ b/back-chat/Dockerfile
@@ -0,0 +1,16 @@
+FROM ruby:3.0.2-slim
+
+WORKDIR /app
+
+RUN apt-get update
+RUN apt-get install -y build-essential apt-utils libpq-dev default-mysql-client default-libmysqlclient-dev
+
+COPY Gemfile Gemfile.lock ./
+
+RUN gem install bundler && RAILS_ENV=production bundle install --jobs 20 --retry 5
+
+ENV RAILS_ENV=production
+
+COPY . ./
+
+ENTRYPOINT ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
diff --git a/back-chat/Gemfile b/back-chat/Gemfile
new file mode 100644
index 0000000000..6860eadbb0
--- /dev/null
+++ b/back-chat/Gemfile
@@ -0,0 +1,43 @@
+source 'https://rubygems.org'
+git_source(:github) { |repo| "https://github.com/#{repo}.git" }
+
+ruby '3.0.2'
+
+# Bundle edge Rails instead: gem 'rails', github: 'rails/rails', branch: 'main'
+gem 'rails', '~> 6.1.7', '>= 6.1.7.7'
+# Use mysql as the database for Active Record
+gem 'mysql2', '~> 0.5'
+
+# Use Puma as the app server
+gem 'puma', '~> 5.0'
+# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
+# gem 'jbuilder', '~> 2.7'
+# Use Redis adapter to run Action Cable in production
+# gem 'redis', '~> 4.0'
+# Use Active Model has_secure_password
+# gem 'bcrypt', '~> 3.1.7'
+
+# Use Active Storage variant
+# gem 'image_processing', '~> 1.2'
+
+# Reduces boot times through caching; required in config/boot.rb
+gem 'bootsnap', '>= 1.4.4', require: false
+
+# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
+# gem 'rack-cors'
+
+group :development, :test do
+ # Call 'byebug' anywhere in the code to stop execution and get a debugger console
+ gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
+end
+
+group :development do
+ gem 'listen', '~> 3.3'
+ # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
+ gem 'spring'
+end
+
+# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
+gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
+
+gem 'jwt'
\ No newline at end of file
diff --git a/back-chat/Gemfile.lock b/back-chat/Gemfile.lock
new file mode 100644
index 0000000000..ca4089e21a
--- /dev/null
+++ b/back-chat/Gemfile.lock
@@ -0,0 +1,181 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ actioncable (6.1.7.7)
+ actionpack (= 6.1.7.7)
+ activesupport (= 6.1.7.7)
+ nio4r (~> 2.0)
+ websocket-driver (>= 0.6.1)
+ actionmailbox (6.1.7.7)
+ actionpack (= 6.1.7.7)
+ activejob (= 6.1.7.7)
+ activerecord (= 6.1.7.7)
+ activestorage (= 6.1.7.7)
+ activesupport (= 6.1.7.7)
+ mail (>= 2.7.1)
+ actionmailer (6.1.7.7)
+ actionpack (= 6.1.7.7)
+ actionview (= 6.1.7.7)
+ activejob (= 6.1.7.7)
+ activesupport (= 6.1.7.7)
+ mail (~> 2.5, >= 2.5.4)
+ rails-dom-testing (~> 2.0)
+ actionpack (6.1.7.7)
+ actionview (= 6.1.7.7)
+ activesupport (= 6.1.7.7)
+ rack (~> 2.0, >= 2.0.9)
+ rack-test (>= 0.6.3)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.0, >= 1.2.0)
+ actiontext (6.1.7.7)
+ actionpack (= 6.1.7.7)
+ activerecord (= 6.1.7.7)
+ activestorage (= 6.1.7.7)
+ activesupport (= 6.1.7.7)
+ nokogiri (>= 1.8.5)
+ actionview (6.1.7.7)
+ activesupport (= 6.1.7.7)
+ builder (~> 3.1)
+ erubi (~> 1.4)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
+ activejob (6.1.7.7)
+ activesupport (= 6.1.7.7)
+ globalid (>= 0.3.6)
+ activemodel (6.1.7.7)
+ activesupport (= 6.1.7.7)
+ activerecord (6.1.7.7)
+ activemodel (= 6.1.7.7)
+ activesupport (= 6.1.7.7)
+ activestorage (6.1.7.7)
+ actionpack (= 6.1.7.7)
+ activejob (= 6.1.7.7)
+ activerecord (= 6.1.7.7)
+ activesupport (= 6.1.7.7)
+ marcel (~> 1.0)
+ mini_mime (>= 1.1.0)
+ activesupport (6.1.7.7)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ zeitwerk (~> 2.3)
+ base64 (0.2.0)
+ bootsnap (1.18.3)
+ msgpack (~> 1.2)
+ builder (3.2.4)
+ byebug (11.1.3)
+ concurrent-ruby (1.2.3)
+ crass (1.0.6)
+ date (3.3.4)
+ erubi (1.12.0)
+ ffi (1.16.3)
+ globalid (1.2.1)
+ activesupport (>= 6.1)
+ i18n (1.14.4)
+ concurrent-ruby (~> 1.0)
+ jwt (2.8.1)
+ base64
+ listen (3.9.0)
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
+ loofah (2.22.0)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.12.0)
+ mail (2.8.1)
+ mini_mime (>= 0.1.1)
+ net-imap
+ net-pop
+ net-smtp
+ marcel (1.0.4)
+ method_source (1.1.0)
+ mini_mime (1.1.5)
+ minitest (5.22.3)
+ msgpack (1.7.2)
+ mysql2 (0.5.6)
+ net-imap (0.4.10)
+ date
+ net-protocol
+ net-pop (0.1.2)
+ net-protocol
+ net-protocol (0.2.2)
+ timeout
+ net-smtp (0.5.0)
+ net-protocol
+ nio4r (2.7.1)
+ nokogiri (1.16.4-x86_64-linux)
+ racc (~> 1.4)
+ puma (5.6.8)
+ nio4r (~> 2.0)
+ racc (1.7.3)
+ rack (2.2.9)
+ rack-test (2.1.0)
+ rack (>= 1.3)
+ rails (6.1.7.7)
+ actioncable (= 6.1.7.7)
+ actionmailbox (= 6.1.7.7)
+ actionmailer (= 6.1.7.7)
+ actionpack (= 6.1.7.7)
+ actiontext (= 6.1.7.7)
+ actionview (= 6.1.7.7)
+ activejob (= 6.1.7.7)
+ activemodel (= 6.1.7.7)
+ activerecord (= 6.1.7.7)
+ activestorage (= 6.1.7.7)
+ activesupport (= 6.1.7.7)
+ bundler (>= 1.15.0)
+ railties (= 6.1.7.7)
+ sprockets-rails (>= 2.0.0)
+ rails-dom-testing (2.2.0)
+ activesupport (>= 5.0.0)
+ minitest
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.6.0)
+ loofah (~> 2.21)
+ nokogiri (~> 1.14)
+ railties (6.1.7.7)
+ actionpack (= 6.1.7.7)
+ activesupport (= 6.1.7.7)
+ method_source
+ rake (>= 12.2)
+ thor (~> 1.0)
+ rake (13.2.1)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.10.1)
+ ffi (~> 1.0)
+ spring (4.2.1)
+ sprockets (4.2.1)
+ concurrent-ruby (~> 1.0)
+ rack (>= 2.2.4, < 4)
+ sprockets-rails (3.4.2)
+ actionpack (>= 5.2)
+ activesupport (>= 5.2)
+ sprockets (>= 3.0.0)
+ thor (1.3.1)
+ timeout (0.4.1)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ websocket-driver (0.7.6)
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.5)
+ zeitwerk (2.6.13)
+
+PLATFORMS
+ x86_64-linux
+
+DEPENDENCIES
+ bootsnap (>= 1.4.4)
+ byebug
+ jwt
+ listen (~> 3.3)
+ mysql2 (~> 0.5)
+ puma (~> 5.0)
+ rails (~> 6.1.7, >= 6.1.7.7)
+ spring
+ tzinfo-data
+
+RUBY VERSION
+ ruby 3.0.2p107
+
+BUNDLED WITH
+ 2.3.5
diff --git a/back-chat/README.md b/back-chat/README.md
new file mode 100644
index 0000000000..7db80e4ca1
--- /dev/null
+++ b/back-chat/README.md
@@ -0,0 +1,24 @@
+# README
+
+This README would normally document whatever steps are necessary to get the
+application up and running.
+
+Things you may want to cover:
+
+* Ruby version
+
+* System dependencies
+
+* Configuration
+
+* Database creation
+
+* Database initialization
+
+* How to run the test suite
+
+* Services (job queues, cache servers, search engines, etc.)
+
+* Deployment instructions
+
+* ...
diff --git a/back-chat/Rakefile b/back-chat/Rakefile
new file mode 100644
index 0000000000..9a5ea7383a
--- /dev/null
+++ b/back-chat/Rakefile
@@ -0,0 +1,6 @@
+# Add your own tasks in files placed in lib/tasks ending in .rake,
+# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
+
+require_relative "config/application"
+
+Rails.application.load_tasks
diff --git a/back-chat/app/channels/application_cable/channel.rb b/back-chat/app/channels/application_cable/channel.rb
new file mode 100644
index 0000000000..d672697283
--- /dev/null
+++ b/back-chat/app/channels/application_cable/channel.rb
@@ -0,0 +1,4 @@
+module ApplicationCable
+ class Channel < ActionCable::Channel::Base
+ end
+end
diff --git a/back-chat/app/channels/application_cable/connection.rb b/back-chat/app/channels/application_cable/connection.rb
new file mode 100644
index 0000000000..0ff5442f47
--- /dev/null
+++ b/back-chat/app/channels/application_cable/connection.rb
@@ -0,0 +1,4 @@
+module ApplicationCable
+ class Connection < ActionCable::Connection::Base
+ end
+end
diff --git a/back-chat/app/controllers/application_controller.rb b/back-chat/app/controllers/application_controller.rb
new file mode 100644
index 0000000000..95e49655b5
--- /dev/null
+++ b/back-chat/app/controllers/application_controller.rb
@@ -0,0 +1,56 @@
+class ApplicationController < ActionController::API
+ rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
+ rescue_from StandardError, with: :handle_standard_error
+ rescue_from JWT::DecodeError, with: :handle_jwt_error
+ rescue_from JWT::ExpiredSignature, with: :handle_jwt_error
+ rescue_from ActiveRecord::RecordInvalid, with: :handle_record_invalid
+ rescue_from ActionController::RoutingError, with: :handle_not_found
+ rescue_from ActionController::MethodNotAllowed, with: :handle_method_not_allowed
+ rescue_from ActionController::ParameterMissing, with: :handle_missing_param
+
+ def render_success(success: true, data: {}, message: nil, status: :ok)
+ render json: {
+ success: success,
+ response: data,
+ message: message
+ }, status: status
+ end
+
+ def render_fail(success: false, message: nil, status: nil)
+ render json: {
+ success: success,
+ message: message,
+ }, status: status
+ end
+
+ private
+ def authorize_request
+ header = request.headers['Authorization']
+ header = header.split(' ').last if header
+ @decoded = JsonWebToken.decode(header)
+ end
+
+ def handle_not_found(exception)
+ render_fail(message: "Resource not found: #{exception.message}", status: :not_found)
+ end
+
+ def handle_standard_error(exception)
+ render_fail(message: "Internal server error: #{exception.message}", status: :internal_server_error)
+ end
+
+ def handle_jwt_error(exception)
+ render_fail(message: "JWT Invalid", status: :unauthorized)
+ end
+
+ def handle_method_not_allowed(exception)
+ render_fail(message: "Method Not Allowed: #{exception.message}", status: :method_not_allowed)
+ end
+
+ def handle_record_invalid(exception)
+ render_fail(message: "Record invalid: #{exception.message}", status: :bad_request)
+ end
+
+ def handle_missing_param(exception)
+ render_fail(message: "Invalid Input", status: :bad_request)
+ end
+end
diff --git a/back-chat/app/controllers/chats_controller.rb b/back-chat/app/controllers/chats_controller.rb
new file mode 100644
index 0000000000..56c68a3b0c
--- /dev/null
+++ b/back-chat/app/controllers/chats_controller.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+class ChatsController < ApplicationController
+ before_action :authorize_request
+ def list
+ user_id = @decoded[:uuid]
+
+ user = User.find_by(user_id: user_id)
+ if user.nil?
+ render_fail(message: "User not found", status: :bad_request)
+ return
+ end
+
+ chat_rooms = ChatRoom.where("user1_uuid = ? OR user2_uuid = ?", user_id, user_id).includes(:messages)
+
+ other_user_ids = chat_rooms.map do |room|
+ room.user1_uuid == user_id ? room.user2_uuid : room.user1_uuid
+ end.uniq
+
+ users_info = User.where(user_id: other_user_ids).index_by(&:user_id)
+
+ chat_rooms_data = chat_rooms.map do |chat_room|
+ other_user_id = chat_room.user1_uuid == user_id ? chat_room.user2_uuid : chat_room.user1_uuid
+ last_message = chat_room.messages.order(timestamp: :desc).first
+ {
+ chat_room_id: chat_room.id,
+ user_id: other_user_id,
+ user_name: users_info[other_user_id]&.name,
+ last_message_id: last_message&.id,
+ chat_room_message: last_message&.content,
+ chat_room_date: last_message&.timestamp&.strftime("%Y-%m-%d %H:%M")
+ }
+ end
+
+ sorted_rooms = chat_rooms_data.sort_by { |room| room[:chat_room_date] ? DateTime.parse(room[:chat_room_date]) : DateTime.new(0) }.reverse
+
+ render_success(data: { rooms: sorted_rooms }, message: "Chat rooms retrieved successfully")
+ end
+
+ # 유저간 채팅방을 생성해줌
+ def connect
+ user1_id = params[:user_id]
+ user2_id = @decoded[:uuid]
+
+ user1 = User.find_by(user_id: user1_id)
+ user2 = User.find_by(user_id: user2_id)
+
+ if user1.nil? || user2.nil?
+ render_fail(message: "User not found", status: :bad_request)
+ return
+ end
+
+ sorted_ids = [user1_id, user2_id].sort
+
+ chat_room = ChatRoom.find_by(user1_uuid: sorted_ids[0], user2_uuid: sorted_ids[1])
+ if chat_room.nil?
+ chat_room = ChatRoom.create(user1_uuid: sorted_ids[0], user2_uuid: sorted_ids[1])
+ render_success(message: "Successfully chat room created", data: { chat_room_id: chat_room.id }, status: :created)
+ else
+ render_success(message: "Successfully chat room found", data: { chat_room_id: chat_room.id })
+ end
+ end
+
+ def join
+ user_id = @decoded[:uuid]
+ chat_room = ChatRoom.where('id = ? AND (user1_uuid = ? OR user2_uuid = ?)',
+ params[:chat_id], user_id, user_id).first
+
+ if chat_room.nil?
+ render_fail(message: "Chat room not found", status: :bad_request)
+ end
+
+ last_message_id = params[:message_id].to_i
+
+ message_contents = ChatMessage
+ .where('chat_room_id = ? AND id > ? AND user_id != ?',
+ params[:chat_id], last_message_id, user_id)
+ .order(timestamp: :desc)
+ .limit(100)
+ .map do |message|
+ {
+ id: message.id,
+ content: message.content,
+ timestamp: message.timestamp.strftime("%Y-%m-%d %H:%M")
+ }
+ end
+
+ if message_contents.empty?
+ render_fail(message: "No message to load", status: :bad_request)
+ return
+ else
+ sorted_contents = message_contents.sort_by { |message| message[:id] }
+ render_success(data: { messages: sorted_contents }, message: "Successfully message loaded")
+ return
+ end
+ end
+
+ def send_message
+ user_id = @decoded[:uuid]
+
+ chat_room = ChatRoom.where('id = ? AND (user1_uuid = ? OR user2_uuid = ?)',
+ params[:chat_id], user_id, user_id).first
+
+ if chat_room.nil?
+ render_fail(message: "Chat room not found", status: :bad_request)
+ end
+
+ content = params[:content]
+ message = chat_room.messages.create(user_id: user_id, content: content)
+ response = {
+ id: message.id,
+ content: message.content,
+ timestamp: message.timestamp.strftime("%Y-%m-%d %H:%M")
+ }
+
+ print(response)
+
+ if message.persisted?
+ render_success(data: response, message: "Message sent", status: :created)
+ return
+ end
+ render_fail(message: "Message Send Error", status: :bad_request)
+ end
+
+ def test
+ test = @decoded[:uuid]
+
+ test = User.find_by(user_id: "rqwerwerwer")
+
+ render_success(data:test, message: "Hello World")
+ end
+end
diff --git a/back-chat/app/controllers/chats_polling_controller.rb b/back-chat/app/controllers/chats_polling_controller.rb
new file mode 100644
index 0000000000..2fea361e34
--- /dev/null
+++ b/back-chat/app/controllers/chats_polling_controller.rb
@@ -0,0 +1,85 @@
+class ChatsPollingController < ApplicationController
+ include ActionController::Live
+
+ before_action :authorize_request
+
+ def short_poll
+ user_id = @decoded[:uuid]
+
+ chat_room_list = params.require(:list)
+
+ updated_rooms = []
+ timeout = 20.seconds.from_now
+
+ loop do
+ chat_room_list.each do |room|
+ chat_room_id = room[:id].to_i
+ last_message_id = room[:message_id].to_i
+
+ new_message = ChatMessage
+ .where('chat_room_id = ? AND id > ? AND user_id != ?', chat_room_id, last_message_id, user_id)
+ .order(timestamp: :desc)
+ .limit(1)
+ .map do |message|
+ {
+ id: message.id,
+ chat_room_id: chat_room_id,
+ content: message.content,
+ timestamp: message.timestamp.strftime("%Y-%m-%d %H:%M")
+ }
+ end
+
+ updated_rooms.concat(new_message)
+ end
+
+ if updated_rooms.any?
+ sorted_contents = updated_rooms.sort_by { |message| message[:id] }
+ render_success(message: "Polling Successful", data: { messages: sorted_contents })
+ return
+ elsif Time.current >= timeout
+ render_fail(message: "Chat room timeout", status: :bad_request)
+ return
+ else
+ sleep(5)
+ end
+
+ end
+ end
+ def detail_poll
+ user_id = @decoded[:uuid]
+ chat_room = ChatRoom.where('id = ? AND (user1_uuid = ? OR user2_uuid = ?)',
+ params[:chat_id], user_id, user_id).first
+
+ unless chat_room
+ render_fail(message: "Chat room not found", status: :bad_request)
+ end
+
+ last_message_id = params[:message_id].to_i
+ timeout = 20.seconds.from_now
+
+ loop do
+ new_messages = ChatMessage
+ .where('chat_room_id = ? AND id > ? AND user_id != ?', chat_room.id, last_message_id, user_id)
+ .order(timestamp: :desc)
+ .limit(100)
+ .map do |message|
+ {
+ id: message.id,
+ content: message.content,
+ timestamp: message.timestamp.strftime("%Y-%m-%d %H:%M")
+ }
+ end
+
+ if !new_messages.empty?
+ sorted_contents = new_messages.sort_by { |message| message[:id] }
+ render_success(message: "Polling Successful", data: { messages: sorted_contents })
+ return
+ elsif Time.current >= timeout
+ render_fail(message: "Chat room timeout", status: :bad_request)
+ return
+ else
+ sleep(5)
+ end
+ end
+ end
+end
diff --git a/back-chat/app/controllers/concerns/.keep b/back-chat/app/controllers/concerns/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/app/jobs/application_job.rb b/back-chat/app/jobs/application_job.rb
new file mode 100644
index 0000000000..d394c3d106
--- /dev/null
+++ b/back-chat/app/jobs/application_job.rb
@@ -0,0 +1,7 @@
+class ApplicationJob < ActiveJob::Base
+ # Automatically retry jobs that encountered a deadlock
+ # retry_on ActiveRecord::Deadlocked
+
+ # Most jobs are safe to ignore if the underlying records are no longer available
+ # discard_on ActiveJob::DeserializationError
+end
diff --git a/back-chat/app/lib/JsonWebToken.rb b/back-chat/app/lib/JsonWebToken.rb
new file mode 100644
index 0000000000..2857000dfd
--- /dev/null
+++ b/back-chat/app/lib/JsonWebToken.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class JsonWebToken
+ SECRET_KEY = ENV["JWT_SECRET"]
+
+ def self.decode(token)
+ decoded = JWT.decode(token, Base64.decode64(SECRET_KEY), true, { algorithm: 'HS256' })
+ HashWithIndifferentAccess.new(decoded.first)
+ end
+end
diff --git a/back-chat/app/mailers/application_mailer.rb b/back-chat/app/mailers/application_mailer.rb
new file mode 100644
index 0000000000..286b2239d1
--- /dev/null
+++ b/back-chat/app/mailers/application_mailer.rb
@@ -0,0 +1,4 @@
+class ApplicationMailer < ActionMailer::Base
+ default from: 'from@example.com'
+ layout 'mailer'
+end
diff --git a/back-chat/app/models/application_record.rb b/back-chat/app/models/application_record.rb
new file mode 100644
index 0000000000..10a4cba84d
--- /dev/null
+++ b/back-chat/app/models/application_record.rb
@@ -0,0 +1,3 @@
+class ApplicationRecord < ActiveRecord::Base
+ self.abstract_class = true
+end
diff --git a/back-chat/app/models/chat_message.rb b/back-chat/app/models/chat_message.rb
new file mode 100644
index 0000000000..16568279db
--- /dev/null
+++ b/back-chat/app/models/chat_message.rb
@@ -0,0 +1,8 @@
+class ChatMessage < ApplicationRecord
+ belongs_to :chat_room
+ before_save :set_local_timestamp
+
+ def set_local_timestamp
+ self.timestamp = Time.current
+ end
+end
\ No newline at end of file
diff --git a/back-chat/app/models/chat_room.rb b/back-chat/app/models/chat_room.rb
new file mode 100644
index 0000000000..45682dbca0
--- /dev/null
+++ b/back-chat/app/models/chat_room.rb
@@ -0,0 +1,3 @@
+class ChatRoom < ApplicationRecord
+ has_many :messages, class_name: 'ChatMessage', foreign_key: 'chat_room_id', dependent: :destroy
+end
diff --git a/back-chat/app/models/concerns/.keep b/back-chat/app/models/concerns/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/app/models/user.rb b/back-chat/app/models/user.rb
new file mode 100644
index 0000000000..8c1d927405
--- /dev/null
+++ b/back-chat/app/models/user.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class User < ApplicationRecord
+ self.table_name = 'users'
+end
diff --git a/back-chat/app/views/layouts/mailer.html.erb b/back-chat/app/views/layouts/mailer.html.erb
new file mode 100644
index 0000000000..cbd34d2e9d
--- /dev/null
+++ b/back-chat/app/views/layouts/mailer.html.erb
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+ <%= yield %>
+
+
diff --git a/back-chat/app/views/layouts/mailer.text.erb b/back-chat/app/views/layouts/mailer.text.erb
new file mode 100644
index 0000000000..37f0bddbd7
--- /dev/null
+++ b/back-chat/app/views/layouts/mailer.text.erb
@@ -0,0 +1 @@
+<%= yield %>
diff --git a/back-chat/bin/bundle b/back-chat/bin/bundle
new file mode 100644
index 0000000000..60aca02d53
--- /dev/null
+++ b/back-chat/bin/bundle
@@ -0,0 +1,114 @@
+#!/usr/bin/env ruby3.0
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'bundle' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+require "rubygems"
+
+m = Module.new do
+ module_function
+
+ def invoked_as_script?
+ File.expand_path($0) == File.expand_path(__FILE__)
+ end
+
+ def env_var_version
+ ENV["BUNDLER_VERSION"]
+ end
+
+ def cli_arg_version
+ return unless invoked_as_script? # don't want to hijack other binstubs
+ return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
+ bundler_version = nil
+ update_index = nil
+ ARGV.each_with_index do |a, i|
+ if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN
+ bundler_version = a
+ end
+ next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
+ bundler_version = $1
+ update_index = i
+ end
+ bundler_version
+ end
+
+ def gemfile
+ gemfile = ENV["BUNDLE_GEMFILE"]
+ return gemfile if gemfile && !gemfile.empty?
+
+ File.expand_path("../../Gemfile", __FILE__)
+ end
+
+ def lockfile
+ lockfile =
+ case File.basename(gemfile)
+ when "gems.rb" then gemfile.sub(/\.rb$/, gemfile)
+ else "#{gemfile}.lock"
+ end
+ File.expand_path(lockfile)
+ end
+
+ def lockfile_version
+ return unless File.file?(lockfile)
+ lockfile_contents = File.read(lockfile)
+ return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
+ Regexp.last_match(1)
+ end
+
+ def bundler_version
+ @bundler_version ||=
+ env_var_version || cli_arg_version ||
+ lockfile_version
+ end
+
+ def bundler_requirement
+ return "#{Gem::Requirement.default}.a" unless bundler_version
+
+ bundler_gem_version = Gem::Version.new(bundler_version)
+
+ requirement = bundler_gem_version.approximate_recommendation
+
+ return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new("2.7.0")
+
+ requirement += ".a" if bundler_gem_version.prerelease?
+
+ requirement
+ end
+
+ def load_bundler!
+ ENV["BUNDLE_GEMFILE"] ||= gemfile
+
+ activate_bundler
+ end
+
+ def activate_bundler
+ gem_error = activation_error_handling do
+ gem "bundler", bundler_requirement
+ end
+ return if gem_error.nil?
+ require_error = activation_error_handling do
+ require "bundler/version"
+ end
+ return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
+ warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
+ exit 42
+ end
+
+ def activation_error_handling
+ yield
+ nil
+ rescue StandardError, LoadError => e
+ e
+ end
+end
+
+m.load_bundler!
+
+if m.invoked_as_script?
+ load Gem.bin_path("bundler", "bundle")
+end
diff --git a/back-chat/bin/rails b/back-chat/bin/rails
new file mode 100644
index 0000000000..21d3e02d89
--- /dev/null
+++ b/back-chat/bin/rails
@@ -0,0 +1,5 @@
+#!/usr/bin/env ruby
+load File.expand_path("spring", __dir__)
+APP_PATH = File.expand_path('../config/application', __dir__)
+require_relative "../config/boot"
+require "rails/commands"
diff --git a/back-chat/bin/rake b/back-chat/bin/rake
new file mode 100644
index 0000000000..7327f471e4
--- /dev/null
+++ b/back-chat/bin/rake
@@ -0,0 +1,5 @@
+#!/usr/bin/env ruby
+load File.expand_path("spring", __dir__)
+require_relative "../config/boot"
+require "rake"
+Rake.application.run
diff --git a/back-chat/bin/setup b/back-chat/bin/setup
new file mode 100644
index 0000000000..57923026c4
--- /dev/null
+++ b/back-chat/bin/setup
@@ -0,0 +1,33 @@
+#!/usr/bin/env ruby
+require "fileutils"
+
+# path to your application root.
+APP_ROOT = File.expand_path('..', __dir__)
+
+def system!(*args)
+ system(*args) || abort("\n== Command #{args} failed ==")
+end
+
+FileUtils.chdir APP_ROOT do
+ # This script is a way to set up or update your development environment automatically.
+ # This script is idempotent, so that you can run it at any time and get an expectable outcome.
+ # Add necessary setup steps to this file.
+
+ puts '== Installing dependencies =='
+ system! 'gem install bundler --conservative'
+ system('bundle check') || system!('bundle install')
+
+ # puts "\n== Copying sample files =="
+ # unless File.exist?('config/database.yml')
+ # FileUtils.cp 'config/database.yml.sample', 'config/database.yml'
+ # end
+
+ puts "\n== Preparing database =="
+ system! 'bin/rails db:prepare'
+
+ puts "\n== Removing old logs and tempfiles =="
+ system! 'bin/rails log:clear tmp:clear'
+
+ puts "\n== Restarting application server =="
+ system! 'bin/rails restart'
+end
diff --git a/back-chat/bin/spring b/back-chat/bin/spring
new file mode 100644
index 0000000000..b4147e8437
--- /dev/null
+++ b/back-chat/bin/spring
@@ -0,0 +1,14 @@
+#!/usr/bin/env ruby
+if !defined?(Spring) && [nil, "development", "test"].include?(ENV["RAILS_ENV"])
+ gem "bundler"
+ require "bundler"
+
+ # Load Spring without loading other gems in the Gemfile, for speed.
+ Bundler.locked_gems&.specs&.find { |spec| spec.name == "spring" }&.tap do |spring|
+ Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path
+ gem "spring", spring.version
+ require "spring/binstub"
+ rescue Gem::LoadError
+ # Ignore when Spring is not installed.
+ end
+end
diff --git a/back-chat/config.ru b/back-chat/config.ru
new file mode 100644
index 0000000000..4a3c09a688
--- /dev/null
+++ b/back-chat/config.ru
@@ -0,0 +1,6 @@
+# This file is used by Rack-based servers to start the application.
+
+require_relative "config/environment"
+
+run Rails.application
+Rails.application.load_server
diff --git a/back-chat/config/application.rb b/back-chat/config/application.rb
new file mode 100644
index 0000000000..bd0edd2571
--- /dev/null
+++ b/back-chat/config/application.rb
@@ -0,0 +1,43 @@
+require_relative "boot"
+
+require "rails"
+# Pick the frameworks you want:
+require "active_model/railtie"
+require "active_job/railtie"
+require "active_record/railtie"
+require "active_storage/engine"
+require "action_controller/railtie"
+require "action_mailer/railtie"
+require "action_mailbox/engine"
+require "action_text/engine"
+require "action_view/railtie"
+require "action_cable/engine"
+# require "sprockets/railtie"
+require "rails/test_unit/railtie"
+
+# Require the gems listed in Gemfile, including any gems
+# you've limited to :test, :development, or :production.
+Bundler.require(*Rails.groups)
+
+module BackChat
+ class Application < Rails::Application
+ # Initialize configuration defaults for originally generated Rails version.
+ config.load_defaults 6.1
+
+ # Configuration for the application, engines, and railties goes here.
+ #
+ # These settings can be overridden in specific environments using the files
+ # in config/environments, which are processed later.
+ #
+ # config.time_zone = "Central Time (US & Canada)"
+ # config.eager_load_paths << Rails.root.join("extras")
+
+ # Only loads a smaller set of middleware suitable for API only apps.
+ # Middleware like session, flash, cookies can be added back manually.
+ # Skip views, helpers and assets when generating a new resource.
+ config.api_only = true
+
+ config.time_zone = 'Seoul'
+ config.active_record.default_timezone = :local
+ end
+end
diff --git a/back-chat/config/boot.rb b/back-chat/config/boot.rb
new file mode 100644
index 0000000000..3cda23b4db
--- /dev/null
+++ b/back-chat/config/boot.rb
@@ -0,0 +1,4 @@
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
+
+require "bundler/setup" # Set up gems listed in the Gemfile.
+require "bootsnap/setup" # Speed up boot time by caching expensive operations.
diff --git a/back-chat/config/cable.yml b/back-chat/config/cable.yml
new file mode 100644
index 0000000000..ef22525d0e
--- /dev/null
+++ b/back-chat/config/cable.yml
@@ -0,0 +1,10 @@
+development:
+ adapter: async
+
+test:
+ adapter: test
+
+production:
+ adapter: redis
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
+ channel_prefix: back_chat_production
diff --git a/back-chat/config/credentials.yml.enc b/back-chat/config/credentials.yml.enc
new file mode 100644
index 0000000000..1f2d5b3a1c
--- /dev/null
+++ b/back-chat/config/credentials.yml.enc
@@ -0,0 +1 @@
+cBMHTPViDZ9aHMwtLL44LxJ/m4bR3WdO+QFB8cnCSarhnRzqt+GdShCrYwz5dmLH4hvkhNgqEWh+67w7b74ewamKLxqotL/RBJMNI0vxx+ZYzAfAO5I4L4mOZ59K/jiRZCg2Md2r6SMZHy8J1EVv4Z5x9E3wZxa5F4MlOZXb7anUUSprIJhgXpwWePLQs2+YLlxPoUHqUmrPH3G76JCVF/JaW5i9s1V4LdN0sCSjzsv7SCVhBtLLFUWX3seEC96g2cozJ45PeFkMc6BC+cahTq1npwt+1hZYW9bcwuwHU4TLIdKl3FldSo8gREOF8yWsIuci5MT3ydcy4wGYk86JRuF9gzJdCJCqqFujS7tpTG47Ff7dN6R/ltB+SWTnO+wHRGrAxvYRe8GUU3KiUPj3z0ZKwPCEJKKlp3Su--lKX+/PDOyHFmmc94--MOKvypginfehCjQIdHLpzg==
\ No newline at end of file
diff --git a/back-chat/config/database.yml b/back-chat/config/database.yml
new file mode 100644
index 0000000000..062e04086d
--- /dev/null
+++ b/back-chat/config/database.yml
@@ -0,0 +1,54 @@
+# MySQL. Versions 5.5.8 and up are supported.
+#
+# Install the MySQL driver
+# gem install mysql2
+#
+# Ensure the MySQL gem is defined in your Gemfile
+# gem 'mysql2'
+#
+# And be sure to use new-style password hashing:
+# https://dev.mysql.com/doc/refman/5.7/en/password-hashing.html
+#
+default: &default
+ adapter: mysql2
+ encoding: utf8
+ database: <%= ENV['DB_NAME'] %>
+ username: <%= ENV['MYSQL_USERNAME'] %>
+ password: <%= ENV['MYSQL_PASSWORD'] %>
+ host: <%= ENV['DB_ENDPOINT'] %>
+ port: <%= ENV['DB_PORT'] %>
+ pool: 5
+
+development:
+ <<: *default
+ database: <%= ENV['DB_NAME'] %>
+
+# Warning: The database defined as "test" will be erased and
+# re-generated from your development database when you run "rake".
+# Do not set this db to the same as development or production.
+test:
+ <<: *default
+ database: back_chat_test
+
+# As with config/credentials.yml, you never want to store sensitive information,
+# like your database password, in your source code. If your source code is
+# ever seen by anyone, they now have access to your database.
+#
+# Instead, provide the password or a full connection URL as an environment
+# variable when you boot the app. For example:
+#
+# DATABASE_URL="mysql2://myuser:mypass@localhost/somedatabase"
+#
+# If the connection URL is provided in the special DATABASE_URL environment
+# variable, Rails will automatically merge its configuration values on top of
+# the values provided in this file. Alternatively, you can specify a connection
+# URL environment variable explicitly:
+#
+# production:
+# url: <%= ENV['MY_APP_DATABASE_URL'] %>
+#
+# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database
+# for a full overview on how database connection configuration can be specified.
+#
+production:
+ <<: *default
diff --git a/back-chat/config/environment.rb b/back-chat/config/environment.rb
new file mode 100644
index 0000000000..cac5315775
--- /dev/null
+++ b/back-chat/config/environment.rb
@@ -0,0 +1,5 @@
+# Load the Rails application.
+require_relative "application"
+
+# Initialize the Rails application.
+Rails.application.initialize!
diff --git a/back-chat/config/environments/development.rb b/back-chat/config/environments/development.rb
new file mode 100644
index 0000000000..231c2a6aa7
--- /dev/null
+++ b/back-chat/config/environments/development.rb
@@ -0,0 +1,66 @@
+require "active_support/core_ext/integer/time"
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # In the development environment your application's code is reloaded any time
+ # it changes. This slows down response time but is perfect for development
+ # since you don't have to restart the web server when you make code changes.
+ config.cache_classes = false
+
+ # Do not eager load code on boot.
+ config.eager_load = false
+
+ # Show full error reports.
+ config.consider_all_requests_local = true
+
+ # Enable/disable caching. By default caching is disabled.
+ # Run rails dev:cache to toggle caching.
+ if Rails.root.join('tmp', 'caching-dev.txt').exist?
+ config.cache_store = :memory_store
+ config.public_file_server.headers = {
+ 'Cache-Control' => "public, max-age=#{2.days.to_i}"
+ }
+ else
+ config.action_controller.perform_caching = false
+
+ config.cache_store = :null_store
+ end
+
+ # Store uploaded files on the local file system (see config/storage.yml for options).
+ config.active_storage.service = :local
+
+ # Don't care if the mailer can't send.
+ config.action_mailer.raise_delivery_errors = false
+
+ config.action_mailer.perform_caching = false
+
+ # Print deprecation notices to the Rails logger.
+ config.active_support.deprecation = :log
+
+ # Raise exceptions for disallowed deprecations.
+ config.active_support.disallowed_deprecation = :raise
+
+ # Tell Active Support which deprecation messages to disallow.
+ config.active_support.disallowed_deprecation_warnings = []
+
+ # Raise an error on page load if there are pending migrations.
+ config.active_record.migration_error = :page_load
+
+ # Highlight code that triggered database queries in logs.
+ config.active_record.verbose_query_logs = true
+
+
+ # Raises error for missing translations.
+ # config.i18n.raise_on_missing_translations = true
+
+ # Annotate rendered view with file names.
+ # config.action_view.annotate_rendered_view_with_filenames = true
+
+ # Use an evented file watcher to asynchronously detect changes in source code,
+ # routes, locales, etc. This feature depends on the listen gem.
+ config.file_watcher = ActiveSupport::EventedFileUpdateChecker
+
+ # Uncomment if you wish to allow Action Cable access from any origin.
+ # config.action_cable.disable_request_forgery_protection = true
+end
diff --git a/back-chat/config/environments/production.rb b/back-chat/config/environments/production.rb
new file mode 100644
index 0000000000..c1ebbc6482
--- /dev/null
+++ b/back-chat/config/environments/production.rb
@@ -0,0 +1,113 @@
+require "active_support/core_ext/integer/time"
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # Code is not reloaded between requests.
+ config.cache_classes = true
+
+ # Eager load code on boot. This eager loads most of Rails and
+ # your application in memory, allowing both threaded web servers
+ # and those relying on copy on write to perform better.
+ # Rake tasks automatically ignore this option for performance.
+ config.eager_load = true
+
+ # Full error reports are disabled and caching is turned on.
+ config.consider_all_requests_local = false
+
+ # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
+ # or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
+ config.secret_key_base = ENV["SECRET_KEY_BASE"]
+
+ # Disable serving static files from the `/public` folder by default since
+ # Apache or NGINX already handles this.
+ config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
+
+ # Enable serving of images, stylesheets, and JavaScripts from an asset server.
+ # config.asset_host = 'http://assets.example.com'
+
+ # Specifies the header that your server uses for sending files.
+ # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
+ # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
+
+ # Store uploaded files on the local file system (see config/storage.yml for options).
+ config.active_storage.service = :local
+
+ # Mount Action Cable outside main process or domain.
+ # config.action_cable.mount_path = nil
+ # config.action_cable.url = 'wss://example.com/cable'
+ # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ]
+
+ # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
+ # config.force_ssl = true
+
+ # Include generic and useful information about system operation, but avoid logging too much
+ # information to avoid inadvertent exposure of personally identifiable information (PII).
+ config.log_level = :info
+
+ # Prepend all log lines with the following tags.
+ config.log_tags = [ :request_id ]
+
+ # Use a different cache store in production.
+ # config.cache_store = :mem_cache_store
+
+ # Use a real queuing backend for Active Job (and separate queues per environment).
+ # config.active_job.queue_adapter = :resque
+ # config.active_job.queue_name_prefix = "back_chat_production"
+
+ config.action_mailer.perform_caching = false
+
+ # Ignore bad email addresses and do not raise email delivery errors.
+ # Set this to true and configure the email server for immediate delivery to raise delivery errors.
+ # config.action_mailer.raise_delivery_errors = false
+
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+ # the I18n.default_locale when a translation cannot be found).
+ config.i18n.fallbacks = true
+
+ # Send deprecation notices to registered listeners.
+ config.active_support.deprecation = :notify
+
+ # Log disallowed deprecations.
+ config.active_support.disallowed_deprecation = :log
+
+ # Tell Active Support which deprecation messages to disallow.
+ config.active_support.disallowed_deprecation_warnings = []
+
+ # Use default logging formatter so that PID and timestamp are not suppressed.
+ config.log_formatter = ::Logger::Formatter.new
+
+ # Use a different logger for distributed setups.
+ # require "syslog/logger"
+ # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')
+
+ if ENV["RAILS_LOG_TO_STDOUT"].present?
+ logger = ActiveSupport::Logger.new(STDOUT)
+ logger.formatter = config.log_formatter
+ config.logger = ActiveSupport::TaggedLogging.new(logger)
+ end
+
+ # Do not dump schema after migrations.
+ config.active_record.dump_schema_after_migration = false
+
+ # Inserts middleware to perform automatic connection switching.
+ # The `database_selector` hash is used to pass options to the DatabaseSelector
+ # middleware. The `delay` is used to determine how long to wait after a write
+ # to send a subsequent read to the primary.
+ #
+ # The `database_resolver` class is used by the middleware to determine which
+ # database is appropriate to use based on the time delay.
+ #
+ # The `database_resolver_context` class is used by the middleware to set
+ # timestamps for the last write to the primary. The resolver uses the context
+ # class timestamps to determine how long to wait before reading from the
+ # replica.
+ #
+ # By default Rails will store a last write timestamp in the session. The
+ # DatabaseSelector middleware is designed as such you can define your own
+ # strategy for connection switching and pass that into the middleware through
+ # these configuration options.
+ # config.active_record.database_selector = { delay: 2.seconds }
+ # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
+ # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
+end
diff --git a/back-chat/config/environments/test.rb b/back-chat/config/environments/test.rb
new file mode 100644
index 0000000000..93ed4f1b78
--- /dev/null
+++ b/back-chat/config/environments/test.rb
@@ -0,0 +1,60 @@
+require "active_support/core_ext/integer/time"
+
+# The test environment is used exclusively to run your application's
+# test suite. You never need to work with it otherwise. Remember that
+# your test database is "scratch space" for the test suite and is wiped
+# and recreated between test runs. Don't rely on the data there!
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ config.cache_classes = false
+ config.action_view.cache_template_loading = true
+
+ # Do not eager load code on boot. This avoids loading your whole application
+ # just for the purpose of running a single test. If you are using a tool that
+ # preloads Rails for running tests, you may have to set it to true.
+ config.eager_load = false
+
+ # Configure public file server for tests with Cache-Control for performance.
+ config.public_file_server.enabled = true
+ config.public_file_server.headers = {
+ 'Cache-Control' => "public, max-age=#{1.hour.to_i}"
+ }
+
+ # Show full error reports and disable caching.
+ config.consider_all_requests_local = true
+ config.action_controller.perform_caching = false
+ config.cache_store = :null_store
+
+ # Raise exceptions instead of rendering exception templates.
+ config.action_dispatch.show_exceptions = false
+
+ # Disable request forgery protection in test environment.
+ config.action_controller.allow_forgery_protection = false
+
+ # Store uploaded files on the local file system in a temporary directory.
+ config.active_storage.service = :test
+
+ config.action_mailer.perform_caching = false
+
+ # Tell Action Mailer not to deliver emails to the real world.
+ # The :test delivery method accumulates sent emails in the
+ # ActionMailer::Base.deliveries array.
+ config.action_mailer.delivery_method = :test
+
+ # Print deprecation notices to the stderr.
+ config.active_support.deprecation = :stderr
+
+ # Raise exceptions for disallowed deprecations.
+ config.active_support.disallowed_deprecation = :raise
+
+ # Tell Active Support which deprecation messages to disallow.
+ config.active_support.disallowed_deprecation_warnings = []
+
+ # Raises error for missing translations.
+ # config.i18n.raise_on_missing_translations = true
+
+ # Annotate rendered view with file names.
+ # config.action_view.annotate_rendered_view_with_filenames = true
+end
diff --git a/back-chat/config/initializers/application_controller_renderer.rb b/back-chat/config/initializers/application_controller_renderer.rb
new file mode 100644
index 0000000000..89d2efab2b
--- /dev/null
+++ b/back-chat/config/initializers/application_controller_renderer.rb
@@ -0,0 +1,8 @@
+# Be sure to restart your server when you modify this file.
+
+# ActiveSupport::Reloader.to_prepare do
+# ApplicationController.renderer.defaults.merge!(
+# http_host: 'example.org',
+# https: false
+# )
+# end
diff --git a/back-chat/config/initializers/backtrace_silencers.rb b/back-chat/config/initializers/backtrace_silencers.rb
new file mode 100644
index 0000000000..33699c3091
--- /dev/null
+++ b/back-chat/config/initializers/backtrace_silencers.rb
@@ -0,0 +1,8 @@
+# Be sure to restart your server when you modify this file.
+
+# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
+# Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) }
+
+# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code
+# by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'".
+Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"]
diff --git a/back-chat/config/initializers/cors.rb b/back-chat/config/initializers/cors.rb
new file mode 100644
index 0000000000..3b1c1b5ed1
--- /dev/null
+++ b/back-chat/config/initializers/cors.rb
@@ -0,0 +1,16 @@
+# Be sure to restart your server when you modify this file.
+
+# Avoid CORS issues when API is called from the frontend app.
+# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests.
+
+# Read more: https://github.com/cyu/rack-cors
+
+# Rails.application.config.middleware.insert_before 0, Rack::Cors do
+# allow do
+# origins 'example.com'
+#
+# resource '*',
+# headers: :any,
+# methods: [:get, :post, :put, :patch, :delete, :options, :head]
+# end
+# end
diff --git a/back-chat/config/initializers/filter_parameter_logging.rb b/back-chat/config/initializers/filter_parameter_logging.rb
new file mode 100644
index 0000000000..4b34a03668
--- /dev/null
+++ b/back-chat/config/initializers/filter_parameter_logging.rb
@@ -0,0 +1,6 @@
+# Be sure to restart your server when you modify this file.
+
+# Configure sensitive parameters which will be filtered from the log file.
+Rails.application.config.filter_parameters += [
+ :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
+]
diff --git a/back-chat/config/initializers/inflections.rb b/back-chat/config/initializers/inflections.rb
new file mode 100644
index 0000000000..ac033bf9dc
--- /dev/null
+++ b/back-chat/config/initializers/inflections.rb
@@ -0,0 +1,16 @@
+# Be sure to restart your server when you modify this file.
+
+# Add new inflection rules using the following format. Inflections
+# are locale specific, and you may define rules for as many different
+# locales as you wish. All of these examples are active by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.plural /^(ox)$/i, '\1en'
+# inflect.singular /^(ox)en/i, '\1'
+# inflect.irregular 'person', 'people'
+# inflect.uncountable %w( fish sheep )
+# end
+
+# These inflection rules are supported but not enabled by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.acronym 'RESTful'
+# end
diff --git a/back-chat/config/initializers/mime_types.rb b/back-chat/config/initializers/mime_types.rb
new file mode 100644
index 0000000000..dc1899682b
--- /dev/null
+++ b/back-chat/config/initializers/mime_types.rb
@@ -0,0 +1,4 @@
+# Be sure to restart your server when you modify this file.
+
+# Add new mime types for use in respond_to blocks:
+# Mime::Type.register "text/richtext", :rtf
diff --git a/back-chat/config/initializers/wrap_parameters.rb b/back-chat/config/initializers/wrap_parameters.rb
new file mode 100644
index 0000000000..bbfc3961bf
--- /dev/null
+++ b/back-chat/config/initializers/wrap_parameters.rb
@@ -0,0 +1,14 @@
+# Be sure to restart your server when you modify this file.
+
+# This file contains settings for ActionController::ParamsWrapper which
+# is enabled by default.
+
+# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
+ActiveSupport.on_load(:action_controller) do
+ wrap_parameters format: [:json]
+end
+
+# To enable root element in JSON for ActiveRecord objects.
+# ActiveSupport.on_load(:active_record) do
+# self.include_root_in_json = true
+# end
diff --git a/back-chat/config/locales/en.yml b/back-chat/config/locales/en.yml
new file mode 100644
index 0000000000..cf9b342d0a
--- /dev/null
+++ b/back-chat/config/locales/en.yml
@@ -0,0 +1,33 @@
+# Files in the config/locales directory are used for internationalization
+# and are automatically loaded by Rails. If you want to use locales other
+# than English, add the necessary files in this directory.
+#
+# To use the locales, use `I18n.t`:
+#
+# I18n.t 'hello'
+#
+# In views, this is aliased to just `t`:
+#
+# <%= t('hello') %>
+#
+# To use a different locale, set it with `I18n.locale`:
+#
+# I18n.locale = :es
+#
+# This would use the information in config/locales/es.yml.
+#
+# The following keys must be escaped otherwise they will not be retrieved by
+# the default I18n backend:
+#
+# true, false, on, off, yes, no
+#
+# Instead, surround them with single quotes.
+#
+# en:
+# 'true': 'foo'
+#
+# To learn more, please read the Rails Internationalization guide
+# available at https://guides.rubyonrails.org/i18n.html.
+
+en:
+ hello: "Hello world"
diff --git a/back-chat/config/puma.rb b/back-chat/config/puma.rb
new file mode 100644
index 0000000000..d9b3e836cf
--- /dev/null
+++ b/back-chat/config/puma.rb
@@ -0,0 +1,43 @@
+# Puma can serve each request in a thread from an internal thread pool.
+# The `threads` method setting takes two numbers: a minimum and maximum.
+# Any libraries that use thread pools should be configured to match
+# the maximum value specified for Puma. Default is set to 5 threads for minimum
+# and maximum; this matches the default thread size of Active Record.
+#
+max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
+min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
+threads min_threads_count, max_threads_count
+
+# Specifies the `worker_timeout` threshold that Puma will use to wait before
+# terminating a worker in development environments.
+#
+worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"
+
+# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
+#
+port ENV.fetch("PORT") { 3000 }
+
+# Specifies the `environment` that Puma will run in.
+#
+environment ENV.fetch("RAILS_ENV") { "development" }
+
+# Specifies the `pidfile` that Puma will use.
+pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
+
+# Specifies the number of `workers` to boot in clustered mode.
+# Workers are forked web server processes. If using threads and workers together
+# the concurrency of the application would be max `threads` * `workers`.
+# Workers do not work on JRuby or Windows (both of which do not support
+# processes).
+#
+# workers ENV.fetch("WEB_CONCURRENCY") { 2 }
+
+# Use the `preload_app!` method when specifying a `workers` number.
+# This directive tells Puma to first boot the application and load code
+# before forking the application. This takes advantage of Copy On Write
+# process behavior so workers use less memory.
+#
+# preload_app!
+
+# Allow puma to be restarted by `rails restart` command.
+plugin :tmp_restart
diff --git a/back-chat/config/routes.rb b/back-chat/config/routes.rb
new file mode 100644
index 0000000000..f09ac28665
--- /dev/null
+++ b/back-chat/config/routes.rb
@@ -0,0 +1,10 @@
+Rails.application.routes.draw do
+ # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
+ get 'api/chat/list' => 'chats#list'
+ get 'api/chat/join/:chat_id/:message_id' => 'chats#join'
+ post 'api/chat/connect/:user_id' => 'chats#connect'
+ post 'api/chat/poll/list' => 'chats_polling#short_poll'
+ post 'api/chat/poll/:chat_id/:message_id' => 'chats_polling#detail_poll'
+ post 'api/chat/:chat_id' => 'chats#send_message'
+ get 'api/chat/test' => 'chats#test'
+end
diff --git a/back-chat/config/spring.rb b/back-chat/config/spring.rb
new file mode 100644
index 0000000000..db5bf1307a
--- /dev/null
+++ b/back-chat/config/spring.rb
@@ -0,0 +1,6 @@
+Spring.watch(
+ ".ruby-version",
+ ".rbenv-vars",
+ "tmp/restart.txt",
+ "tmp/caching-dev.txt"
+)
diff --git a/back-chat/config/storage.yml b/back-chat/config/storage.yml
new file mode 100644
index 0000000000..d32f76e8fb
--- /dev/null
+++ b/back-chat/config/storage.yml
@@ -0,0 +1,34 @@
+test:
+ service: Disk
+ root: <%= Rails.root.join("tmp/storage") %>
+
+local:
+ service: Disk
+ root: <%= Rails.root.join("storage") %>
+
+# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
+# amazon:
+# service: S3
+# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
+# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
+# region: us-east-1
+# bucket: your_own_bucket
+
+# Remember not to checkin your GCS keyfile to a repository
+# google:
+# service: GCS
+# project: your_project
+# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
+# bucket: your_own_bucket
+
+# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
+# microsoft:
+# service: AzureStorage
+# storage_account_name: your_account_name
+# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
+# container: your_container_name
+
+# mirror:
+# service: Mirror
+# primary: local
+# mirrors: [ amazon, google, microsoft ]
diff --git a/back-chat/db/migrate/20240427133843_create_chat_rooms.rb b/back-chat/db/migrate/20240427133843_create_chat_rooms.rb
new file mode 100644
index 0000000000..91de945d40
--- /dev/null
+++ b/back-chat/db/migrate/20240427133843_create_chat_rooms.rb
@@ -0,0 +1,11 @@
+class CreateChatRooms < ActiveRecord::Migration[6.1]
+ def change
+ create_table :chat_rooms do |t|
+ t.string :user1_uuid, null:false
+ t.string :user2_uuid, null:false
+
+ t.timestamps
+ end
+ add_index :chat_rooms, [:user1_uuid, :user2_uuid], unique: true
+ end
+end
diff --git a/back-chat/db/migrate/20240428061101_create_chat_message.rb b/back-chat/db/migrate/20240428061101_create_chat_message.rb
new file mode 100644
index 0000000000..c26c2a1e75
--- /dev/null
+++ b/back-chat/db/migrate/20240428061101_create_chat_message.rb
@@ -0,0 +1,16 @@
+class CreateChatMessage < ActiveRecord::Migration[6.1]
+ def change
+ create_table :chat_messages do |t|
+ t.bigint :chat_room_id, null: false, foreign_key: true
+ t.string :user_id, null: false, foreign_key: {to_table: :users, primary_key: "user_id"}
+ t.text :content, null: false
+ t.datetime :timestamp, null: false
+ end
+
+ add_foreign_key :chat_messages, :chat_rooms, column: :chat_room_id, primary_key: "id"
+ add_foreign_key :chat_messages, :users, column: :user_id, primary_key: "user_id"
+ add_index :chat_messages, :chat_room_id
+ add_index :chat_messages, :user_id
+ add_index :chat_messages, [:chat_room_id, :timestamp]
+ end
+end
\ No newline at end of file
diff --git a/back-chat/db/schema.rb b/back-chat/db/schema.rb
new file mode 100644
index 0000000000..27ab07c70c
--- /dev/null
+++ b/back-chat/db/schema.rb
@@ -0,0 +1,66 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema.define(version: 2024_04_27_133843) do
+
+ create_table "announcement_files", primary_key: "announcement_file_id", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.string "link"
+ t.string "title"
+ t.bigint "announcement_id"
+ t.index ["announcement_id"], name: "FK70ifj0ccp393fv5ysusilffcm"
+ end
+
+ create_table "announcements", primary_key: "announcement_id", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.datetime "created_date", precision: 6
+ t.datetime "modified_date", precision: 6
+ t.string "author"
+ t.string "author_phone"
+ t.string "department"
+ t.text "document", size: :long, null: false
+ t.string "language", null: false
+ t.string "title", null: false
+ t.string "type", null: false
+ t.string "url", null: false
+ t.date "written_date", null: false
+ end
+
+ create_table "chat_rooms", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.string "user1_uuid", null: false
+ t.string "user2_uuid", null: false
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.index ["user1_uuid", "user2_uuid"], name: "index_chat_rooms_on_user1_uuid_and_user2_uuid", unique: true
+ end
+
+ create_table "menu", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.string "cafeteria", null: false
+ t.datetime "date", precision: 6, null: false
+ t.string "language", null: false
+ t.string "name", null: false
+ t.bigint "price", null: false
+ t.string "section", null: false
+ end
+
+ create_table "users", primary_key: "user_id", id: :string, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.datetime "created_date", precision: 6
+ t.datetime "modified_date", precision: 6
+ t.string "country", null: false
+ t.string "email", null: false
+ t.string "major", null: false
+ t.string "name", null: false
+ t.string "phone_number"
+ t.string "role"
+ t.index ["email"], name: "UK_6dotkott2kjsp8vw4d0m25fb7", unique: true
+ end
+
+ add_foreign_key "announcement_files", "announcements", primary_key: "announcement_id", name: "FK70ifj0ccp393fv5ysusilffcm"
+end
diff --git a/back-chat/db/seeds.rb b/back-chat/db/seeds.rb
new file mode 100644
index 0000000000..f3a0480d18
--- /dev/null
+++ b/back-chat/db/seeds.rb
@@ -0,0 +1,7 @@
+# This file should contain all the record creation needed to seed the database with its default values.
+# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
+#
+# Examples:
+#
+# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }])
+# Character.create(name: 'Luke', movie: movies.first)
diff --git a/back-chat/docker-compose.yml b/back-chat/docker-compose.yml
new file mode 100644
index 0000000000..23f8c677d8
--- /dev/null
+++ b/back-chat/docker-compose.yml
@@ -0,0 +1,17 @@
+# 채팅서버 테스트를 위한 Docker-compose입니다.
+# 실제 배포용은 Spring에 있는 Docker-compose입니다.
+# 해당 docker-compose는 테스트용으로만 사용해주세요.
+
+version: '1.0'
+services:
+ ruby:
+ container_name: ruby
+ restart: always
+ image: capstone30/ruby
+ env_file:
+ - .env
+ environment:
+ TZ : "Asia/Seoul"
+ ports:
+ - "3000:3000"
+
diff --git a/back-chat/entrypoint.sh b/back-chat/entrypoint.sh
new file mode 100644
index 0000000000..2b43a96d4c
--- /dev/null
+++ b/back-chat/entrypoint.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+bin/rails db:migrate RAILS_ENV=development
+
diff --git a/back-chat/lib/tasks/.keep b/back-chat/lib/tasks/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/log/.keep b/back-chat/log/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/public/robots.txt b/back-chat/public/robots.txt
new file mode 100644
index 0000000000..c19f78ab68
--- /dev/null
+++ b/back-chat/public/robots.txt
@@ -0,0 +1 @@
+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
diff --git a/back-chat/storage/.keep b/back-chat/storage/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/test/channels/application_cable/connection_test.rb b/back-chat/test/channels/application_cable/connection_test.rb
new file mode 100644
index 0000000000..800405f15e
--- /dev/null
+++ b/back-chat/test/channels/application_cable/connection_test.rb
@@ -0,0 +1,11 @@
+require "test_helper"
+
+class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
+ # test "connects with cookies" do
+ # cookies.signed[:user_id] = 42
+ #
+ # connect
+ #
+ # assert_equal connection.user_id, "42"
+ # end
+end
diff --git a/back-chat/test/controllers/.keep b/back-chat/test/controllers/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/test/controllers/chats_polling_controller_test.rb b/back-chat/test/controllers/chats_polling_controller_test.rb
new file mode 100644
index 0000000000..fc9a13101d
--- /dev/null
+++ b/back-chat/test/controllers/chats_polling_controller_test.rb
@@ -0,0 +1,8 @@
+require "test_helper"
+
+class ChatsPollingControllerTest < ActionDispatch::IntegrationTest
+ test "should get create" do
+ get chats_polling_create_url
+ assert_response :success
+ end
+end
diff --git a/back-chat/test/fixtures/chat_rooms.yml b/back-chat/test/fixtures/chat_rooms.yml
new file mode 100644
index 0000000000..26dbd9fc34
--- /dev/null
+++ b/back-chat/test/fixtures/chat_rooms.yml
@@ -0,0 +1,9 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ user1_uuid: MyString
+ user2_uuid: MyString
+
+two:
+ user1_uuid: MyString
+ user2_uuid: MyString
diff --git a/back-chat/test/fixtures/files/.keep b/back-chat/test/fixtures/files/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/test/integration/.keep b/back-chat/test/integration/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/test/mailers/.keep b/back-chat/test/mailers/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/test/models/.keep b/back-chat/test/models/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/test/models/chat_room_test.rb b/back-chat/test/models/chat_room_test.rb
new file mode 100644
index 0000000000..3451e5c3a0
--- /dev/null
+++ b/back-chat/test/models/chat_room_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class ChatRoomTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/back-chat/test/test_helper.rb b/back-chat/test/test_helper.rb
new file mode 100644
index 0000000000..47b598dee4
--- /dev/null
+++ b/back-chat/test/test_helper.rb
@@ -0,0 +1,13 @@
+ENV['RAILS_ENV'] ||= 'test'
+require_relative "../config/environment"
+require "rails/test_help"
+
+class ActiveSupport::TestCase
+ # Run tests in parallel with specified workers
+ parallelize(workers: :number_of_processors)
+
+ # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
+ fixtures :all
+
+ # Add more helper methods to be used by all tests here...
+end
diff --git a/back-chat/tmp/.keep b/back-chat/tmp/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/tmp/pids/.keep b/back-chat/tmp/pids/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back-chat/vendor/.keep b/back-chat/vendor/.keep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/back/.gitignore b/back/.gitignore
index c2065bc262..9bd38c3eb0 100644
--- a/back/.gitignore
+++ b/back/.gitignore
@@ -35,3 +35,4 @@ out/
### VS Code ###
.vscode/
+.env
\ No newline at end of file
diff --git a/back/Dockerfile b/back/Dockerfile
index 18ba33b77b..4177b0ae69 100644
--- a/back/Dockerfile
+++ b/back/Dockerfile
@@ -1,4 +1,39 @@
-FROM openjdk:17-alpine
-ARG JAR_FILE=./build/libs/*.jar
-COPY ${JAR_FILE} app.jar
-ENTRYPOINT ["java","-jar","/app.jar"]
\ No newline at end of file
+FROM eclipse-temurin:17-jdk AS download
+WORKDIR /workspace/app
+RUN wget https://www.openssl.org/source/openssl-1.1.1o.tar.gz
+
+FROM eclipse-temurin:17-jdk AS build
+WORKDIR /workspace/app
+
+RUN apt-get update && apt-get remove -y openssl && apt-get install -y perl libfindbin-libs-perl build-essential zlib1g-dev
+
+COPY --from=download /workspace/app/openssl-1.1.1o.tar.gz .
+RUN tar -zxvf openssl-1.1.1o.tar.gz
+
+WORKDIR /workspace/app/openssl-1.1.1o
+RUN ./config --prefix=/usr/local/ssl --openssldir=/usr/local/ssl shared zlib
+RUN make && make install
+
+RUN apt-get update && apt-get install -y tzdata ca-certificates
+
+COPY . /workspace/app
+WORKDIR /workspace/app
+RUN chmod +x ./gradlew
+RUN --mount=type=cache,target=/root/.gradle ./gradlew clean bootJar
+RUN mkdir -p build/dependency && (cd build/dependency; jar -xf ../libs/*-SNAPSHOT.jar)
+
+FROM eclipse-temurin:17-jdk
+VOLUME /tmp
+
+COPY --from=build /usr/local/ssl /usr/local/ssl
+
+ARG DEPENDENCY=/workspace/app/build/dependency
+COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib
+COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF
+COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app
+
+ENV PATH="/usr/local/ssl/bin:$PATH"
+ENV LD_LIBRARY_PATH="/usr/local/ssl/lib:$LD_LIBRARY_PATH"
+ENV SSL_CERT_DIR "/etc/ssl/certs"
+
+ENTRYPOINT ["java", "-cp", "app:app/lib/*", "com.example.capstone.CapstoneApplication"]
diff --git a/back/build.gradle b/back/build.gradle
index e304bbddbf..f7690e824d 100644
--- a/back/build.gradle
+++ b/back/build.gradle
@@ -25,7 +25,6 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-web-services'
- testImplementation 'org.springframework.boot:spring-boot-starter-test'
//lombok
compileOnly 'org.projectlombok:lombok'
@@ -36,7 +35,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
- runtimeOnly 'com.h2database:h2'
+ implementation group: 'mysql', name: 'mysql-connector-java', version: '8.0.27'
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
@@ -48,10 +48,93 @@ dependencies {
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
+ //Deepl
+ implementation 'com.deepl.api:deepl-java:1.4.0'
+
+ //Azure
+ implementation group: 'com.microsoft.cognitiveservices.speech', name: 'client-sdk', version: "1.36.0", ext: "jar"
+
+ //Jakarta-JSON
+ implementation 'jakarta.json:jakarta.json-api:2.1.2'
+
+ //Diffutils
+ implementation 'io.github.java-diff-utils:java-diff-utils:4.12'
+
+ //S3
+ implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
+
+ //Json-Provider
+ implementation 'org.eclipse.parsson:parsson:1.1.5'
+
+ //Mapper 관련
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
+
+ //크롤링 관련
+ implementation 'org.jsoup:jsoup:1.15.3'
+
+ //테스트 관련
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation "org.junit.jupiter:junit-jupiter:5.8.1"
+ testImplementation "org.testcontainers:testcontainers:1.19.7"
+ testImplementation "org.testcontainers:junit-jupiter:1.19.7"
+ testImplementation "org.testcontainers:mysql:1.19.7"
+
+ //QueryDSL
+ implementation "com.querydsl:querydsl-core"
+ implementation "com.querydsl:querydsl-collections"
+ implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
+ annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
+
+ annotationProcessor "jakarta.annotation:jakarta.annotation-api"
+ // java.lang.NoClassDefFoundError (javax.annotation.Generated) 에러 대응 코드
+ annotationProcessor "jakarta.persistence:jakarta.persistence-api"
+ // java.lang.NoClassDefFoundError (javax.annotation.Entity) 에러 대응 코드
+}
+
+// --------------------------------- Querydsl settings -----------------------------------------------
+def generated = layout.buildDirectory.dir("generated/querydsl").get().asFile
+
+tasks.withType(JavaCompile).configureEach {
+ options.getGeneratedSourceOutputDirectory().set(file(generated))
+}
+
+sourceSets {
+ main.java.srcDirs += [generated]
}
+clean {
+ delete file(generated)
+}
+// ----------------------------------------------------------------------------------------------------
+
+// --------------------------------- ENV Setting -----------------------------------------------------
+def static getenv(path = ".env") {
+ def env = [:]
+ def file = new File(path)
+ if (file.exists()) {
+ file.eachLine { line ->
+ def pair = line.split("=", 2)
+ if (pair.length == 2) {
+ def (name, value) = pair
+ env[name.trim()] = value.trim()
+ }
+ }
+ }
+ return env
+}
+
+def envVariables = getenv()
+envVariables.each { name, value ->
+ project.ext[name] = value
+}
+// ----------------------------------------------------------------------------------------------------
+
tasks.named('test') {
useJUnitPlatform()
+ environment envVariables
+}
+
+bootJar {
+ mainClass = 'com.example.capstone.CapstoneApplication'
}
diff --git a/back/docker-compose.yml b/back/docker-compose.yml
index 4e35b38d8b..c99c1292e7 100644
--- a/back/docker-compose.yml
+++ b/back/docker-compose.yml
@@ -1,11 +1,86 @@
-version: '3.0'
+version: '3'
+networks:
+ backend_network:
+ ssl_network:
+
services:
+ redis:
+ image: redis:7.2.0-alpine
+ container_name: redis
+ hostname: redis
+ restart: unless-stopped
+ environment:
+ TZ: "Asia/Seoul"
+ ports:
+ - 6379:6379
+ healthcheck:
+ test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ networks:
+ - backend_network
+
spring:
container_name: spring
- build:
- context: .
- dockerfile: ./Dockerfile
+ restart: always
+ image: capstone30/spring
+ env_file:
+ - .env
+ environment:
+ TZ : "Asia/Seoul"
+ ports:
+ - "8080:8080"
+ depends_on:
+ redis:
+ condition: service_healthy
+ volumes:
+ - /path/to/logs:/container/path/to/logs
+ networks:
+ - backend_network
+
+ ruby:
+ container_name: ruby
+ restart: always
+ image: capstone30/ruby
env_file:
- .env
+ environment:
+ TZ: "Asia/Seoul"
ports:
- - "8080:8080"
\ No newline at end of file
+ - "3000:3000"
+ networks:
+ - backend_network
+
+ nginx:
+ container_name: nginx
+ image: capstone30/nginx
+ restart: unless-stopped
+ environment:
+ TZ: "Asia/Seoul"
+ ports:
+ - "80:80"
+ - "443:443"
+ env_file:
+ - .env
+ volumes:
+ - ./data/certbot/conf:/etc/letsencrypt
+ - ./data/certbot/www:/var/www/certbot
+ depends_on:
+ - "redis"
+ - "spring"
+ - "ruby"
+ command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
+ networks:
+ - backend_network
+ - ssl_network
+
+ certbot:
+ image: certbot/certbot
+ restart: unless-stopped
+ volumes:
+ - ./data/certbot/conf:/etc/letsencrypt
+ - ./data/certbot/www:/var/www/certbot
+ entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
+ networks:
+ - ssl_network
diff --git a/back/env.sh b/back/env.sh
new file mode 100644
index 0000000000..41cba56fb7
--- /dev/null
+++ b/back/env.sh
@@ -0,0 +1,6 @@
+ENV_FILE=".env"
+
+if test -f "$ENV_FILE"; then
+ export "$(grep -v '^#' .env | xargs -d '\n')"
+fi
+echo "DONE"
\ No newline at end of file
diff --git a/back/loadEnv.sh b/back/loadEnv.sh
new file mode 100644
index 0000000000..5bc6a2cad1
--- /dev/null
+++ b/back/loadEnv.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+ENV_FILE=".env"
+
+if [ ! -f "$ENV_FILE" ]; then
+ echo "Error: .env file does not exist."
+ exit 1
+fi
+
+while IFS='=' read -r key value
+do
+ if [ ! -z "$key" ]; then
+ export "$key=$value"
+ fi
+done < "$ENV_FILE"
+
+echo "All variables from $ENV_FILE are now exported."
+
+gradle clean build
\ No newline at end of file
diff --git a/back/nginx/Dockerfile b/back/nginx/Dockerfile
new file mode 100644
index 0000000000..af622f1c49
--- /dev/null
+++ b/back/nginx/Dockerfile
@@ -0,0 +1,3 @@
+FROM nginx
+COPY default.conf.template /etc/nginx/conf.d/default.conf.template
+ENTRYPOINT ["/bin/bash", "-c", "envsubst '${SERVER_NAME}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"]
diff --git a/back/nginx/default.conf.template b/back/nginx/default.conf.template
new file mode 100644
index 0000000000..087980892d
--- /dev/null
+++ b/back/nginx/default.conf.template
@@ -0,0 +1,55 @@
+upstream spring_backend {
+ server spring:8080;
+}
+
+upstream ruby_backend {
+ server ruby:3000;
+}
+
+server {
+ listen 80;
+ server_name ${SERVER_NAME};
+ access_log off;
+ server_tokens off;
+ client_max_body_size 1G;
+
+ location /.well-known/acme-challenge/ {
+ allow all;
+ root /var/www/certbot;
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+server {
+ listen 443 ssl;
+ server_name ${SERVER_NAME};
+ access_log off;
+ server_tokens off;
+ client_max_body_size 1G;
+
+ ssl_certificate /etc/letsencrypt/live/${SERVER_NAME}/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/${SERVER_NAME}/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ location /api {
+ proxy_pass http://spring_backend;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-Host $server_name;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location /api/chat {
+ proxy_pass http://ruby_backend;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-Host $server_name;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/back/src/main/generated/com/example/capstone/domain/announcement/entity/QAnnouncement.java b/back/src/main/generated/com/example/capstone/domain/announcement/entity/QAnnouncement.java
new file mode 100644
index 0000000000..89b0724e35
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/announcement/entity/QAnnouncement.java
@@ -0,0 +1,66 @@
+package com.example.capstone.domain.announcement.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QAnnouncement is a Querydsl query type for Announcement
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QAnnouncement extends EntityPathBase {
+
+ private static final long serialVersionUID = -666090741L;
+
+ public static final QAnnouncement announcement = new QAnnouncement("announcement");
+
+ public final com.example.capstone.global.entity.QBaseTimeEntity _super = new com.example.capstone.global.entity.QBaseTimeEntity(this);
+
+ public final StringPath author = createString("author");
+
+ public final StringPath authorPhone = createString("authorPhone");
+
+ //inherited
+ public final DateTimePath createdDate = _super.createdDate;
+
+ public final StringPath department = createString("department");
+
+ public final StringPath document = createString("document");
+
+ public final ListPath files = this.createList("files", AnnouncementFile.class, QAnnouncementFile.class, PathInits.DIRECT2);
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath language = createString("language");
+
+ //inherited
+ public final DateTimePath modifiedDate = _super.modifiedDate;
+
+ public final StringPath title = createString("title");
+
+ public final StringPath type = createString("type");
+
+ public final StringPath url = createString("url");
+
+ public final DatePath writtenDate = createDate("writtenDate", java.time.LocalDate.class);
+
+ public QAnnouncement(String variable) {
+ super(Announcement.class, forVariable(variable));
+ }
+
+ public QAnnouncement(Path extends Announcement> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QAnnouncement(PathMetadata metadata) {
+ super(Announcement.class, metadata);
+ }
+
+}
+
diff --git a/back/src/main/generated/com/example/capstone/domain/announcement/entity/QAnnouncementFile.java b/back/src/main/generated/com/example/capstone/domain/announcement/entity/QAnnouncementFile.java
new file mode 100644
index 0000000000..755fc2d60c
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/announcement/entity/QAnnouncementFile.java
@@ -0,0 +1,55 @@
+package com.example.capstone.domain.announcement.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QAnnouncementFile is a Querydsl query type for AnnouncementFile
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QAnnouncementFile extends EntityPathBase {
+
+ private static final long serialVersionUID = -2094059737L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QAnnouncementFile announcementFile = new QAnnouncementFile("announcementFile");
+
+ public final QAnnouncement announcement;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath link = createString("link");
+
+ public final StringPath title = createString("title");
+
+ public QAnnouncementFile(String variable) {
+ this(AnnouncementFile.class, forVariable(variable), INITS);
+ }
+
+ public QAnnouncementFile(Path extends AnnouncementFile> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QAnnouncementFile(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QAnnouncementFile(PathMetadata metadata, PathInits inits) {
+ this(AnnouncementFile.class, metadata, inits);
+ }
+
+ public QAnnouncementFile(Class extends AnnouncementFile> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.announcement = inits.isInitialized("announcement") ? new QAnnouncement(forProperty("announcement")) : null;
+ }
+
+}
+
diff --git a/back/src/main/generated/com/example/capstone/domain/announcement/mapper/AnnouncementMapperImpl.java b/back/src/main/generated/com/example/capstone/domain/announcement/mapper/AnnouncementMapperImpl.java
new file mode 100644
index 0000000000..0b77d37f49
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/announcement/mapper/AnnouncementMapperImpl.java
@@ -0,0 +1,39 @@
+package com.example.capstone.domain.announcement.mapper;
+
+import com.example.capstone.domain.announcement.dto.AnnouncementListResponse;
+import com.example.capstone.domain.announcement.entity.Announcement;
+import java.time.LocalDate;
+import javax.annotation.processing.Generated;
+
+@Generated(
+ value = "org.mapstruct.ap.MappingProcessor",
+ date = "2024-04-30T01:59:17+0900",
+ comments = "version: 1.5.5.Final, compiler: javac, environment: Java 17.0.9 (JetBrains s.r.o.)"
+)
+public class AnnouncementMapperImpl implements AnnouncementMapper {
+
+ @Override
+ public AnnouncementListResponse entityToResponse(Announcement announcement) {
+ if ( announcement == null ) {
+ return null;
+ }
+
+ Long id = null;
+ String title = null;
+ String type = null;
+ LocalDate writtenDate = null;
+ String department = null;
+ String author = null;
+
+ id = announcement.getId();
+ title = announcement.getTitle();
+ type = announcement.getType();
+ writtenDate = announcement.getWrittenDate();
+ department = announcement.getDepartment();
+ author = announcement.getAuthor();
+
+ AnnouncementListResponse announcementListResponse = new AnnouncementListResponse( id, title, type, writtenDate, department, author );
+
+ return announcementListResponse;
+ }
+}
diff --git a/back/src/main/generated/com/example/capstone/domain/help/entity/Qhelp.java b/back/src/main/generated/com/example/capstone/domain/help/entity/Qhelp.java
new file mode 100644
index 0000000000..b21bd34f32
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/help/entity/Qhelp.java
@@ -0,0 +1,55 @@
+package com.example.capstone.domain.help.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QHelp is a Querydsl query type for Help
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QHelp extends EntityPathBase {
+
+ private static final long serialVersionUID = -836844545L;
+
+ public static final QHelp help = new QHelp("help");
+
+ public final StringPath author = createString("author");
+
+ public final StringPath context = createString("context");
+
+ public final StringPath country = createString("country");
+
+ public final DateTimePath createdDate = createDateTime("createdDate", java.time.LocalDateTime.class);
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final BooleanPath isDone = createBoolean("isDone");
+
+ public final BooleanPath isHelper = createBoolean("isHelper");
+
+ public final StringPath title = createString("title");
+
+ public final DateTimePath updatedDate = createDateTime("updatedDate", java.time.LocalDateTime.class);
+
+ public final ComparablePath uuid = createComparable("uuid", java.util.UUID.class);
+
+ public QHelp(String variable) {
+ super(Help.class, forVariable(variable));
+ }
+
+ public QHelp(Path extends Help> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QHelp(PathMetadata metadata) {
+ super(Help.class, metadata);
+ }
+
+}
+
diff --git a/back/src/main/generated/com/example/capstone/domain/menu/entity/QMenu.java b/back/src/main/generated/com/example/capstone/domain/menu/entity/QMenu.java
new file mode 100644
index 0000000000..eb8eee6255
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/menu/entity/QMenu.java
@@ -0,0 +1,49 @@
+package com.example.capstone.domain.menu.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QMenu is a Querydsl query type for Menu
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QMenu extends EntityPathBase {
+
+ private static final long serialVersionUID = -1572531397L;
+
+ public static final QMenu menu = new QMenu("menu");
+
+ public final StringPath cafeteria = createString("cafeteria");
+
+ public final DateTimePath date = createDateTime("date", java.time.LocalDateTime.class);
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath language = createString("language");
+
+ public final StringPath name = createString("name");
+
+ public final NumberPath price = createNumber("price", Long.class);
+
+ public final StringPath section = createString("section");
+
+ public QMenu(String variable) {
+ super(Menu.class, forVariable(variable));
+ }
+
+ public QMenu(Path extends Menu> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QMenu(PathMetadata metadata) {
+ super(Menu.class, metadata);
+ }
+
+}
+
diff --git a/back/src/main/generated/com/example/capstone/domain/qna/entity/QAnswer.java b/back/src/main/generated/com/example/capstone/domain/qna/entity/QAnswer.java
new file mode 100644
index 0000000000..4340a289e8
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/qna/entity/QAnswer.java
@@ -0,0 +1,63 @@
+package com.example.capstone.domain.qna.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QAnswer is a Querydsl query type for Answer
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QAnswer extends EntityPathBase {
+
+ private static final long serialVersionUID = 759084319L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QAnswer answer = new QAnswer("answer");
+
+ public final StringPath author = createString("author");
+
+ public final StringPath context = createString("context");
+
+ public final DateTimePath createdDate = createDateTime("createdDate", java.time.LocalDateTime.class);
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final NumberPath likeCount = createNumber("likeCount", Long.class);
+
+ public final QQuestion question;
+
+ public final DateTimePath updatedDate = createDateTime("updatedDate", java.time.LocalDateTime.class);
+
+ public final ComparablePath uuid = createComparable("uuid", java.util.UUID.class);
+
+ public QAnswer(String variable) {
+ this(Answer.class, forVariable(variable), INITS);
+ }
+
+ public QAnswer(Path extends Answer> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QAnswer(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QAnswer(PathMetadata metadata, PathInits inits) {
+ this(Answer.class, metadata, inits);
+ }
+
+ public QAnswer(Class extends Answer> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.question = inits.isInitialized("question") ? new QQuestion(forProperty("question")) : null;
+ }
+
+}
+
diff --git a/back/src/main/generated/com/example/capstone/domain/qna/entity/QFAQ.java b/back/src/main/generated/com/example/capstone/domain/qna/entity/QFAQ.java
new file mode 100644
index 0000000000..0b611db065
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/qna/entity/QFAQ.java
@@ -0,0 +1,53 @@
+package com.example.capstone.domain.qna.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QFAQ is a Querydsl query type for FAQ
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QFAQ extends EntityPathBase {
+
+ private static final long serialVersionUID = -938805931L;
+
+ public static final QFAQ fAQ = new QFAQ("fAQ");
+
+ public final StringPath answer = createString("answer");
+
+ public final StringPath author = createString("author");
+
+ public final DateTimePath createdDate = createDateTime("createdDate", java.time.LocalDateTime.class);
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath language = createString("language");
+
+ public final StringPath question = createString("question");
+
+ public final StringPath tag = createString("tag");
+
+ public final StringPath title = createString("title");
+
+ public final DateTimePath updatedDate = createDateTime("updatedDate", java.time.LocalDateTime.class);
+
+ public QFAQ(String variable) {
+ super(FAQ.class, forVariable(variable));
+ }
+
+ public QFAQ(Path extends FAQ> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QFAQ(PathMetadata metadata) {
+ super(FAQ.class, metadata);
+ }
+
+}
+
diff --git a/back/src/main/generated/com/example/capstone/domain/qna/entity/QFAQImage.java b/back/src/main/generated/com/example/capstone/domain/qna/entity/QFAQImage.java
new file mode 100644
index 0000000000..df8be59d21
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/qna/entity/QFAQImage.java
@@ -0,0 +1,53 @@
+package com.example.capstone.domain.qna.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QFAQImage is a Querydsl query type for FAQImage
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QFAQImage extends EntityPathBase {
+
+ private static final long serialVersionUID = 1456066822L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QFAQImage fAQImage = new QFAQImage("fAQImage");
+
+ public final QFAQ faqId;
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath url = createString("url");
+
+ public QFAQImage(String variable) {
+ this(FAQImage.class, forVariable(variable), INITS);
+ }
+
+ public QFAQImage(Path extends FAQImage> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QFAQImage(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QFAQImage(PathMetadata metadata, PathInits inits) {
+ this(FAQImage.class, metadata, inits);
+ }
+
+ public QFAQImage(Class extends FAQImage> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.faqId = inits.isInitialized("faqId") ? new QFAQ(forProperty("faqId")) : null;
+ }
+
+}
+
diff --git a/back/src/main/generated/com/example/capstone/domain/qna/entity/QQuestion.java b/back/src/main/generated/com/example/capstone/domain/qna/entity/QQuestion.java
new file mode 100644
index 0000000000..1cd2be27a0
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/qna/entity/QQuestion.java
@@ -0,0 +1,53 @@
+package com.example.capstone.domain.qna.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QQuestion is a Querydsl query type for Question
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QQuestion extends EntityPathBase {
+
+ private static final long serialVersionUID = -1330717433L;
+
+ public static final QQuestion question = new QQuestion("question");
+
+ public final StringPath author = createString("author");
+
+ public final StringPath context = createString("context");
+
+ public final StringPath country = createString("country");
+
+ public final DateTimePath createdDate = createDateTime("createdDate", java.time.LocalDateTime.class);
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final StringPath tag = createString("tag");
+
+ public final StringPath title = createString("title");
+
+ public final DateTimePath updatedDate = createDateTime("updatedDate", java.time.LocalDateTime.class);
+
+ public final ComparablePath uuid = createComparable("uuid", java.util.UUID.class);
+
+ public QQuestion(String variable) {
+ super(Question.class, forVariable(variable));
+ }
+
+ public QQuestion(Path extends Question> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QQuestion(PathMetadata metadata) {
+ super(Question.class, metadata);
+ }
+
+}
+
diff --git a/back/src/main/generated/com/example/capstone/domain/qna/entity/QQuestionImage.java b/back/src/main/generated/com/example/capstone/domain/qna/entity/QQuestionImage.java
new file mode 100644
index 0000000000..d9c05a1af2
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/qna/entity/QQuestionImage.java
@@ -0,0 +1,53 @@
+package com.example.capstone.domain.qna.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+import com.querydsl.core.types.dsl.PathInits;
+
+
+/**
+ * QQuestionImage is a Querydsl query type for QuestionImage
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QQuestionImage extends EntityPathBase {
+
+ private static final long serialVersionUID = -1153636204L;
+
+ private static final PathInits INITS = PathInits.DIRECT2;
+
+ public static final QQuestionImage questionImage = new QQuestionImage("questionImage");
+
+ public final NumberPath id = createNumber("id", Long.class);
+
+ public final QQuestion questionId;
+
+ public final StringPath url = createString("url");
+
+ public QQuestionImage(String variable) {
+ this(QuestionImage.class, forVariable(variable), INITS);
+ }
+
+ public QQuestionImage(Path extends QuestionImage> path) {
+ this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS));
+ }
+
+ public QQuestionImage(PathMetadata metadata) {
+ this(metadata, PathInits.getFor(metadata, INITS));
+ }
+
+ public QQuestionImage(PathMetadata metadata, PathInits inits) {
+ this(QuestionImage.class, metadata, inits);
+ }
+
+ public QQuestionImage(Class extends QuestionImage> type, PathMetadata metadata, PathInits inits) {
+ super(type, metadata, inits);
+ this.questionId = inits.isInitialized("questionId") ? new QQuestion(forProperty("questionId")) : null;
+ }
+
+}
+
diff --git a/back/src/main/generated/com/example/capstone/domain/user/entity/QUser.java b/back/src/main/generated/com/example/capstone/domain/user/entity/QUser.java
new file mode 100644
index 0000000000..9f6352095d
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/user/entity/QUser.java
@@ -0,0 +1,57 @@
+package com.example.capstone.domain.user.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QUser is a Querydsl query type for User
+ */
+@Generated("com.querydsl.codegen.DefaultEntitySerializer")
+public class QUser extends EntityPathBase {
+
+ private static final long serialVersionUID = -595427821L;
+
+ public static final QUser user = new QUser("user");
+
+ public final com.example.capstone.global.entity.QBaseTimeEntity _super = new com.example.capstone.global.entity.QBaseTimeEntity(this);
+
+ public final StringPath country = createString("country");
+
+ //inherited
+ public final DateTimePath createdDate = _super.createdDate;
+
+ public final StringPath email = createString("email");
+
+ public final StringPath id = createString("id");
+
+ public final StringPath major = createString("major");
+
+ //inherited
+ public final DateTimePath modifiedDate = _super.modifiedDate;
+
+ public final StringPath name = createString("name");
+
+ public final StringPath phoneNumber = createString("phoneNumber");
+
+ public final StringPath role = createString("role");
+
+ public QUser(String variable) {
+ super(User.class, forVariable(variable));
+ }
+
+ public QUser(Path extends User> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QUser(PathMetadata metadata) {
+ super(User.class, metadata);
+ }
+
+}
+
diff --git a/back/src/main/generated/com/example/capstone/domain/user/util/UserMapperImpl.java b/back/src/main/generated/com/example/capstone/domain/user/util/UserMapperImpl.java
new file mode 100644
index 0000000000..3e96b83c6e
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/domain/user/util/UserMapperImpl.java
@@ -0,0 +1,50 @@
+package com.example.capstone.domain.user.util;
+
+import com.example.capstone.domain.jwt.PrincipalDetails;
+import com.example.capstone.domain.user.dto.SignupRequest;
+import com.example.capstone.domain.user.entity.User;
+import javax.annotation.processing.Generated;
+
+@Generated(
+ value = "org.mapstruct.ap.MappingProcessor",
+ date = "2024-04-30T01:59:17+0900",
+ comments = "version: 1.5.5.Final, compiler: javac, environment: Java 17.0.9 (JetBrains s.r.o.)"
+)
+public class UserMapperImpl implements UserMapper {
+
+ @Override
+ public User signupReqeustToUser(SignupRequest request) {
+ if ( request == null ) {
+ return null;
+ }
+
+ User.UserBuilder user = User.builder();
+
+ user.id( request.uuid() );
+ user.email( request.email() );
+ user.major( request.major() );
+ user.country( request.country() );
+ user.name( request.name() );
+ user.phoneNumber( request.phoneNumber() );
+
+ return user.build();
+ }
+
+ @Override
+ public User principalDetailsToUser(PrincipalDetails principalDetails) {
+ if ( principalDetails == null ) {
+ return null;
+ }
+
+ User.UserBuilder user = User.builder();
+
+ user.id( principalDetails.getUuid() );
+ user.email( principalDetails.getEmail() );
+ user.major( principalDetails.getMajor() );
+ user.country( principalDetails.getCountry() );
+ user.name( principalDetails.getName() );
+ user.phoneNumber( principalDetails.getPhoneNumber() );
+
+ return user.build();
+ }
+}
diff --git a/back/src/main/generated/com/example/capstone/global/entity/QBaseTimeEntity.java b/back/src/main/generated/com/example/capstone/global/entity/QBaseTimeEntity.java
new file mode 100644
index 0000000000..33603e9b66
--- /dev/null
+++ b/back/src/main/generated/com/example/capstone/global/entity/QBaseTimeEntity.java
@@ -0,0 +1,39 @@
+package com.example.capstone.global.entity;
+
+import static com.querydsl.core.types.PathMetadataFactory.*;
+
+import com.querydsl.core.types.dsl.*;
+
+import com.querydsl.core.types.PathMetadata;
+import javax.annotation.processing.Generated;
+import com.querydsl.core.types.Path;
+
+
+/**
+ * QBaseTimeEntity is a Querydsl query type for BaseTimeEntity
+ */
+@Generated("com.querydsl.codegen.DefaultSupertypeSerializer")
+public class QBaseTimeEntity extends EntityPathBase {
+
+ private static final long serialVersionUID = -195769013L;
+
+ public static final QBaseTimeEntity baseTimeEntity = new QBaseTimeEntity("baseTimeEntity");
+
+ public final DateTimePath createdDate = createDateTime("createdDate", java.time.LocalDateTime.class);
+
+ public final DateTimePath modifiedDate = createDateTime("modifiedDate", java.time.LocalDateTime.class);
+
+ public QBaseTimeEntity(String variable) {
+ super(BaseTimeEntity.class, forVariable(variable));
+ }
+
+ public QBaseTimeEntity(Path extends BaseTimeEntity> path) {
+ super(path.getType(), path.getMetadata());
+ }
+
+ public QBaseTimeEntity(PathMetadata metadata) {
+ super(BaseTimeEntity.class, metadata);
+ }
+
+}
+
diff --git a/back/src/main/java/com/example/capstone/CapstoneApplication.java b/back/src/main/java/com/example/capstone/CapstoneApplication.java
index 9d47eec7ad..ce0bb3a98e 100644
--- a/back/src/main/java/com/example/capstone/CapstoneApplication.java
+++ b/back/src/main/java/com/example/capstone/CapstoneApplication.java
@@ -1,13 +1,19 @@
-package com.example.capstone;
-
-import org.springframework.boot.SpringApplication;
-import org.springframework.boot.autoconfigure.SpringBootApplication;
-
-@SpringBootApplication
-public class CapstoneApplication {
-
- public static void main(String[] args) {
- SpringApplication.run(CapstoneApplication.class, args);
- }
-
-}
+package com.example.capstone;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@EnableScheduling
+@EnableJpaAuditing
+@EnableAsync
+@SpringBootApplication
+public class CapstoneApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(CapstoneApplication.class, args);
+ }
+
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java b/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java
new file mode 100644
index 0000000000..79ad33b741
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java
@@ -0,0 +1,127 @@
+package com.example.capstone.domain.announcement.controller;
+
+import com.example.capstone.domain.announcement.dto.AnnouncementListResponse;
+import com.example.capstone.domain.announcement.dto.AnnouncementListWrapper;
+import com.example.capstone.domain.announcement.dto.AnnouncementSearchListRequest;
+import com.example.capstone.domain.announcement.entity.Announcement;
+import com.example.capstone.domain.announcement.service.AnnouncementCallerService;
+import com.example.capstone.domain.announcement.service.AnnouncementSearchService;
+import com.example.capstone.global.dto.ApiResult;
+import com.example.capstone.global.error.exception.BusinessException;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Slice;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+import static com.example.capstone.global.error.exception.ErrorCode.SEARCH_TOO_SHORT;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/announcement")
+@RequiredArgsConstructor
+public class AnnouncementController {
+ private final AnnouncementCallerService announcementCallerService;
+ private final AnnouncementSearchService announcementSearchService;
+
+ @PostMapping("/test")
+ @Operation(summary = "공지사항 크롤링", description = "강제로 공지사항 크롤링을 진행합니다.")
+ ResponseEntity> test(
+ @Parameter(description = "해당 매서드를 실행하기 위해서 관리자 키가 필요합니다.", required = true)
+ @RequestParam(value = "key") String key) {
+ announcementSearchService.testKeyCheck(key);
+ announcementCallerService.crawlingAnnouncement();
+ return ResponseEntity
+ .ok("");
+ }
+
+ @GetMapping("")
+ @Operation(summary = "공지사항 받아오기", description = "커서기반으로 공지사항을 받아옵니다")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "정보 받기 성공"),
+ @ApiResponse(responseCode = "400", description = "정보 받기 실패", content = @Content(mediaType = "application/json"))
+ })
+ ResponseEntity> getAnnouncementList(
+ @Parameter(description = "공지사항 유형입니다. 입력하지 않으면 전체를 받아옵니다.")
+ @RequestParam(defaultValue = "all", value = "type") String type,
+ @Parameter(description = "공지사항 언어입니다. 입력하지 않으면 한국어로 받아옵니다.")
+ @RequestParam(defaultValue = "KO", value = "language") String language,
+ @Parameter(description = "어디까지 로드됐는지 가르키는 커서입니다. 입력하지 않으면 처음부터 10개 받아옵니다.")
+ @RequestParam(defaultValue = "0", value = "cursor") long cursor
+ ) {
+ Slice slice = announcementSearchService.getAnnouncementList(cursor, type, language);
+
+ List announcements = slice.getContent();
+ boolean hasNext = slice.hasNext();
+
+
+ AnnouncementListWrapper response = new AnnouncementListWrapper(null, hasNext, announcements);
+
+
+ if (hasNext && !announcements.isEmpty()) {
+ AnnouncementListResponse lastAnnouncement = announcements.get(announcements.size() - 1);
+ response.setLastCursorId(lastAnnouncement.id());
+ }
+
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully load announcement list", response));
+ }
+
+ @GetMapping("/{announcementId}")
+ @Operation(summary = "공지사항 세부정보 받아오기", description = "공지사항의 세부적인 내용을 받아옵니다.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "정보 받기 성공"),
+ @ApiResponse(responseCode = "400", description = "정보 받기 실패", content = @Content(mediaType = "application/json"))
+ })
+ ResponseEntity> getAnnouncementDetail(@PathVariable(value = "announcementId") long announcementId) {
+ Announcement announcement = announcementSearchService.getAnnouncementDetail(announcementId);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully load announcement", announcement));
+ }
+
+ @GetMapping("/search")
+ @Operation(summary = "공지사항 검색기반으로 가져오기", description = "검색한 공지사항을 커서기반으로 받아옵니다. 2글자 이상으로만 검색됩니다.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "정보 받기 성공"),
+ @ApiResponse(responseCode = "400", description = "정보 받기 실패", content = @Content(mediaType = "application/json"))
+ })
+ ResponseEntity> getAnnouncementSearchList(
+ @Parameter(description = "공지사항 유형입니다. 입력하지 않으면 전체를 받아옵니다.")
+ @RequestParam(defaultValue = "all", value = "type") String type,
+ @Parameter(description = "공지사항 언어입니다. 입력하지 않으면 한국어로 받아옵니다.")
+ @RequestParam(defaultValue = "KO", value = "language") String language,
+ @Parameter(description = "어디까지 로드됐는지 가르키는 커서입니다. 입력하지 않으면 처음부터 10개 받아옵니다.")
+ @RequestParam(defaultValue = "0", value = "cursor") long cursor,
+ @RequestBody AnnouncementSearchListRequest request
+ ) {
+
+ if (request.word().length() < 2) throw new BusinessException(SEARCH_TOO_SHORT);
+
+
+ Slice slice = announcementSearchService.getAnnouncementSearchList(cursor, type,
+ language, request.word());
+
+ List announcements = slice.getContent();
+ boolean hasNext = slice.hasNext();
+
+
+ AnnouncementListWrapper response = new AnnouncementListWrapper(null, hasNext, announcements);
+
+
+ if (hasNext && !announcements.isEmpty()) {
+ AnnouncementListResponse lastAnnouncement = announcements.get(announcements.size() - 1);
+ response.setLastCursorId(lastAnnouncement.id());
+ }
+
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully load announcement list", response));
+ }
+
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListResponse.java b/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListResponse.java
new file mode 100644
index 0000000000..43c1a8a30a
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListResponse.java
@@ -0,0 +1,15 @@
+package com.example.capstone.domain.announcement.dto;
+
+import com.example.capstone.domain.announcement.entity.Announcement;
+
+import java.time.LocalDate;
+
+public record AnnouncementListResponse(
+ Long id,
+ String title,
+ String type,
+ LocalDate writtenDate,
+ String department,
+ String author
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListWrapper.java b/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListWrapper.java
new file mode 100644
index 0000000000..0ee1e2448f
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementListWrapper.java
@@ -0,0 +1,20 @@
+package com.example.capstone.domain.announcement.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.*;
+
+import java.util.List;
+
+@Getter
+@AllArgsConstructor
+public class AnnouncementListWrapper{
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private Long lastCursorId;
+
+ private Boolean hasNext;
+ private List announcements;
+
+ public void setLastCursorId(Long lastCursorId){
+ this.lastCursorId = lastCursorId;
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementSearchListRequest.java b/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementSearchListRequest.java
new file mode 100644
index 0000000000..ef7637980e
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/dto/AnnouncementSearchListRequest.java
@@ -0,0 +1,6 @@
+package com.example.capstone.domain.announcement.dto;
+
+public record AnnouncementSearchListRequest(
+ String word
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/entity/Announcement.java b/back/src/main/java/com/example/capstone/domain/announcement/entity/Announcement.java
new file mode 100644
index 0000000000..68c335bd8d
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/entity/Announcement.java
@@ -0,0 +1,59 @@
+package com.example.capstone.domain.announcement.entity;
+
+import com.example.capstone.global.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "announcements")
+@Getter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Announcement extends BaseTimeEntity {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "announcement_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private String type;
+
+ @Column(nullable = false)
+ private String title;
+
+ @Column(name = "written_date", nullable = false)
+ private LocalDate writtenDate;
+
+ @Column
+ private String department;
+
+ @Column
+ private String author;
+
+ @Column(name = "author_phone")
+ private String authorPhone;
+
+ @Column(columnDefinition = "LONGTEXT", nullable = false)
+ private String document;
+
+ @Column(nullable = false)
+ private String language;
+
+ @Column(nullable = false)
+ private String url;
+
+ @OneToMany(mappedBy = "announcement", cascade = CascadeType.ALL, orphanRemoval = true)
+ private List files;
+
+ public void setFiles(List files){
+ if(files != null){
+ this.files.clear();
+ this.files.addAll(files);
+ }
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/entity/AnnouncementFile.java b/back/src/main/java/com/example/capstone/domain/announcement/entity/AnnouncementFile.java
new file mode 100644
index 0000000000..0b5c1d4ecf
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/entity/AnnouncementFile.java
@@ -0,0 +1,29 @@
+package com.example.capstone.domain.announcement.entity;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "announcement_files")
+@Getter
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class AnnouncementFile {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "announcement_file_id")
+ private Long id;
+
+ @JsonIgnore
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "announcement_id")
+ private Announcement announcement;
+
+ @Column
+ private String link;
+
+ @Column
+ private String title;
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/exception/AnnouncementNotFoundException.java b/back/src/main/java/com/example/capstone/domain/announcement/exception/AnnouncementNotFoundException.java
new file mode 100644
index 0000000000..9a3175396e
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/exception/AnnouncementNotFoundException.java
@@ -0,0 +1,10 @@
+package com.example.capstone.domain.announcement.exception;
+
+import com.example.capstone.global.error.exception.EntityNotFoundException;
+
+public class AnnouncementNotFoundException extends EntityNotFoundException {
+ public AnnouncementNotFoundException(Long id) {
+ super(id + " is not found");
+ }
+}
+
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/mapper/AnnouncementMapper.java b/back/src/main/java/com/example/capstone/domain/announcement/mapper/AnnouncementMapper.java
new file mode 100644
index 0000000000..cace6ecad0
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/mapper/AnnouncementMapper.java
@@ -0,0 +1,15 @@
+package com.example.capstone.domain.announcement.mapper;
+
+import com.example.capstone.domain.announcement.dto.AnnouncementListResponse;
+import com.example.capstone.domain.announcement.entity.Announcement;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.factory.Mappers;
+
+@Mapper
+public interface AnnouncementMapper {
+
+ AnnouncementMapper INSTANCE = Mappers.getMapper(AnnouncementMapper.class);
+
+ AnnouncementListResponse entityToResponse(Announcement announcement);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/repository/AnnouncementCustomRepository.java b/back/src/main/java/com/example/capstone/domain/announcement/repository/AnnouncementCustomRepository.java
new file mode 100644
index 0000000000..4efba71748
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/repository/AnnouncementCustomRepository.java
@@ -0,0 +1,12 @@
+package com.example.capstone.domain.announcement.repository;
+
+import com.example.capstone.domain.announcement.dto.AnnouncementListResponse;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+
+public interface AnnouncementCustomRepository {
+ Slice getFilteredAnnouncementsWithPaging(Long cursorId, String type,
+ String language, Pageable pageable);
+ Slice getFilteredSearchAnnouncementsWithPaging(Long cursorId, String type,
+ String language, String word, Pageable pageable);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/repository/AnnouncementRepository.java b/back/src/main/java/com/example/capstone/domain/announcement/repository/AnnouncementRepository.java
new file mode 100644
index 0000000000..8a3791824e
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/repository/AnnouncementRepository.java
@@ -0,0 +1,11 @@
+package com.example.capstone.domain.announcement.repository;
+
+import com.example.capstone.domain.announcement.entity.Announcement;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface AnnouncementRepository extends JpaRepository, AnnouncementCustomRepository {
+ Optional findByTitle(String title);
+ Optional findById(Long id);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/repository/AnnouncementRepositoryImpl.java b/back/src/main/java/com/example/capstone/domain/announcement/repository/AnnouncementRepositoryImpl.java
new file mode 100644
index 0000000000..91c60f6d2a
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/repository/AnnouncementRepositoryImpl.java
@@ -0,0 +1,93 @@
+package com.example.capstone.domain.announcement.repository;
+
+import com.example.capstone.domain.announcement.dto.AnnouncementListResponse;
+import com.example.capstone.domain.announcement.entity.Announcement;
+import com.example.capstone.domain.announcement.entity.QAnnouncement;
+import com.example.capstone.domain.announcement.mapper.AnnouncementMapper;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.SliceImpl;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static com.example.capstone.domain.announcement.entity.QAnnouncement.announcement;
+
+@Repository
+@RequiredArgsConstructor
+public class AnnouncementRepositoryImpl implements AnnouncementCustomRepository {
+ private final JPAQueryFactory jpaQueryFactory;
+
+ @Override
+ public Slice getFilteredAnnouncementsWithPaging(Long cursorId, String type,
+ String language, Pageable pageable) {
+ QAnnouncement announcement = QAnnouncement.announcement;
+ BooleanExpression predicate = announcement.isNotNull();
+ predicate = predicate.and(eqCursorId(cursorId));
+ predicate = predicate.and(announcement.language.eq(language));
+
+ if(!type.equals("all")){
+ predicate = predicate.and(announcement.type.eq(type));
+ }
+
+ List announcements = jpaQueryFactory
+ .selectFrom(announcement)
+ .where(predicate)
+ .orderBy(announcement.createdDate.desc())
+ .limit(pageable.getPageSize() + 1)
+ .fetch();
+
+ List list = announcements.stream()
+ .map(AnnouncementMapper.INSTANCE::entityToResponse).collect(Collectors.toList());
+
+ boolean hasNext = false;
+ if (list.size() > pageable.getPageSize() && list.size() != 0) {
+ list.remove(pageable.getPageSize());
+ hasNext = true;
+ }
+
+ return new SliceImpl<>(list, pageable, hasNext);
+ }
+
+ @Override
+ public Slice getFilteredSearchAnnouncementsWithPaging(Long cursorId, String type, String language, String word, Pageable pageable) {
+ QAnnouncement announcement = QAnnouncement.announcement;
+ BooleanExpression predicate = announcement.isNotNull();
+ predicate = predicate.and(eqCursorId(cursorId));
+ predicate = predicate.and(announcement.language.eq(language));
+
+ if(!type.equals("all")){
+ predicate = predicate.and(announcement.type.eq(type));
+ }
+
+ List announcements = jpaQueryFactory
+ .selectFrom(announcement)
+ .where(predicate)
+ .where(announcement.title.containsIgnoreCase(word))
+ .orderBy(announcement.createdDate.desc())
+ .limit(pageable.getPageSize() + 1)
+ .fetch();
+
+ List list = announcements.stream()
+ .map(AnnouncementMapper.INSTANCE::entityToResponse).collect(Collectors.toList());
+
+ boolean hasNext = false;
+ if (list.size() > pageable.getPageSize() && list.size() != 0) {
+ list.remove(pageable.getPageSize());
+ hasNext = true;
+ }
+
+ return new SliceImpl<>(list, pageable, hasNext);
+ }
+
+ private BooleanExpression eqCursorId(Long cursorId) {
+ if (cursorId != null) {
+ return announcement.id.lt(cursorId);
+ }
+ return null;
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCallerService.java b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCallerService.java
new file mode 100644
index 0000000000..7b597f1989
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCallerService.java
@@ -0,0 +1,32 @@
+package com.example.capstone.domain.announcement.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import static com.example.capstone.domain.announcement.service.AnnouncementUrl.*;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AnnouncementCallerService {
+ private final AnnouncementCrawlService announcementCrawlService;
+
+ @Scheduled(cron = "0 0 0 * * *")
+ public void crawlingAnnouncement() {
+ announcementCrawlService.crawlKookminAnnouncement(KOOKMIN_OFFICIAL);
+ announcementCrawlService.crawlInternationlAnnouncement(INTERNATIONAL_ACADEMIC);
+ announcementCrawlService.crawlKookminAnnouncement(INTERNATIONAL_VISA);
+ announcementCrawlService.crawlInternationlAnnouncement(INTERNATIONAL_PROGRAM);
+ announcementCrawlService.crawlInternationlAnnouncement(INTERNATIONAL_EVENT);
+ announcementCrawlService.crawlInternationlAnnouncement(INTERNATIONAL_SCHOLARSHIP);
+ announcementCrawlService.crawlInternationlAnnouncement(INTERNATIONAL_GKS);
+ announcementCrawlService.crawlSoftwareAnnouncement(SOFTWARE_ACADEMIC);
+ announcementCrawlService.crawlSoftwareAnnouncement(SOFTWARE_JOB);
+ announcementCrawlService.crawlSoftwareAnnouncement(SOFTWARE_EVENT);
+ announcementCrawlService.crawlSoftwareAnnouncement(SOFTWARE_SCHOLARSHIP);
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java
new file mode 100644
index 0000000000..2e9beec79c
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java
@@ -0,0 +1,363 @@
+package com.example.capstone.domain.announcement.service;
+
+import com.deepl.api.DeepLException;
+import com.deepl.api.TextTranslationOptions;
+import com.deepl.api.Translator;
+import com.example.capstone.domain.announcement.entity.Announcement;
+import com.example.capstone.domain.announcement.entity.AnnouncementFile;
+import com.example.capstone.domain.announcement.repository.AnnouncementRepository;
+import com.example.capstone.global.error.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import static com.example.capstone.global.error.exception.ErrorCode.Crawling_FAIL;
+import static com.example.capstone.global.error.exception.ErrorCode.REDIS_CONNECTION_FAIL;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AnnouncementCrawlService {
+ @Value("${deepl.api.key}")
+ private String authKey;
+
+ private final AnnouncementRepository announcementRepository;
+
+ private final List languages = List.of("KO", "EN-US");
+
+ @Async
+ @Transactional
+ public void crawlKookminAnnouncement(AnnouncementUrl url) {
+ try {
+ Document doc = Jsoup.connect(url.getUrl()).get();
+ Elements announcements = doc.select("div.board_list > ul > li > a");
+ ArrayList links = new ArrayList<>();
+
+ LocalDate currentDate = LocalDate.now();
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd");
+
+
+ for (Element announcement : announcements) {
+ String dateStr = announcement.select(".board_etc > span").first().text();
+ LocalDate noticeDate = LocalDate.parse(dateStr, formatter);
+
+ if (noticeDate.equals(currentDate) || noticeDate.isEqual(currentDate.minusDays(1))) {
+ String href = announcement.attr("href");
+ links.add(href);
+ }
+ }
+
+ String baseUrl = "https://www.kookmin.ac.kr/";
+
+ for (String link : links) {
+ doc = Jsoup.connect(baseUrl + link).timeout(20000).get();
+
+ String type = doc.select("h2.section_tit").text();
+
+ Element element = doc.selectFirst(".view_tit");
+ String title = element != null ? element.text() : "Default Title";
+
+ Elements spans = doc.select(".board_etc > span");
+ String writeDate = spans.get(0).text().split(" ")[1];
+ String department = spans.get(1).text().split(" ")[1];
+ String author = spans.get(2).text().split(" ")[1];
+ Element phoneElement = doc.select("em").first();
+ String authorPhone = (phoneElement != null) ? phoneElement.text().replace("☎ ", "") : "No Phone";
+ Elements fileInfo = doc.select("div.board_atc.file > ul > li > a");
+ String html = doc.select(".view_inner").outerHtml();
+
+ Translator translator = new Translator(authKey);
+
+ for (String language : languages) {
+ String translatedTitle = title;
+ String translatedDepartment = department;
+
+ if (!language.equals("KO")) {
+ translatedTitle = translator.translateText(title, "KO", language).getText();
+ translatedDepartment = translator.translateText(department, "KO", language).getText();
+ }
+
+ Optional check = announcementRepository.findByTitle(translatedTitle);
+ if (!check.isEmpty()) continue;
+
+ String document = html;
+ if (!language.equals("KO")) {
+ document = translateRecursive(html, language, 1, translator);
+ if(!StringUtils.hasText(document)) document = html;
+ }
+
+ List files = new ArrayList<>();
+// System.out.println(fileInfo.size());
+// for (Element file : fileInfo){
+// String fileUrl = file.attr("href");
+// String fileTitle = file.text();
+// if(!language.equals("KO")){
+// fileTitle = translator.translateText(fileTitle, "KO", language).getText();
+// }
+// System.out.println(fileUrl + ", " + fileTitle);
+// AnnouncementFile announcementFile = AnnouncementFile.builder()
+// .link(fileUrl)
+// .title(fileTitle)
+// .build();
+// files.add(announcementFile);
+// }
+
+ Announcement announcement = Announcement.builder()
+ .type(type)
+ .title(translatedTitle)
+ .author(author)
+ .authorPhone(authorPhone)
+ .department(translatedDepartment)
+ .writtenDate(LocalDate.parse(writeDate, formatter))
+ .document(document)
+ .language(language)
+ .files(files)
+ .url(baseUrl + link)
+ .build();
+
+ announcementRepository.save(announcement);
+ }
+ }
+ } catch (Exception e) {
+ System.out.println(e);
+ throw new BusinessException(Crawling_FAIL);
+ }
+ }
+
+ @Async
+ @Transactional
+ public void crawlInternationlAnnouncement(AnnouncementUrl url) {
+ try {
+ Document doc = Jsoup.connect(url.getUrl()).get();
+ Elements announcements = doc.select("table.board-table tbody tr");
+ ArrayList links = new ArrayList<>();
+
+ LocalDate currentDate = LocalDate.now();
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yy.MM.dd");
+
+ for (Element announcement : announcements) {
+ Elements dateElements = announcement.select("span.b-date");
+ if (!dateElements.isEmpty()) {
+ String dateStr = dateElements.text();
+ LocalDate noticeDate = LocalDate.parse(dateStr, formatter);
+
+ if (noticeDate.isAfter(currentDate.minusDays(1))) {
+ Elements linkElements = announcement.select("td.b-td-left a");
+ if (!linkElements.isEmpty()) {
+ String href = linkElements.attr("href");
+ if (links.contains(href)) continue;
+ links.add(href);
+ System.out.println(href);
+ }
+ }
+ }
+ }
+
+ String baseUrl = url.getUrl();
+ Translator translator = new Translator(authKey);
+
+ for (String link : links) {
+ doc = Jsoup.connect(baseUrl + link).timeout(20000).get();
+
+ Element element = doc.selectFirst("span.b-title");
+ String title = element != null ? element.text() : "Default Title";
+
+ Elements spans = doc.select("li.b-writer-box > span");
+ String author = spans.get(0).text();
+ spans = doc.select("li.b-date-box > span");
+ String writeDate = spans.get(0).text();
+ String department = "외국인유학생지원센터";
+ String authorPhone = "02-910-5808";
+
+ Elements images = doc.select("img[src]");
+ for (Element img : images) {
+ String src = img.attr("src");
+ if (!src.startsWith("http://") && !src.startsWith("https://")) {
+ img.attr("src", "https://cms.kookmin.ac.kr" + src);
+ }
+ }
+
+ String html = doc.select(".b-content-box").outerHtml();
+
+ for (String language : languages) {
+ String translatedTitle = title;
+ String translatedDepartment = department;
+
+ if (!language.equals("KO")) {
+ translatedTitle = translator.translateText(title, "KO", language).getText();
+ translatedDepartment = translator.translateText(department, "KO", language).getText();
+ }
+
+ Optional check = announcementRepository.findByTitle(translatedTitle);
+ if (!check.isEmpty()) continue;
+
+ String document = html;
+ if (!language.equals("KO")) {
+ document = translateRecursive(html, language, 1, translator);
+ if(!StringUtils.hasText(document)) document = html;
+ }
+
+ List files = new ArrayList<>();
+ formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ Announcement announcement = Announcement.builder()
+ .type(url.getType())
+ .title(translatedTitle)
+ .author(author)
+ .authorPhone(authorPhone)
+ .department(translatedDepartment)
+ .writtenDate(LocalDate.parse(writeDate, formatter))
+ .document(document)
+ .language(language)
+ .url(baseUrl + link)
+ .files(files)
+ .build();
+
+ announcementRepository.save(announcement);
+ }
+ }
+ } catch (Exception e) {
+ System.out.println(e);
+ throw new BusinessException(Crawling_FAIL);
+ }
+ }
+
+ @Async
+ @Transactional
+ public void crawlSoftwareAnnouncement(AnnouncementUrl url) {
+ try {
+ Document doc = Jsoup.connect(url.getUrl()).get();
+ Elements announcements = doc.select(".list-tbody > ul");
+ ArrayList links = new ArrayList<>();
+
+ LocalDate currentDate = LocalDate.now();
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yy.MM.dd");
+
+ for (Element announcement : announcements) {
+ String dateStr = announcement.select(".date").text();
+ LocalDate noticeDate = LocalDate.parse(dateStr, formatter);
+
+ if (noticeDate.equals(currentDate) || noticeDate.isAfter(currentDate.minusDays(4))) {
+ String href = announcement.select(".subject > a").attr("href");
+ links.add(href.substring(1, href.length()));
+ }
+ }
+
+ String baseUrl = url.getUrl();
+ Translator translator = new Translator(authKey);
+
+ for (String link : links) {
+ doc = Jsoup.connect(baseUrl + link).timeout(20000).get();
+
+ String title = doc.select(".view-title").text();
+ String author = doc.select("th:contains(작성자) + td").text();
+ String writeDate = doc.select("th.aricle-subject + td").text();
+ String department = "소프트웨어융합대학";
+ String authorPhone = "02-910-6642";
+
+ Elements images = doc.select("img[src]");
+ for (Element img : images) {
+ String src = img.attr("src");
+ if (!src.startsWith("http:") && !src.startsWith("https:")) {
+ img.attr("src", "https:" + src);
+ }
+ }
+
+ String html = doc.select(".board.board-view #view-detail-data").first().outerHtml();
+
+ for (String language : languages) {
+ String translatedTitle = title;
+ String translatedDepartment = department;
+
+ if (!language.equals("KO")) {
+ translatedTitle = translator.translateText(title, "KO", language).getText();
+ translatedDepartment = translator.translateText(department, "KO", language).getText();
+ }
+
+ Optional check = announcementRepository.findByTitle(translatedTitle);
+ if (!check.isEmpty()) continue;
+
+ String document = html;
+ if (!language.equals("KO")) {
+ document = translateRecursive(html, language, 1, translator);
+ if(!StringUtils.hasText(document)) document = html;
+ }
+
+ List files = new ArrayList<>();
+ formatter = DateTimeFormatter.ofPattern("yy.MM.dd");
+
+ Announcement announcement = Announcement.builder()
+ .type(url.getType())
+ .title(translatedTitle)
+ .author(author)
+ .authorPhone(authorPhone)
+ .department(translatedDepartment)
+ .writtenDate(LocalDate.parse(writeDate, formatter))
+ .document(document)
+ .language(language)
+ .url(baseUrl.substring(0, baseUrl.length()-1) + link)
+ .files(files)
+ .build();
+
+ announcementRepository.save(announcement);
+ }
+ }
+ } catch (Exception e) {
+ System.out.println(e);
+ throw new BusinessException(Crawling_FAIL);
+ }
+ }
+
+ private String translateRecursive(String html, String language, int part, Translator translator){
+ if(part == 11) return "";
+
+ String document = "";
+ try {
+ List list = new ArrayList<>();
+ list.add(html);
+ if(part != 1) list = splitHtml(html, part);
+ for(int i = 0; i splitHtml(String html, int part){
+ List list = new ArrayList<>();
+ int len = html.length() / part;
+ int lastIdx = 0;
+
+ for(int i = 0; i', start);
+ return nextSplitPoint == -1 ? -1 : nextSplitPoint + 1;
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementSearchService.java b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementSearchService.java
new file mode 100644
index 0000000000..0ff877e602
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementSearchService.java
@@ -0,0 +1,54 @@
+package com.example.capstone.domain.announcement.service;
+
+import com.example.capstone.domain.announcement.exception.AnnouncementNotFoundException;
+import com.example.capstone.domain.announcement.dto.AnnouncementListResponse;
+import com.example.capstone.domain.announcement.entity.Announcement;
+import com.example.capstone.domain.announcement.repository.AnnouncementRepository;
+import com.example.capstone.global.error.exception.BusinessException;
+import com.example.capstone.global.error.exception.ErrorCode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.domain.*;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import static com.example.capstone.global.error.exception.ErrorCode.TEST_KEY_NOT_VALID;
+
+@Slf4j
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class AnnouncementSearchService {
+ private final AnnouncementRepository announcementRepository;
+
+ @Value("${test.key}")
+ private String testKey;
+
+ public boolean testKeyCheck(String key) {
+ if (key.equals(testKey)) return true;
+ else throw new BusinessException(TEST_KEY_NOT_VALID);
+ }
+
+ public Slice getAnnouncementList(Long cursorId, String type, String language) {
+ Pageable pageable = PageRequest.of(0, 10);
+ if (cursorId == 0) cursorId = null;
+ Slice list = announcementRepository
+ .getFilteredAnnouncementsWithPaging(cursorId, type, language, pageable);
+ return list;
+ }
+
+ public Slice getAnnouncementSearchList(Long cursorId, String type, String language, String keyword) {
+ Pageable pageable = PageRequest.of(0, 10);
+ if (cursorId == 0) cursorId = null;
+ Slice list = announcementRepository
+ .getFilteredSearchAnnouncementsWithPaging(cursorId, type, language, keyword, pageable);
+ return list;
+ }
+
+ public Announcement getAnnouncementDetail(Long id) {
+ Announcement announcement = announcementRepository.findById(id)
+ .orElseThrow(() -> new AnnouncementNotFoundException(id));
+ return announcement;
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementUrl.java b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementUrl.java
new file mode 100644
index 0000000000..cddf6d7df9
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementUrl.java
@@ -0,0 +1,23 @@
+package com.example.capstone.domain.announcement.service;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum AnnouncementUrl {
+ KOOKMIN_OFFICIAL("https://www.kookmin.ac.kr/user/kmuNews/notice/index.do", "official"),
+ INTERNATIONAL_ACADEMIC("https://cms.kookmin.ac.kr/kmuciss/notice/academic.do", "유학생 학사공지"),
+ INTERNATIONAL_VISA("https://cms.kookmin.ac.kr/kmuciss/notice/visa.do", "유학생 비자공지"),
+ INTERNATIONAL_SCHOLARSHIP("https://cms.kookmin.ac.kr/kmuciss/notice/scholarship.do", "유학생 장학공지"),
+ INTERNATIONAL_EVENT("https://cms.kookmin.ac.kr/kmuciss/notice/event.do", "유학생 행사/취업공지"),
+ INTERNATIONAL_PROGRAM("https://cms.kookmin.ac.kr/kmuciss/notice/program.do", "유학생 학생지원공지"),
+ INTERNATIONAL_GKS("https://cms.kookmin.ac.kr/kmuciss/notice/gks.do", "유학생 정부초청공지"),
+ SOFTWARE_ACADEMIC("https://cs.kookmin.ac.kr/news/notice/", "SW 학사공지"),
+ SOFTWARE_JOB("https://cs.kookmin.ac.kr/news/jobs/", "SW 취업공지"),
+ SOFTWARE_SCHOLARSHIP("https://cs.kookmin.ac.kr/news/scholarship/", "SW 장학공지"),
+ SOFTWARE_EVENT("https://cs.kookmin.ac.kr/news/event/", "SW 특강/행사공지");
+
+ private String url;
+ private String type;
+}
diff --git a/back/src/main/java/com/example/capstone/domain/auth/controller/AuthController.java b/back/src/main/java/com/example/capstone/domain/auth/controller/AuthController.java
index 0940888418..9a33b72c27 100644
--- a/back/src/main/java/com/example/capstone/domain/auth/controller/AuthController.java
+++ b/back/src/main/java/com/example/capstone/domain/auth/controller/AuthController.java
@@ -1,10 +1,9 @@
package com.example.capstone.domain.auth.controller;
-import com.example.capstone.domain.auth.dto.SigninRequest;
-import com.example.capstone.domain.auth.dto.SignupRequest;
-import com.example.capstone.domain.auth.dto.SigninResponse;
-import com.example.capstone.domain.auth.service.LoginService;
-import com.example.capstone.domain.user.entity.User;
+import com.example.capstone.domain.auth.dto.ReissueRequest;
+import com.example.capstone.domain.auth.dto.TokenResponse;
+import com.example.capstone.domain.auth.service.AuthService;
+import com.example.capstone.global.dto.ApiResult;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -20,21 +19,12 @@
@RequiredArgsConstructor
public class AuthController {
- private final LoginService loginService;
+ private final AuthService authService;
- @PostMapping("/signup")
- public ResponseEntity> signup(@RequestBody @Valid SignupRequest signupRequest){
- //TODO : HMAC을 통한 검증 로직 추가 필요
- loginService.signUp(signupRequest);
- return ResponseEntity.ok().body("Successfully Signup");
+ @PostMapping("/reissue")
+ public ResponseEntity> reissue(@RequestBody @Valid ReissueRequest reissueRequest) {
+ TokenResponse tokenResponse = authService.reissueToken(reissueRequest.refreshToekn());
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully get token", tokenResponse));
}
-
- @PostMapping("/signin")
- public ResponseEntity signin(@RequestBody @Valid SigninRequest signinRequest){
- //TODO : HMAC을 통한 검증 로직 추가 필요
- SigninResponse response = loginService.signIn(signinRequest);
- return ResponseEntity.ok().body(response);
- }
-
-
}
diff --git a/back/src/main/java/com/example/capstone/domain/auth/dto/ReissueRequest.java b/back/src/main/java/com/example/capstone/domain/auth/dto/ReissueRequest.java
new file mode 100644
index 0000000000..6d544734a5
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/auth/dto/ReissueRequest.java
@@ -0,0 +1,8 @@
+package com.example.capstone.domain.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record ReissueRequest(
+ @NotBlank String refreshToekn
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/auth/dto/SigninRequest.java b/back/src/main/java/com/example/capstone/domain/auth/dto/SigninRequest.java
deleted file mode 100644
index 8aa8547657..0000000000
--- a/back/src/main/java/com/example/capstone/domain/auth/dto/SigninRequest.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.example.capstone.domain.auth.dto;
-
-import jakarta.validation.constraints.Email;
-
-import java.util.UUID;
-
-public record SigninRequest (
- String uuid,
- @Email String email
-){}
diff --git a/back/src/main/java/com/example/capstone/domain/auth/dto/SigninResponse.java b/back/src/main/java/com/example/capstone/domain/auth/dto/SigninResponse.java
deleted file mode 100644
index 075fd64f43..0000000000
--- a/back/src/main/java/com/example/capstone/domain/auth/dto/SigninResponse.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.example.capstone.domain.auth.dto;
-
-import lombok.Builder;
-
-@Builder
-public record SigninResponse(
- String accessToken,
- String refreshToken
-){ }
diff --git a/back/src/main/java/com/example/capstone/domain/auth/dto/TokenResponse.java b/back/src/main/java/com/example/capstone/domain/auth/dto/TokenResponse.java
new file mode 100644
index 0000000000..3384e11f9f
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/auth/dto/TokenResponse.java
@@ -0,0 +1,10 @@
+package com.example.capstone.domain.auth.dto;
+
+import lombok.Builder;
+
+@Builder
+public record TokenResponse(
+ String accessToken,
+ String refreshToken
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/auth/exception/AlreadyEmailExistException.java b/back/src/main/java/com/example/capstone/domain/auth/exception/AlreadyEmailExistException.java
deleted file mode 100644
index e77efcc3cf..0000000000
--- a/back/src/main/java/com/example/capstone/domain/auth/exception/AlreadyEmailExistException.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.example.capstone.domain.auth.exception;
-
-import com.example.capstone.global.error.exception.ErrorCode;
-import com.example.capstone.global.error.exception.InvalidValueException;
-
-public class AlreadyEmailExistException extends InvalidValueException {
- public AlreadyEmailExistException(final String email){
- super(email, ErrorCode.ALREADY_EMAIL_EXIST);
- }
-}
diff --git a/back/src/main/java/com/example/capstone/domain/auth/service/AuthService.java b/back/src/main/java/com/example/capstone/domain/auth/service/AuthService.java
new file mode 100644
index 0000000000..db4c094abc
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/auth/service/AuthService.java
@@ -0,0 +1,47 @@
+package com.example.capstone.domain.auth.service;
+
+import com.example.capstone.domain.auth.dto.TokenResponse;
+import com.example.capstone.domain.jwt.JwtTokenProvider;
+import com.example.capstone.domain.jwt.PrincipalDetails;
+import com.example.capstone.global.error.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import static com.example.capstone.global.error.exception.ErrorCode.INTERNAL_SERVER_ERROR;
+import static com.example.capstone.global.error.exception.ErrorCode.NOT_EXIST_REFRESH_TOKEN;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class AuthService {
+ private final JwtTokenProvider jwtTokenProvider;
+ private final RedisTemplate redisTemplate;
+
+ public TokenResponse reissueToken(String refreshToken) {
+ jwtTokenProvider.validateToken(refreshToken);
+
+ Authentication authentication = jwtTokenProvider.getAuthentication(refreshToken);
+ PrincipalDetails principalDetails;
+
+ if (authentication.getPrincipal() instanceof PrincipalDetails) {
+ principalDetails = (PrincipalDetails) authentication.getPrincipal();
+ } else throw new BusinessException(INTERNAL_SERVER_ERROR);
+
+ String redisRefreshToken = redisTemplate.opsForValue().get(principalDetails.getUuid());
+ if (redisRefreshToken == null || !redisRefreshToken.equals(refreshToken)) {
+ throw new BusinessException(NOT_EXIST_REFRESH_TOKEN);
+ }
+
+ TokenResponse tokenResponse = new TokenResponse(
+ jwtTokenProvider.createAccessToken(principalDetails),
+ jwtTokenProvider.createRefreshToken(principalDetails)
+ );
+
+ return tokenResponse;
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/auth/service/LoginService.java b/back/src/main/java/com/example/capstone/domain/auth/service/LoginService.java
deleted file mode 100644
index a5899cb84c..0000000000
--- a/back/src/main/java/com/example/capstone/domain/auth/service/LoginService.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.example.capstone.domain.auth.service;
-
-import com.example.capstone.domain.auth.dto.SigninRequest;
-import com.example.capstone.domain.auth.dto.SigninResponse;
-import com.example.capstone.domain.auth.dto.SignupRequest;
-import com.example.capstone.domain.auth.exception.AlreadyEmailExistException;
-import com.example.capstone.domain.jwt.JwtTokenProvider;
-import com.example.capstone.domain.jwt.PrincipalDetails;
-import com.example.capstone.domain.user.entity.User;
-import com.example.capstone.domain.user.repository.UserRepository;
-import com.example.capstone.domain.user.util.UserMapper;
-import com.example.capstone.global.error.exception.BusinessException;
-import com.example.capstone.global.error.exception.ErrorCode;
-import lombok.RequiredArgsConstructor;
-import org.springframework.security.core.authority.SimpleGrantedAuthority;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.Collections;
-
-@Service
-@RequiredArgsConstructor
-@Transactional(readOnly = true)
-public class LoginService {
- private final UserRepository userRepository;
- private final JwtTokenProvider jwtTokenProvider;
-
- public void emailExists(String email) {
- boolean exist = userRepository.existsByEmail(email);
- if(exist) throw new AlreadyEmailExistException(email);
- }
-
- @Transactional
- public void signUp(SignupRequest dto){
- User user = UserMapper.INSTANCE.signupReqeustToUser(dto);
- System.out.println(user.getId());
- System.out.println(user.getEmail());
- emailExists(user.getEmail());
- userRepository.save(user);
- }
-
- @Transactional
- public SigninResponse signIn(SigninRequest dto){
- String uuid = dto.uuid();
- //TODO : UserNotFoundException 만들고 throw하기
- User user = userRepository.findUserById(uuid)
- .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
-
- String accessToken = jwtTokenProvider.createAccessToken(new PrincipalDetails(user.getId(),
- user.getName(), user.getMajor(), false, Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"))));
-
- return SigninResponse.builder()
- .accessToken(accessToken)
- .refreshToken("")
- .build();
- }
-}
diff --git a/back/src/main/java/com/example/capstone/domain/help/controller/HelpController.java b/back/src/main/java/com/example/capstone/domain/help/controller/HelpController.java
new file mode 100644
index 0000000000..2a12a0ce56
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/controller/HelpController.java
@@ -0,0 +1,94 @@
+package com.example.capstone.domain.help.controller;
+
+import com.example.capstone.domain.help.dto.*;
+import com.example.capstone.domain.help.service.HelpService;
+import com.example.capstone.domain.jwt.JwtTokenProvider;
+import com.example.capstone.global.dto.ApiResult;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.UUID;
+
+@Controller
+@RequiredArgsConstructor
+@RequestMapping("/api/help")
+public class HelpController {
+
+ private final HelpService helpService;
+ private final JwtTokenProvider jwtTokenProvider;
+
+ @PostMapping(value = "/create")
+ @Operation(summary = "헬퍼글 생성", description = "request 정보를 기반으로 헬퍼글을 생성합니다.")
+ @ApiResponse(responseCode = "200", description = "request 정보를 기반으로 생성된 헬퍼글을 반환됩니다.")
+ public ResponseEntity> createHelp(
+ @Parameter(description = "헬퍼 모집글의 구성 요소 입니다. 제목, 작성자, 본문, 국가 정보가 들어가야 합니다.", required = true)
+ @RequestBody HelpPostRequest request) {
+ String userId = UUID.randomUUID().toString();//jwtTokenProvider.extractUUID(token);
+ HelpResponse helpResponse = helpService.createHelp(userId, request);
+
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully create help", helpResponse));
+ }
+
+ @GetMapping("/read")
+ @Operation(summary = "헬퍼글 불러오기", description = "id를 통해 해당 글을 가져옵니다.")
+ @ApiResponse(responseCode = "200", description = "해당 id의 글을 반환합니다.")
+ public ResponseEntity> readHelp(
+ @Parameter(description = "가져올 글의 id 입니다.", required = true)
+ @RequestParam Long id) {
+ HelpResponse helpResponse = helpService.getHelp(id);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully read help", helpResponse));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "헬퍼글 수정", description = "request 정보를 기반으로 글을 수정합니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200을 리턴합니다.")
+ public ResponseEntity> updateHelp(/*@RequestHeader String token,*/
+ @Parameter(description = "수정할 헬퍼글의 id와 헬퍼글의 request가 들어갑니다.", required = true)
+ @RequestBody HelpPutRequest request) {
+ String userId = UUID.randomUUID().toString();//jwtTokenProvider.extractUUID(token);
+ helpService.updateHelp(userId, request);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully update help", 200));
+ }
+
+ @DeleteMapping("/erase")
+ @Operation(summary = "헬퍼글 삭제", description = "id를 기반으로 해당 글을 삭제합니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200을 리턴합니다.")
+ public ResponseEntity> eraseHelp(
+ @Parameter(description = "삭제할 글의 id 입니다.", required = true)
+ @RequestParam Long id) {
+ helpService.eraseHelp(id);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully delete help", 200));
+ }
+
+ @GetMapping("/list")
+ @Operation(summary = "헬퍼글 미리보기 리스트 생성", description = "request 정보를 기반으로 페이지네이션이 적용된 헬퍼글 리스트를 반환합니다.")
+ @ApiResponse(responseCode = "200", description = "request 조건에 맞는 헬퍼글 리스트를 반환합니다.")
+ public ResponseEntity> listHelp(
+ @Parameter(description = "헬퍼글 리스트를 위한 cursorId, 검색어 word, 태그값 tag가 필요합니다.", required = true)
+ @RequestBody HelpListRequest request) {
+ HelpSliceResponse response = helpService.getHelpList(request);
+
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully create help list", response));
+ }
+
+ @PutMapping("/done")
+ @Operation(summary = "헬퍼글 모집 종료", description = "id에 맞는 헬퍼글을 모집 종료합니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200을 반환합니다.")
+ public ResponseEntity> doneHelp(
+ @Parameter(description = "모집 종료할 헬퍼글의 id입니다.", required = true)
+ @RequestParam Long id) {
+ helpService.doneHelp(id);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully done help", 200));
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/dto/HelpListRequest.java b/back/src/main/java/com/example/capstone/domain/help/dto/HelpListRequest.java
new file mode 100644
index 0000000000..a6490c4bfd
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/dto/HelpListRequest.java
@@ -0,0 +1,11 @@
+package com.example.capstone.domain.help.dto;
+
+import java.util.UUID;
+
+public record HelpListRequest(
+ Long cursorId,
+ Boolean isDone,
+ Boolean isHelper,
+ UUID isMine
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/dto/HelpListResponse.java b/back/src/main/java/com/example/capstone/domain/help/dto/HelpListResponse.java
new file mode 100644
index 0000000000..bfaddd4f36
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/dto/HelpListResponse.java
@@ -0,0 +1,21 @@
+package com.example.capstone.domain.help.dto;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record HelpListResponse(
+ Long id,
+
+ String title,
+
+ String author,
+
+ String country,
+
+ LocalDateTime createdDate,
+
+ Boolean isDone,
+
+ Boolean isHelper
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/dto/HelpPostRequest.java b/back/src/main/java/com/example/capstone/domain/help/dto/HelpPostRequest.java
new file mode 100644
index 0000000000..e277e465b6
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/dto/HelpPostRequest.java
@@ -0,0 +1,13 @@
+package com.example.capstone.domain.help.dto;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record HelpPostRequest(
+ String title,
+ String author,
+ String country,
+ String context,
+ boolean isHelper
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/dto/HelpPutRequest.java b/back/src/main/java/com/example/capstone/domain/help/dto/HelpPutRequest.java
new file mode 100644
index 0000000000..835998d63d
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/dto/HelpPutRequest.java
@@ -0,0 +1,11 @@
+package com.example.capstone.domain.help.dto;
+
+public record HelpPutRequest(
+ Long id,
+ String title,
+ String author,
+ String country,
+ String context,
+ boolean isHelper
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/dto/HelpResponse.java b/back/src/main/java/com/example/capstone/domain/help/dto/HelpResponse.java
new file mode 100644
index 0000000000..6ea3837917
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/dto/HelpResponse.java
@@ -0,0 +1,29 @@
+package com.example.capstone.domain.help.dto;
+
+import jakarta.persistence.Column;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record HelpResponse(
+ Long id,
+
+ boolean isDone,
+
+ boolean isHelper,
+
+ String title,
+
+ String context,
+
+ String author,
+
+ String country,
+
+ LocalDateTime createdDate,
+
+ LocalDateTime updatedDate,
+
+ UUID uuid
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/dto/HelpSliceResponse.java b/back/src/main/java/com/example/capstone/domain/help/dto/HelpSliceResponse.java
new file mode 100644
index 0000000000..cf23b0c4ba
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/dto/HelpSliceResponse.java
@@ -0,0 +1,12 @@
+package com.example.capstone.domain.help.dto;
+
+import java.util.List;
+
+public record HelpSliceResponse(
+
+ Long lastCursorId,
+
+ Boolean hasNext,
+ List helpList
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/entity/Help.java b/back/src/main/java/com/example/capstone/domain/help/entity/Help.java
new file mode 100644
index 0000000000..cd88889e1e
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/entity/Help.java
@@ -0,0 +1,63 @@
+package com.example.capstone.domain.help.entity;
+
+import com.example.capstone.domain.help.dto.HelpResponse;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Entity
+@Table(name = "helps")
+@Getter
+@Builder
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Help {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "is_done", nullable = false)
+ private boolean isDone;
+
+ @Column(name = "is_helper", nullable = false)
+ private boolean isHelper;
+
+ @Column(name = "title", nullable = false)
+ private String title;
+
+ @Column(name = "context", columnDefinition = "LONGTEXT", nullable = false)
+ private String context;
+
+ @Column(name = "author", nullable = false)
+ private String author;
+
+ @Column(name = "country", nullable = false)
+ private String country;
+
+ @Column(name = "created_date", nullable = false)
+ private LocalDateTime createdDate;
+
+ @Column(name = "updated_date", nullable = false)
+ private LocalDateTime updatedDate;
+
+ @Column(name = "uuid", nullable = false)
+ private UUID uuid;
+
+ public void update(String title, String context, LocalDateTime updatedDate) {
+ this.title = title;
+ this.context = context;
+ this.updatedDate = updatedDate;
+ }
+
+ public void done() {
+ isDone = true;
+ }
+
+ public HelpResponse toDTO() {
+ return new HelpResponse(id, isDone, isHelper, title, context, author, country, createdDate, updatedDate,uuid);
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/repository/HelpListRepository.java b/back/src/main/java/com/example/capstone/domain/help/repository/HelpListRepository.java
new file mode 100644
index 0000000000..d49f6d0d38
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/repository/HelpListRepository.java
@@ -0,0 +1,13 @@
+package com.example.capstone.domain.help.repository;
+
+import com.example.capstone.domain.help.dto.HelpListResponse;
+import com.example.capstone.domain.help.dto.HelpSliceResponse;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+
+import java.util.Map;
+import java.util.UUID;
+
+public interface HelpListRepository {
+ HelpSliceResponse getHelpListByPaging(Long cursorId, Pageable page, Boolean isDone, Boolean isHelper, UUID isMine);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/repository/HelpRepository.java b/back/src/main/java/com/example/capstone/domain/help/repository/HelpRepository.java
new file mode 100644
index 0000000000..376dc624fc
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/repository/HelpRepository.java
@@ -0,0 +1,15 @@
+package com.example.capstone.domain.help.repository;
+
+import com.example.capstone.domain.help.entity.Help;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface HelpRepository extends JpaRepository, HelpListRepository {
+
+ Help save(Help help);
+
+ Optional findById(Long id);
+
+ void deleteById(Long id);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/repository/HelpRepositoryImpl.java b/back/src/main/java/com/example/capstone/domain/help/repository/HelpRepositoryImpl.java
new file mode 100644
index 0000000000..07d22e0445
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/repository/HelpRepositoryImpl.java
@@ -0,0 +1,75 @@
+package com.example.capstone.domain.help.repository;
+
+import com.example.capstone.domain.help.dto.HelpListResponse;
+import com.example.capstone.domain.help.dto.HelpSliceResponse;
+import com.example.capstone.domain.help.entity.QHelp;
+import com.querydsl.core.types.Projections;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.SliceImpl;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+@RequiredArgsConstructor
+public class HelpRepositoryImpl implements HelpListRepository {
+
+ private final JPAQueryFactory jpaQueryFactory;
+ private QHelp help = QHelp.help;
+
+ @Override
+ public HelpSliceResponse getHelpListByPaging(Long cursorId, Pageable page, Boolean isDone, Boolean isHelper, UUID isMine) {
+ List helpList = jpaQueryFactory
+ .select(
+ Projections.constructor(
+ HelpListResponse.class,
+ help.id, help.title,
+ help.author, help.country,
+ help.createdDate, help.isDone, help.isHelper
+ )
+ )
+ .from(help)
+ .where(cursorId(cursorId),
+ doneEq(isDone),
+ helperEq(isHelper),
+ mineEq(isMine))
+ .orderBy(help.createdDate.desc())
+ .limit(page.getPageSize() + 1)
+ .fetch();
+
+ boolean hasNext = false;
+ if(helpList.size() > page.getPageSize()) {
+ helpList.remove(page.getPageSize());
+ hasNext = true;
+ }
+
+ Long lastCursorId = null;
+
+ if(hasNext && helpList.size() != 0) {
+ lastCursorId = helpList.get(helpList.size() - 1).id();
+ }
+
+ return new HelpSliceResponse(lastCursorId, hasNext, helpList);
+ }
+
+ private BooleanExpression cursorId(Long cursorId) {
+ return cursorId == null ? null : help.id.gt(cursorId);
+ }
+
+ private BooleanExpression doneEq(Boolean isDone) {
+ return isDone == null ? null : help.isDone.eq(isDone);
+ }
+
+ private BooleanExpression helperEq(Boolean isHelper) {
+ return isHelper == null ? null : help.isHelper.eq(isHelper);
+ }
+
+ private BooleanExpression mineEq(UUID isMine) {
+ return isMine == null ? null : help.uuid.eq(isMine);
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/help/service/HelpService.java b/back/src/main/java/com/example/capstone/domain/help/service/HelpService.java
new file mode 100644
index 0000000000..c26da2aff0
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/help/service/HelpService.java
@@ -0,0 +1,62 @@
+package com.example.capstone.domain.help.service;
+
+import com.example.capstone.domain.help.dto.*;
+import com.example.capstone.domain.help.repository.HelpListRepository;
+import com.example.capstone.domain.help.repository.HelpRepository;
+import com.example.capstone.domain.help.entity.Help;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class HelpService {
+ private final HelpRepository helpRepository;
+
+ public HelpResponse createHelp(String userId, HelpPostRequest request) {
+ LocalDateTime current = LocalDateTime.now();
+ Help help = helpRepository.save(Help.builder().title(request.title()).context(request.context())
+ .author(request.author()).createdDate(current).updatedDate(current).isHelper(request.isHelper())
+ .isDone(false).country(request.country()).uuid(UUID.fromString(userId)).build());
+
+ return help.toDTO();
+ }
+
+ public HelpResponse getHelp(Long id) {
+ Help help = helpRepository.findById(id).get();
+ return help.toDTO();
+ }
+
+ @Transactional
+ public void updateHelp(String userId, HelpPutRequest request) {
+ LocalDateTime current = LocalDateTime.now();
+ Help help = helpRepository.findById(request.id()).get();
+ help.update(request.title(), request.context(), current);
+ }
+
+ public void eraseHelp(Long id){
+ helpRepository.deleteById(id);
+ }
+
+ public HelpSliceResponse getHelpList(HelpListRequest request) {
+ Pageable page = PageRequest.of(0, 20);
+ Long cursorId = request.cursorId();
+ if(cursorId == 0) cursorId = null;
+ HelpSliceResponse helpList = helpRepository.getHelpListByPaging(cursorId, page, request.isDone(), request.isHelper(), request.isMine());
+
+ return helpList;
+ }
+
+ @Transactional
+ public void doneHelp(Long id) {
+ Help help = helpRepository.findById(id).get();
+ help.done();
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/jwt/JwtAuthenticationFilter.java b/back/src/main/java/com/example/capstone/domain/jwt/JwtAuthenticationFilter.java
index 83a1a33350..526f189ba1 100644
--- a/back/src/main/java/com/example/capstone/domain/jwt/JwtAuthenticationFilter.java
+++ b/back/src/main/java/com/example/capstone/domain/jwt/JwtAuthenticationFilter.java
@@ -1,12 +1,15 @@
package com.example.capstone.domain.jwt;
+import com.example.capstone.domain.jwt.exception.JwtTokenInvalidException;
+import com.example.capstone.global.error.exception.BusinessException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@@ -15,12 +18,16 @@
import java.io.IOException;
+import static com.example.capstone.global.error.exception.ErrorCode.REDIS_CONNECTION_FAIL;
+
+
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
+ public static final String EXCEPTION = "exception";
@Override
protected void doFilterInternal(HttpServletRequest request,
@@ -29,17 +36,22 @@ protected void doFilterInternal(HttpServletRequest request,
) throws ServletException, IOException {
String token = resolveToken(request);
- if (token != null && jwtTokenProvider.validateToken(token)) {
- Authentication authentication = jwtTokenProvider.getAuthentication(token);
- SecurityContextHolder.getContext().setAuthentication(authentication);
+ try {
+ if (token != null && jwtTokenProvider.validateToken(token)) {
+ Authentication authentication = jwtTokenProvider.getAuthentication(token);
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+ } catch (JwtTokenInvalidException jwtTokenInvalidException){
+ request.setAttribute(EXCEPTION, jwtTokenInvalidException);
+ } catch (RedisConnectionFailureException redisConnectionFailureException){
+ SecurityContextHolder.clearContext();
+ request.setAttribute(EXCEPTION, redisConnectionFailureException);
}
- else logger.info("Token is expired");
filterChain.doFilter(request, response);
-
}
- private String resolveToken(HttpServletRequest request){
+ private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
diff --git a/back/src/main/java/com/example/capstone/domain/jwt/JwtClaim.java b/back/src/main/java/com/example/capstone/domain/jwt/JwtClaim.java
index 310f64c3c0..92f1f0bed9 100644
--- a/back/src/main/java/com/example/capstone/domain/jwt/JwtClaim.java
+++ b/back/src/main/java/com/example/capstone/domain/jwt/JwtClaim.java
@@ -8,6 +8,9 @@
public enum JwtClaim {
UUID("uuid"),
NAME("name"),
+ EMAIL("email"),
+ COUNTRY("country"),
+ PHONENUMBER("phoneNumber"),
MAJOR("major"),
AUTHORITIES("authorities");
diff --git a/back/src/main/java/com/example/capstone/domain/jwt/JwtTokenProvider.java b/back/src/main/java/com/example/capstone/domain/jwt/JwtTokenProvider.java
index 9cd4ca1299..2b7ce85e75 100644
--- a/back/src/main/java/com/example/capstone/domain/jwt/JwtTokenProvider.java
+++ b/back/src/main/java/com/example/capstone/domain/jwt/JwtTokenProvider.java
@@ -2,12 +2,17 @@
import com.example.capstone.domain.jwt.exception.JwtTokenInvalidException;
import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
@@ -16,24 +21,32 @@
import java.security.Key;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
import java.util.stream.Collectors;
@Slf4j
@Component
public class JwtTokenProvider {
- private final Key key;
+ @Autowired
+ private RedisTemplate redisTemplate;
+
+ private Key key;
private long accessExpirationTime;
+ private long refreshExpirationTime;
- public JwtTokenProvider(@Value("${jwt.secret}") String secret,
- @Value("${jwt.token.access-expiration-time}") long accessExpirationTime){
+ public JwtTokenProvider(@Value("${jwt.secret}") String secret, @Value("${jwt.token.access-expiration-time}") long accessExpirationTime,
+ @Value("${jwt.token.refresh-expiration-time}") long refreshExpirationTime) {
+ this.accessExpirationTime = accessExpirationTime;
+ this.refreshExpirationTime = refreshExpirationTime;
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
- this.accessExpirationTime = accessExpirationTime;
}
- public String createAccessToken(PrincipalDetails authentication){
+ public String createAccessToken(PrincipalDetails authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
@@ -41,25 +54,53 @@ public String createAccessToken(PrincipalDetails authentication){
Date now = new Date();
Date expireDate = new Date(now.getTime() + accessExpirationTime);
-
-
return Jwts.builder()
.claim(JwtClaim.AUTHORITIES.getKey(), authorities)
- .claim(JwtClaim.UUID.getKey(),authentication.getUuid())
+ .claim(JwtClaim.UUID.getKey(), authentication.getUuid())
.claim(JwtClaim.NAME.getKey(), authentication.getName())
+ .claim(JwtClaim.EMAIL.getKey(), authentication.getEmail())
.claim(JwtClaim.MAJOR.getKey(), authentication.getMajor())
+ .claim(JwtClaim.COUNTRY.getKey(), authentication.getCountry())
+ .claim(JwtClaim.PHONENUMBER.getKey(), authentication.getPhoneNumber())
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
- public String createRefreshToken(Authentication authentication){
- //TODO : refreshToken 생성하는거 만들기
- return "";
+ public String createRefreshToken(PrincipalDetails authentication) {
+ String authorities = authentication.getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .collect(Collectors.joining(","));
+
+ Date now = new Date();
+ Date expireDate = new Date(now.getTime() + refreshExpirationTime);
+
+ String refreshToken = Jwts.builder()
+ .claim(JwtClaim.AUTHORITIES.getKey(), authorities)
+ .claim(JwtClaim.UUID.getKey(), authentication.getUuid())
+ .claim(JwtClaim.NAME.getKey(), authentication.getName())
+ .claim(JwtClaim.EMAIL.getKey(), authentication.getEmail())
+ .claim(JwtClaim.COUNTRY.getKey(), authentication.getCountry())
+ .claim(JwtClaim.PHONENUMBER.getKey(), authentication.getPhoneNumber())
+ .claim(JwtClaim.MAJOR.getKey(), authentication.getMajor())
+ .claim(JwtClaim.AUTHORITIES.getKey(), authentication.getAuthorities())
+ .setIssuedAt(now)
+ .setExpiration(expireDate)
+ .signWith(key, SignatureAlgorithm.HS256)
+ .compact();
+
+ redisTemplate.opsForValue().set(
+ authentication.getUuid(),
+ refreshToken,
+ refreshExpirationTime,
+ TimeUnit.MICROSECONDS
+ );
+
+ return refreshToken;
}
- public Authentication getAuthentication(String token){
+ public Authentication getAuthentication(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
@@ -67,34 +108,58 @@ public Authentication getAuthentication(String token){
.getBody();
String userId = claims.get(JwtClaim.UUID.getKey(), String.class);
+ String email = claims.get(JwtClaim.EMAIL.getKey(), String.class);
String name = claims.get(JwtClaim.NAME.getKey(), String.class);
+ String country = claims.get(JwtClaim.COUNTRY.getKey(), String.class);
+ String phoneNumber = claims.get(JwtClaim.PHONENUMBER.getKey(), String.class);
String major = claims.get(JwtClaim.MAJOR.getKey(), String.class);
/**
* [{"authority": "역할1"}, {"authority": "역할2"}] 이런식으로 들어갑니다.
* 그래서 이걸 파싱해주는 과정입니다.
*/
- String[] list = claims.get(JwtClaim.AUTHORITIES).toString().split(",");
+ String[] list = claims.get(JwtClaim.AUTHORITIES.getKey()).toString().split(",");
Collection authorities = new ArrayList<>();
- for (String a: list) {
+ for (String a : list) {
authorities.add(new SimpleGrantedAuthority(a));
}
- PrincipalDetails principalDetails = new PrincipalDetails(userId, name, major, false, authorities);
+ PrincipalDetails principalDetails = new PrincipalDetails(userId, email, name, major,
+ country, phoneNumber, false, authorities);
return new UsernamePasswordAuthenticationToken(principalDetails, "", authorities);
}
- public boolean validateToken(String token){
- try{
+ public boolean validateToken(String token) {
+ try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
- } catch(JwtTokenInvalidException e){
+ } catch (JwtException e) {
throw JwtTokenInvalidException.INSTANCE;
}
}
+
+ /**
+ * 좀만 고민해보고 폐기 예정
+ */
+ private Claims parseClaims(String token) {
+ return Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ }
+
+ public T extractClaim(String token, Function claimsResolver) {
+ final Claims claims = parseClaims(token);
+ return claimsResolver.apply(claims);
+ }
+
+ public String extractUUID(String token) {
+ return extractClaim(token, claims -> claims.get(JwtClaim.UUID.getKey(), String.class));
+ }
}
diff --git a/back/src/main/java/com/example/capstone/domain/jwt/PrincipalDetails.java b/back/src/main/java/com/example/capstone/domain/jwt/PrincipalDetails.java
index 574a8b7fc0..4da6538873 100644
--- a/back/src/main/java/com/example/capstone/domain/jwt/PrincipalDetails.java
+++ b/back/src/main/java/com/example/capstone/domain/jwt/PrincipalDetails.java
@@ -2,16 +2,45 @@
import lombok.AllArgsConstructor;
import lombok.Getter;
+import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
@Getter
@AllArgsConstructor
-public class PrincipalDetails{
+public class PrincipalDetails implements Authentication {
private String uuid;
+ private String email;
private String name;
private String major;
+ private String country;
+ private String phoneNumber;
private boolean lock;
private Collection extends GrantedAuthority> authorities;
+
+ @Override
+ public Object getCredentials() {
+ return null;
+ }
+
+ @Override
+ public Object getDetails() {
+ return null;
+ }
+
+ @Override
+ public PrincipalDetails getPrincipal() {
+ return this;
+ }
+
+ @Override
+ public boolean isAuthenticated() {
+ return !this.lock;
+ }
+
+ @Override
+ public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
+ this.lock = isAuthenticated;
+ }
}
diff --git a/back/src/main/java/com/example/capstone/domain/jwt/exception/JwtTokenInvalidException.java b/back/src/main/java/com/example/capstone/domain/jwt/exception/JwtTokenInvalidException.java
index 8027250650..cbf4da198e 100644
--- a/back/src/main/java/com/example/capstone/domain/jwt/exception/JwtTokenInvalidException.java
+++ b/back/src/main/java/com/example/capstone/domain/jwt/exception/JwtTokenInvalidException.java
@@ -8,5 +8,7 @@ public class JwtTokenInvalidException extends BusinessException {
// 자주 발생할 수 있는 Exception이라 싱글톤화 하는게 좋다고 합니다.
public static final JwtTokenInvalidException INSTANCE = new JwtTokenInvalidException();
- private JwtTokenInvalidException() {super(ErrorCode.INVALID_JWT_TOKEN);}
+ private JwtTokenInvalidException() {
+ super(ErrorCode.INVALID_JWT_TOKEN);
+ }
}
diff --git a/back/src/main/java/com/example/capstone/domain/menu/controller/MenuController.java b/back/src/main/java/com/example/capstone/domain/menu/controller/MenuController.java
new file mode 100644
index 0000000000..9e5f38fcdc
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/menu/controller/MenuController.java
@@ -0,0 +1,40 @@
+package com.example.capstone.domain.menu.controller;
+
+import com.example.capstone.domain.menu.service.MenuCrawlingService;
+import com.example.capstone.domain.menu.service.MenuSearchService;
+import com.example.capstone.domain.menu.service.MenuUpdateService;
+import com.example.capstone.global.dto.ApiResult;
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.json.JsonArray;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Controller
+@RequestMapping("/api/menu")
+@RequiredArgsConstructor
+public class MenuController {
+ private final MenuCrawlingService menuCrawlingService;
+ private final MenuSearchService menuSearchService;
+
+ @ResponseBody
+ @GetMapping("/daily")
+ public ResponseEntity>> getMenuByDate(@RequestParam LocalDate date, @RequestParam String language){
+ List menu = menuSearchService.findMenuByDate(date, language);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully load menus", menu));
+ }
+
+ @PostMapping("/test")
+ @Operation(summary = "학식 파싱", description = "[주의 : 테스트용] 강제로 학생 저장을 시킴 (DB 중복해서 들어가니깐 물어보고 쓰세요!!)")
+ public ResponseEntity> testMenu(@RequestParam(value = "key") String key){
+ menuCrawlingService.testKeyCheck(key);
+ menuCrawlingService.crawlingMenus();
+ return ResponseEntity.ok("");
+ }
+
+}
diff --git a/back/src/main/java/com/example/capstone/domain/menu/entity/Menu.java b/back/src/main/java/com/example/capstone/domain/menu/entity/Menu.java
new file mode 100644
index 0000000000..87745b1403
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/menu/entity/Menu.java
@@ -0,0 +1,39 @@
+package com.example.capstone.domain.menu.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "menus")
+@Getter
+@Builder
+@EntityListeners(AuditingEntityListener.class)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class Menu {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "cafeteria", nullable = false)
+ private String cafeteria;
+
+ @Column(name = "section", nullable = false)
+ private String section;
+
+ @Column(name = "name", nullable = false)
+ private String name;
+
+ @Column(name = "price", nullable = false)
+ private Long price;
+
+ @Column(name = "date", nullable = false)
+ private LocalDateTime date;
+
+ @Column(name = "language", nullable = false)
+ private String language;
+}
diff --git a/back/src/main/java/com/example/capstone/domain/menu/repository/MenuRepository.java b/back/src/main/java/com/example/capstone/domain/menu/repository/MenuRepository.java
new file mode 100644
index 0000000000..3afd3703db
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/menu/repository/MenuRepository.java
@@ -0,0 +1,29 @@
+package com.example.capstone.domain.menu.repository;
+
+import com.example.capstone.domain.menu.entity.Menu;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.security.core.parameters.P;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+@Repository
+public interface MenuRepository extends JpaRepository {
+ Menu save(Menu menu);
+
+ @Query("SELECT m FROM Menu m " +
+ "WHERE m.date = :menuDate " +
+ "AND m.cafeteria = :cafe " +
+ "AND m.language = :lang")
+ List findMenuByDateAndCafeteria(@Param("menuDate") LocalDateTime date, @Param("cafe") String cafe, @Param("lang") String lang);
+
+ @Query("SELECT DISTINCT m.cafeteria FROM Menu m " +
+ "WHERE m.date = :menuDate " +
+ "AND m.language = :lang")
+ List findMenuCafeByDateAndLang(@Param("menuDate") LocalDateTime date, @Param("lang") String lang);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/menu/service/DecodeUnicodeService.java b/back/src/main/java/com/example/capstone/domain/menu/service/DecodeUnicodeService.java
new file mode 100644
index 0000000000..58e848f6d8
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/menu/service/DecodeUnicodeService.java
@@ -0,0 +1,31 @@
+package com.example.capstone.domain.menu.service;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.concurrent.CompletableFuture;
+
+@Service
+@RequiredArgsConstructor
+public class DecodeUnicodeService {
+
+ @Async
+ public CompletableFuture decodeUnicode(String str) {
+ StringBuilder builder = new StringBuilder();
+ int i = 0;
+ while(i < str.length()) {
+ char ch = str.charAt(i);
+ if(ch == '\\' && i + 1 < str.length() && str.charAt(i + 1) == 'u') {
+ int codePoint = Integer.parseInt(str.substring(i + 2, i + 6), 16);
+ builder.append(Character.toChars(codePoint));
+ i += 6;
+ }
+ else {
+ builder.append(ch);
+ i++;
+ }
+ }
+ return CompletableFuture.completedFuture(builder.toString());
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/menu/service/MenuCrawlingService.java b/back/src/main/java/com/example/capstone/domain/menu/service/MenuCrawlingService.java
new file mode 100644
index 0000000000..9c3d9e079a
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/menu/service/MenuCrawlingService.java
@@ -0,0 +1,35 @@
+package com.example.capstone.domain.menu.service;
+
+import com.example.capstone.global.error.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+
+import static com.example.capstone.global.error.exception.ErrorCode.TEST_KEY_NOT_VALID;
+
+@Service
+@RequiredArgsConstructor
+public class MenuCrawlingService {
+
+ private final MenuUpdateService menuUpdateService;
+
+ @Value("${test.key}")
+ private String testKey;
+
+ public boolean testKeyCheck(String key){
+ if(key.equals(testKey)) return true;
+ else throw new BusinessException(TEST_KEY_NOT_VALID);
+ }
+
+ @Scheduled(cron = "0 0 4 * * MON")
+ public void crawlingMenus(){
+ LocalDateTime startDay = LocalDateTime.now();
+
+ for(int i=0; i<7; i++){
+ menuUpdateService.updateMenus(startDay.plusDays(i));
+ }
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/menu/service/MenuSearchService.java b/back/src/main/java/com/example/capstone/domain/menu/service/MenuSearchService.java
new file mode 100644
index 0000000000..900ea3d503
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/menu/service/MenuSearchService.java
@@ -0,0 +1,49 @@
+package com.example.capstone.domain.menu.service;
+
+import com.example.capstone.domain.menu.entity.Menu;
+import com.example.capstone.domain.menu.repository.MenuRepository;
+import jakarta.json.Json;
+import jakarta.json.JsonArray;
+import jakarta.json.JsonArrayBuilder;
+import jakarta.json.JsonObjectBuilder;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class MenuSearchService {
+ private final MenuRepository menuRepository;
+
+ public List findMenuByDate(LocalDate date, String language) {
+ LocalDateTime dateTime = LocalDateTime.of(date, LocalTime.MIN);
+ List cafeList = menuRepository.findMenuCafeByDateAndLang(dateTime, language);
+
+ List response = new ArrayList<>();
+
+ for(String cafe : cafeList) {
+ List menuList = menuRepository.findMenuByDateAndCafeteria(dateTime, cafe, language);
+ Map subInfos = new HashMap<>();
+ for(Menu menu : menuList) {
+ Map menuInfo = Map.of(
+ "메뉴", menu.getName(),
+ "가격", menu.getPrice().toString());
+ subInfos.put(menu.getSection(), menuInfo);
+ }
+
+ Map cafeInfo = Map.of(
+ cafe, Map.of(
+ date, subInfos));
+ response.add(cafeInfo);
+ }
+
+ return response;
+ }
+}
\ No newline at end of file
diff --git a/back/src/main/java/com/example/capstone/domain/menu/service/MenuUpdateService.java b/back/src/main/java/com/example/capstone/domain/menu/service/MenuUpdateService.java
new file mode 100644
index 0000000000..446c915280
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/menu/service/MenuUpdateService.java
@@ -0,0 +1,82 @@
+package com.example.capstone.domain.menu.service;
+
+import com.deepl.api.Translator;
+import com.example.capstone.domain.menu.entity.Menu;
+import com.example.capstone.domain.menu.repository.MenuRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.apache.commons.lang3.math.NumberUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+@Service
+@RequiredArgsConstructor
+public class MenuUpdateService {
+ private final MenuRepository menuRepository;
+ private final DecodeUnicodeService decodeUnicodeService;
+
+ @Value("${deepl.api.key}")
+ private String authKey;
+
+ @Async
+ public void updateMenus(LocalDateTime startTime) {
+ RestTemplate restTemplate = new RestTemplateBuilder().build();
+ String sdate = startTime.format(DateTimeFormatter.ISO_LOCAL_DATE);
+ String url = "https://kmucoop.kookmin.ac.kr/menu/menujson.php?callback=jQuery112401919322099601417_1711424604017";
+
+ url += "&sdate=" + sdate + "&edate=" + sdate + "&today=" + sdate + "&_=1711424604018";
+ String escapeString = restTemplate.getForObject(url, String.class);
+ CompletableFuture decode = decodeUnicodeService.decodeUnicode(escapeString);
+
+ Translator translator = new Translator(authKey);
+ List languages = List.of("KO", "EN-US");
+
+ try {
+ String response = decode.get();
+ for(String language : languages) {
+ String json = response.substring(response.indexOf("(") + 1, response.lastIndexOf(")"));
+ ObjectMapper mapper = new ObjectMapper();
+ Map allMap = mapper.readValue(json, Map.class);
+
+ for (Map.Entry cafeEntry : allMap.entrySet()) {
+ String cafeteria = translator.translateText(cafeEntry.getKey(), null, language).getText();
+
+ for (Map.Entry dateEntry : ((Map) cafeEntry.getValue()).entrySet()) {
+ String dateString = dateEntry.getKey();
+ LocalDateTime date = LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE).atStartOfDay();
+
+ for (Map.Entry sectionEntry : ((Map) dateEntry.getValue()).entrySet()) {
+ String section = translator.translateText(sectionEntry.getKey(), null, language).getText();
+ String name = "";
+ Long price = 0L;
+
+ for (Map.Entry context : ((Map) sectionEntry.getValue()).entrySet()) {
+ if (context.getKey().equals("메뉴") && context.getValue().equals("") == false) {
+ name = translator.translateText(context.getValue().toString(), null, language).getText();
+ } else if (context.getKey().equals("가격")) {
+ price = NumberUtils.toLong(context.getValue().toString());
+ }
+ }
+ if (name.equals("") == false) {
+ menuRepository.save(Menu.builder().cafeteria(cafeteria).section(section).date(date).name(name).price(price).language(language).build());
+ }
+ }
+ }
+ }
+ }
+ }
+ catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+}
\ No newline at end of file
diff --git a/back/src/main/java/com/example/capstone/domain/qna/controller/AnswerController.java b/back/src/main/java/com/example/capstone/domain/qna/controller/AnswerController.java
new file mode 100644
index 0000000000..2d9121185f
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/controller/AnswerController.java
@@ -0,0 +1,89 @@
+package com.example.capstone.domain.qna.controller;
+
+import com.example.capstone.domain.jwt.JwtTokenProvider;
+import com.example.capstone.domain.qna.dto.*;
+import com.example.capstone.domain.qna.service.AnswerService;
+import com.example.capstone.global.dto.ApiResult;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.RequestEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+import java.util.UUID;
+
+@Controller
+@RequiredArgsConstructor
+@RequestMapping("/api/answer")
+public class AnswerController {
+ private final AnswerService answerService;
+ private final JwtTokenProvider jwtTokenProvider;
+
+ @PostMapping("/create")
+ @Operation(summary = "댓글 생성", description = "request 정보를 기반으로 댓글을 생성합니다.")
+ @ApiResponse(responseCode = "200", description = "생성된 댓글을 반환합니다.")
+ public ResponseEntity> createAnswer(/*@RequestHeader String token,*/
+ @Parameter(description = "댓글의 구성요소 입니다. 질문글의 id, 작성자, 댓글내용이 필요합니다.", required = true)
+ @RequestBody AnswerPostRequest request) {
+ String userId = UUID.randomUUID().toString(); //jwtTokenProvider.extractUUID(token);
+ AnswerResponse answer = answerService.createAnswer(userId, request);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully create answer",answer));
+ }
+
+ @GetMapping("/list")
+ @Operation(summary = "댓글 리스트 생성", description = "request 정보를 기반으로 댓글의 리스트를 생성합니다.")
+ @ApiResponse(responseCode = "200", description = "request 정보를 기반으로 생성된 댓글의 리스트가 반환됩니다.")
+ public ResponseEntity> listAnswer(@Parameter(description = "댓글 리스트 생성을 위한 파라미터 값입니다. 질문글의 id, cursorId, 댓글 정렬 기준( date / like )이 필요합니다.", required = true)
+ @RequestBody AnswerListRequest request) {
+ AnswerSliceResponse response = answerService.getAnswerList(request.questionId(), request.cursorId(), request.sortBy());
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully create answer list", response));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "댓글 수정", description = "request 정보를 기반으로 댓글을 수정합니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200을 반환됩니다.")
+ public ResponseEntity> updateAnswer(/*@RequestHeader String token,*/
+ @Parameter(description = "댓글 수정을 위한 파라미터입니다. 댓글 id, 질문글 id, 작성자, 댓글 내용이 필요합니다.", required = true)
+ @RequestBody AnswerPutRequest request) {
+ String userId = UUID.randomUUID().toString(); //jwtTokenProvider.extractUUID(token);
+ answerService.updateAnswer(userId, request);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully update answer", 200));
+ }
+
+ @DeleteMapping("/erase")
+ @Operation(summary = "질문글 삭제", description = "댓글 id를 통해 해당글을 삭제합니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200이 반환됩니다.")
+ public ResponseEntity> eraseAnswer( @Parameter(description = "삭제할 댓글의 id가 필요합니다.", required = true)
+ @RequestParam Long id) {
+ answerService.eraseAnswer(id);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully delete answer", 200));
+ }
+
+ @PutMapping("/like")
+ @Operation(summary = "댓글 추천", description = "해당 id의 댓글을 추천합니다. 현재 추천 댓글 여부를 관리하지 않습니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200을 반환합니다.")
+ public ResponseEntity> upLikeCount( @Parameter(description = "추천할 댓글의 id가 필요합니다.", required = true)
+ @RequestParam Long id) {
+ answerService.upLikeCountById(id);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully like answer", 200));
+ }
+
+ @PutMapping("/unlike")
+ @Operation(summary = "댓글 추천 해제", description = "해당 id의 댓글을 추천 해제합니다. 현재 추천 댓글 여부를 관리하지 않습니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200을 반환합니다.")
+ public ResponseEntity> downLikeCount( @Parameter(description = "추천 해제할 댓글의 id가 필요합니다.", required = true)
+ @RequestParam Long id) {
+ answerService.downLikeCountById(id);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully unlike answer", 200));
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/controller/FAQController.java b/back/src/main/java/com/example/capstone/domain/qna/controller/FAQController.java
new file mode 100644
index 0000000000..a85ca924fb
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/controller/FAQController.java
@@ -0,0 +1,97 @@
+package com.example.capstone.domain.qna.controller;
+
+import com.example.capstone.domain.jwt.JwtTokenProvider;
+import com.example.capstone.domain.qna.dto.*;
+import com.example.capstone.domain.qna.entity.FAQ;
+import com.example.capstone.domain.qna.service.FAQService;
+import com.example.capstone.domain.qna.service.ImageService;
+import com.example.capstone.global.dto.ApiResult;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.springframework.http.HttpStatus.CREATED;
+
+@Controller
+@RequiredArgsConstructor
+@RequestMapping("/api/faq")
+public class FAQController {
+ private final FAQService faqService;
+
+ private final ImageService imageService;
+
+ private final JwtTokenProvider jwtTokenProvider;
+
+ @PostMapping(value = "/create", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(summary = "FAQ글 생성", description = "request 정보를 기반으로 FAQ글을 생성합니다. imgList 정보를 통해 이미지 파일을 업로드 합니다")
+ @ApiResponse(responseCode = "200", description = "request 정보를 기반으로 생성된 FAQ글과 imgList을 통해 업로드된 이미지 파일의 url 정보가 함께 반환됩니다.")
+ public ResponseEntity> createFAQ( @Parameter(description = "FAQ글 생성을 위한 파라미터입니다. 제목, 작성자, 질문, 답변, 언어, 태그값이 필요합니다.", required = true)
+ @RequestPart FAQPostRequest request,
+ @Parameter(description = "FAQ글에 첨부될 이미지입니다. List 형태로 입력되야 합니다.")
+ @RequestPart(required = false) List imgList) {
+ List urlList = new ArrayList<>();
+ FAQResponse faq = faqService.createFAQ(request);
+ if(imgList != null) {
+ System.out.println(imgList.size());
+ urlList = imageService.upload(imgList, faq.id(), true);
+ }
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully create FAQ", new FAQEntireResponse(faq, urlList)));
+ }
+
+ @GetMapping("/read")
+ @Operation(summary = "FAQ글 읽기", description = "FAQ글을 읽어 반환합니다.")
+ @ApiResponse(responseCode = "200", description = "FAQ글의 내용이 담긴 content와 첨부이미지 주소가 담긴 imgUrl이 반환됩니다.")
+ public ResponseEntity> readFAQ( @Parameter(description = "읽을 FAQ글의 id가 필요합니다.", required = true)
+ @RequestParam Long id) {
+ FAQResponse faqResponse = faqService.getFAQ(id);
+ List urlList = imageService.getUrlListByFAQId(id);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully read FAQ", new FAQEntireResponse(faqResponse, urlList)));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "FAQ글 수정", description = "FAQ글을 수정합니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200을 반환합니다.")
+ public ResponseEntity> updateFAQ( @Parameter(description = "FAQ글 수정을 위한 파라미터입니다. FAQ글 id, 제목, 작성자, 질문, 답변, 언어, 태그값이 필요합니다.", required = true)
+ @RequestBody FAQPutRequest request) {
+ faqService.updateFAQ(request);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully update FAQ", 200));
+ }
+
+ @DeleteMapping("/erase")
+ @Operation(summary = "FAQ글 삭제", description = "FAQ글을 삭제합니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200을 반환합니다.")
+ public ResponseEntity> eraseFAQ( @Parameter(description = "삭제할 FAQ글의 id가 필요합니다.", required = true)
+ @RequestParam Long id) {
+ List urlList = imageService.getUrlListByFAQId(id);
+ for(String url : urlList) {
+ imageService.deleteImageFromS3(url);
+ }
+ faqService.eraseFAQ(id);
+ imageService.deleteByFAQId(id);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully delete FAQ", 200));
+ }
+
+ @GetMapping("/list")
+ @Operation(summary = "FAQ글의 미리보기 리스트 생성", description = "FAQ글 리스트를 생성하여 반환합니다.")
+ @ApiResponse(responseCode = "200", description = "FAQ글의 미리보기 리스트가 반환됩니다.")
+ public ResponseEntity> listFAQ( @Parameter(description = "FAQ글 리스트를 생성하기 위한 파라미터입니다. cursorId, 언어, 검색어, 태그값이 필요합니다.", required = true)
+ @RequestBody FAQListRequest request) {
+ FAQSliceResponse response = faqService.getFAQList(request.cursorId(), request.language(), request.word(), request.tag());
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully create FAQ list", response));
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/controller/QuestionController.java b/back/src/main/java/com/example/capstone/domain/qna/controller/QuestionController.java
new file mode 100644
index 0000000000..8355afa08b
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/controller/QuestionController.java
@@ -0,0 +1,100 @@
+package com.example.capstone.domain.qna.controller;
+
+import com.example.capstone.domain.jwt.JwtTokenProvider;
+import com.example.capstone.domain.qna.dto.*;
+import com.example.capstone.domain.qna.entity.Question;
+import com.example.capstone.domain.qna.service.ImageService;
+import com.example.capstone.domain.qna.service.QuestionService;
+import com.example.capstone.global.dto.ApiResult;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.*;
+
+import static org.springframework.http.HttpStatus.CREATED;
+
+@Controller
+@RequiredArgsConstructor
+@RequestMapping("/api/question")
+public class QuestionController {
+
+ private final QuestionService questionService;
+ private final ImageService imageService;
+ private final JwtTokenProvider jwtTokenProvider;
+
+ @PostMapping(value = "/create", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(summary = "질문글 생성", description = "request 정보를 기반으로 질문글을 생성합니다. imgList 정보를 통해 이미지 파일을 업로드 합니다")
+ @ApiResponse(responseCode = "200", description = "request 정보를 기반으로 생성된 질문글과 imgList을 통해 업로드된 이미지 파일의 url 정보가 함께 반환됩니다.")
+ public ResponseEntity> createQuestion(/*@RequestHeader String token,*/
+ @Parameter(description = "질문글의 구성 요소 입니다. 제목, 작성자, 본문, 태그, 국가 정보가 들어가야 합니다.", required = true)
+ @RequestPart QuestionPostRequest request,
+ @Parameter(description = "질문글에 첨부되는 이미지 파일들 입니다. 여러 파일을 리스트 형식으로 입력해야 합니다.")
+ @RequestPart(required = false) List imgList) {
+ List urlList = new ArrayList<>();
+ String userId = UUID.randomUUID().toString();//jwtTokenProvider.extractUUID(token);
+ QuestionResponse quest = questionService.createQuestion(userId, request);
+ if(imgList != null){
+ urlList = imageService.upload(imgList, quest.id(), false);
+ }
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully create question", new QuestionEntireResponse(quest, urlList)));
+ }
+
+ @GetMapping("/read")
+ @Operation(summary = "질문글 불러오기", description = "id를 통해 해당 질문글을 가져옵니다.")
+ @ApiResponse(responseCode = "200", description = "해당 id의 질문글과 이미지 url을 반환합니다.")
+ public ResponseEntity> readQuestion(
+ @Parameter(description = "가져올 질문글의 id 입니다.", required = true)
+ @RequestParam Long id) {
+ QuestionResponse questionResponse = questionService.getQuestion(id);
+ List urlList = imageService.getUrlListByQuestionId(id);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully read question", new QuestionEntireResponse(questionResponse, urlList)));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "질문글 수정", description = "request 정보를 기반으로 질문글을 수정합니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200을 리턴합니다.")
+ public ResponseEntity> updateQuestion(/*@RequestHeader String token,*/
+ @Parameter(description = "수정할 질문글의 id와 질문글의 content가 들어갑니다.", required = true)
+ @RequestBody QuestionPutRequest request) {
+ String userId = UUID.randomUUID().toString();//jwtTokenProvider.extractUUID(token);
+ questionService.updateQuestion(userId, request);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully update question", 200));
+ }
+
+ @DeleteMapping("/erase")
+ @Operation(summary = "질문글 삭제", description = "id를 기반으로 해당 질문글을 삭제합니다.")
+ @ApiResponse(responseCode = "200", description = "완료시 200을 리턴합니다.")
+ public ResponseEntity> eraseQuestion(
+ @Parameter(description = "삭제할 질문글의 id 입니다.", required = true)
+ @RequestParam Long id) {
+ List urlList = imageService.getUrlListByQuestionId(id);
+ for(String url : urlList) {
+ imageService.deleteImageFromS3(url);
+ }
+ questionService.eraseQuestion(id);
+ imageService.deleteByQuestionId(id);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully delete question", 200));
+ }
+
+ @GetMapping("/list")
+ @Operation(summary = "질문글 미리보기 리스트 생성", description = "request 정보를 기반으로 페이지네이션이 적용된 질문글 리스트를 반환합니다.")
+ @ApiResponse(responseCode = "200", description = "request 조건에 맞는 질문글 리스트를 반환합니다.")
+ public ResponseEntity> listQuestion(
+ @Parameter(description = "질문글 리스트를 위한 cursorId, 검색어 word, 태그값 tag가 필요합니다.", required = true)
+ @RequestBody QuestionListRequest request) {
+ QuestionSliceResponse response = questionService.getQuestionList(request);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully create question list", response));
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerListRequest.java b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerListRequest.java
new file mode 100644
index 0000000000..7b58d06cbf
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerListRequest.java
@@ -0,0 +1,10 @@
+package com.example.capstone.domain.qna.dto;
+
+import org.springframework.web.bind.annotation.RequestBody;
+
+public record AnswerListRequest(
+ Long questionId,
+ Long cursorId,
+ String sortBy
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerListResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerListResponse.java
new file mode 100644
index 0000000000..001b2944dc
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerListResponse.java
@@ -0,0 +1,13 @@
+package com.example.capstone.domain.qna.dto;
+
+import java.time.LocalDateTime;
+
+public record AnswerListResponse(
+ Long id,
+ Long questionId,
+ String author,
+ String context,
+ Long likeCount,
+ LocalDateTime createdDate
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerPostRequest.java b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerPostRequest.java
new file mode 100644
index 0000000000..5d3e52c499
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerPostRequest.java
@@ -0,0 +1,8 @@
+package com.example.capstone.domain.qna.dto;
+
+public record AnswerPostRequest(
+ Long questionId,
+ String author,
+ String context
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerPutRequest.java b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerPutRequest.java
new file mode 100644
index 0000000000..e6a3c0c0e8
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerPutRequest.java
@@ -0,0 +1,9 @@
+package com.example.capstone.domain.qna.dto;
+
+public record AnswerPutRequest(
+ Long id,
+ Long questionId,
+ String author,
+ String context
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerResponse.java
new file mode 100644
index 0000000000..19f74e94ec
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerResponse.java
@@ -0,0 +1,22 @@
+package com.example.capstone.domain.qna.dto;
+
+import com.example.capstone.domain.qna.entity.Question;
+import jakarta.persistence.Column;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record AnswerResponse(
+ Long id,
+ Long questionId,
+ String author,
+ String context,
+ Long likeCount,
+ LocalDateTime createdDate,
+ LocalDateTime updatedDate,
+ UUID uuid
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerSliceResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerSliceResponse.java
new file mode 100644
index 0000000000..5fa5c8f92a
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/AnswerSliceResponse.java
@@ -0,0 +1,10 @@
+package com.example.capstone.domain.qna.dto;
+
+import java.util.List;
+
+public record AnswerSliceResponse(
+ Long lastCursorId,
+ Boolean hasNext,
+ List answerList
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/FAQEntireResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQEntireResponse.java
new file mode 100644
index 0000000000..01cf7fa125
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQEntireResponse.java
@@ -0,0 +1,9 @@
+package com.example.capstone.domain.qna.dto;
+
+import java.util.List;
+
+public record FAQEntireResponse(
+ FAQResponse faqResponse,
+ List imgUrl
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/FAQListRequest.java b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQListRequest.java
new file mode 100644
index 0000000000..3cb9b4eb91
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQListRequest.java
@@ -0,0 +1,11 @@
+package com.example.capstone.domain.qna.dto;
+
+import org.springframework.web.bind.annotation.RequestBody;
+
+public record FAQListRequest(
+ Long cursorId,
+ String language,
+ String word,
+ String tag
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/FAQListResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQListResponse.java
new file mode 100644
index 0000000000..f47d0b8d3c
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQListResponse.java
@@ -0,0 +1,13 @@
+package com.example.capstone.domain.qna.dto;
+
+import java.time.LocalDateTime;
+
+public record FAQListResponse(
+ Long id,
+ String title,
+ String author,
+ LocalDateTime createdDate,
+ String tag,
+ String language
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/FAQPostRequest.java b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQPostRequest.java
new file mode 100644
index 0000000000..8320dde899
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQPostRequest.java
@@ -0,0 +1,11 @@
+package com.example.capstone.domain.qna.dto;
+
+public record FAQPostRequest(
+ String title,
+ String author,
+ String question,
+ String answer,
+ String language,
+ String tag
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/FAQPutRequest.java b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQPutRequest.java
new file mode 100644
index 0000000000..6f98f62d5e
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQPutRequest.java
@@ -0,0 +1,12 @@
+package com.example.capstone.domain.qna.dto;
+
+public record FAQPutRequest(
+ Long id,
+ String title,
+ String author,
+ String question,
+ String answer,
+ String language,
+ String tag
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/FAQResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQResponse.java
new file mode 100644
index 0000000000..0d7607552a
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQResponse.java
@@ -0,0 +1,18 @@
+package com.example.capstone.domain.qna.dto;
+
+import jakarta.persistence.Column;
+
+import java.time.LocalDateTime;
+
+public record FAQResponse(
+ Long id,
+ String title,
+ String author,
+ String question,
+ String answer,
+ LocalDateTime createdDate,
+ LocalDateTime updatedDate,
+ String tag,
+ String language
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/FAQSliceResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQSliceResponse.java
new file mode 100644
index 0000000000..a9048c383e
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/FAQSliceResponse.java
@@ -0,0 +1,10 @@
+package com.example.capstone.domain.qna.dto;
+
+import java.util.List;
+
+public record FAQSliceResponse(
+ Long lastCursorId,
+ Boolean hasNext,
+ List faqList
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionEntireResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionEntireResponse.java
new file mode 100644
index 0000000000..e5a36550cb
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionEntireResponse.java
@@ -0,0 +1,9 @@
+package com.example.capstone.domain.qna.dto;
+
+import java.util.List;
+
+public record QuestionEntireResponse(
+ QuestionResponse questionResponse,
+ List imgUrl
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionListRequest.java b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionListRequest.java
new file mode 100644
index 0000000000..16c8a687f2
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionListRequest.java
@@ -0,0 +1,8 @@
+package com.example.capstone.domain.qna.dto;
+
+public record QuestionListRequest(
+ Long cursorId,
+ String word,
+ String tag
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionListResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionListResponse.java
new file mode 100644
index 0000000000..1561701749
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionListResponse.java
@@ -0,0 +1,17 @@
+package com.example.capstone.domain.qna.dto;
+
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+public record QuestionListResponse (
+ Long id,
+ String title,
+ String author,
+ String context,
+ String tag,
+ String country,
+ LocalDateTime createdDate
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionPostRequest.java b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionPostRequest.java
new file mode 100644
index 0000000000..94974b605b
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionPostRequest.java
@@ -0,0 +1,12 @@
+package com.example.capstone.domain.qna.dto;
+
+import java.time.LocalDateTime;
+
+public record QuestionPostRequest(
+ String title,
+ String author,
+ String context,
+ String tag,
+ String country
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionPutRequest.java b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionPutRequest.java
new file mode 100644
index 0000000000..dd3034f4b7
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionPutRequest.java
@@ -0,0 +1,11 @@
+package com.example.capstone.domain.qna.dto;
+
+public record QuestionPutRequest(
+ Long id,
+ String title,
+ String author,
+ String context,
+ String tag,
+ String country
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionResponse.java
new file mode 100644
index 0000000000..12fa8f1d20
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionResponse.java
@@ -0,0 +1,17 @@
+package com.example.capstone.domain.qna.dto;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+public record QuestionResponse(
+ Long id,
+ String title,
+ String author,
+ String context,
+ LocalDateTime createdDate,
+ LocalDateTime updatedDate,
+ String tag,
+ String country,
+ UUID uuid
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionSliceResponse.java b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionSliceResponse.java
new file mode 100644
index 0000000000..63d1d81f4d
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/dto/QuestionSliceResponse.java
@@ -0,0 +1,12 @@
+package com.example.capstone.domain.qna.dto;
+
+import java.util.List;
+import java.util.Map;
+
+public record QuestionSliceResponse(
+ Long lastCursorId,
+ Boolean hasNext,
+ List questionList,
+ Map answerCountList
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/entity/Answer.java b/back/src/main/java/com/example/capstone/domain/qna/entity/Answer.java
new file mode 100644
index 0000000000..4012f8fbb3
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/entity/Answer.java
@@ -0,0 +1,62 @@
+package com.example.capstone.domain.qna.entity;
+
+import com.example.capstone.domain.qna.dto.AnswerResponse;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+@Entity
+@Table(name = "answers")
+@Getter
+@Builder
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Answer {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "question_id")
+ private Question question;
+
+ @Column(name = "author", nullable = false)
+ private String author;
+
+ @Column(name = "context", columnDefinition = "LONGTEXT", nullable = false)
+ private String context;
+
+ @Column(name = "like_count", nullable = false)
+ private Long likeCount;
+
+ @Column(name = "created_date", nullable = false)
+ private LocalDateTime createdDate;
+
+ @Column(name = "updated_date", nullable = false)
+ private LocalDateTime updatedDate;
+
+ @Column(name = "uuid", nullable = false, unique = true)
+ private UUID uuid;
+
+ public void update(String context, LocalDateTime updatedDate) {
+ this.context = context;
+ this.updatedDate = updatedDate;
+ }
+
+ public void upLikeCount() {
+ this.likeCount += 1;
+ }
+
+ public void downLikeCount() {
+ this.likeCount -= 1;
+ }
+
+ public AnswerResponse toDTO() {
+ return new AnswerResponse(id, question.getId(), author, context, likeCount, createdDate, updatedDate, uuid);
+ }
+}
\ No newline at end of file
diff --git a/back/src/main/java/com/example/capstone/domain/qna/entity/FAQ.java b/back/src/main/java/com/example/capstone/domain/qna/entity/FAQ.java
new file mode 100644
index 0000000000..abbc4da1c8
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/entity/FAQ.java
@@ -0,0 +1,55 @@
+package com.example.capstone.domain.qna.entity;
+
+import com.example.capstone.domain.qna.dto.FAQResponse;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "faqs")
+@Getter
+@Builder
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class FAQ {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "title", nullable = false)
+ private String title;
+
+ @Column(name = "author", nullable = false)
+ private String author;
+
+ @Column(name = "question", columnDefinition = "LONGTEXT", nullable = false)
+ private String question;
+
+ @Column(name = "answer", columnDefinition = "LONGTEXT", nullable = false)
+ private String answer;
+
+ @Column(name = "created_date", nullable = false)
+ private LocalDateTime createdDate;
+
+ @Column(name = "updated_date", nullable = false)
+ private LocalDateTime updatedDate;
+
+ @Column(name = "tag", nullable = false)
+ private String tag;
+
+ @Column(name = "language", nullable = false)
+ private String language;
+
+ public void update(String title, String question, String answer, LocalDateTime updatedDate) {
+ this.title = title;
+ this.question = question;
+ this.answer = answer;
+ this.updatedDate = updatedDate;
+ }
+
+ public FAQResponse toDTO() {
+ return new FAQResponse(id, title, author, question, answer, createdDate, updatedDate, tag, language);
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/entity/FAQImage.java b/back/src/main/java/com/example/capstone/domain/qna/entity/FAQImage.java
new file mode 100644
index 0000000000..10995ef0e8
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/entity/FAQImage.java
@@ -0,0 +1,25 @@
+package com.example.capstone.domain.qna.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "faq_images")
+@Getter
+@Builder
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class FAQImage {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @ManyToOne
+ @JoinColumn(name = "faq_id")
+ private FAQ faqId;
+
+ @Column(name = "url", nullable = false)
+ private String url;
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/entity/Question.java b/back/src/main/java/com/example/capstone/domain/qna/entity/Question.java
new file mode 100644
index 0000000000..8b4ac55597
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/entity/Question.java
@@ -0,0 +1,58 @@
+package com.example.capstone.domain.qna.entity;
+
+import com.example.capstone.domain.qna.dto.QuestionResponse;
+import com.example.capstone.domain.user.entity.User;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+@Entity
+@Table(name = "questions")
+@Getter
+@Builder
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Question {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "title", nullable = false)
+ private String title;
+
+ @Column(name = "author", nullable = false)
+ private String author;
+
+ @Column(name = "context", columnDefinition = "LONGTEXT", nullable = false)
+ private String context;
+
+ @Column(name = "created_date", nullable = false)
+ private LocalDateTime createdDate;
+
+ @Column(name = "updated_date", nullable = false)
+ private LocalDateTime updatedDate;
+
+ @Column(name = "tag", nullable = false)
+ private String tag;
+
+ @Column(name = "country", nullable = false)
+ private String country;
+
+ @Column(name = "uuid", nullable = false)
+ private UUID uuid;
+
+ public void update(String title, String context, LocalDateTime updatedDate) {
+ this.title = title;
+ this.context = context;
+ this.updatedDate = updatedDate;
+ }
+
+ public QuestionResponse toDTO() {
+ return new QuestionResponse(id, title, author, context, createdDate, updatedDate, tag, country, uuid);
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/entity/QuestionImage.java b/back/src/main/java/com/example/capstone/domain/qna/entity/QuestionImage.java
new file mode 100644
index 0000000000..286068feeb
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/entity/QuestionImage.java
@@ -0,0 +1,25 @@
+package com.example.capstone.domain.qna.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "question_images")
+@Getter
+@Builder
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class QuestionImage {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @ManyToOne
+ @JoinColumn(name = "question_id")
+ private Question questionId;
+
+ @Column(name = "url", nullable = false)
+ private String url;
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/AnswerListRepository.java b/back/src/main/java/com/example/capstone/domain/qna/repository/AnswerListRepository.java
new file mode 100644
index 0000000000..e48da7a96c
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/AnswerListRepository.java
@@ -0,0 +1,12 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.dto.AnswerListResponse;
+import com.example.capstone.domain.qna.dto.AnswerSliceResponse;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+
+import java.util.Map;
+
+public interface AnswerListRepository {
+ AnswerSliceResponse getAnswerListByPaging(Long cursorId, Pageable page, Long questionId, String sortBy);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/AnswerRepository.java b/back/src/main/java/com/example/capstone/domain/qna/repository/AnswerRepository.java
new file mode 100644
index 0000000000..cab816cfe1
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/AnswerRepository.java
@@ -0,0 +1,16 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.entity.Answer;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface AnswerRepository extends JpaRepository, AnswerListRepository {
+ Answer save(Answer answer);
+
+ Optional findById(Long id);
+
+ void deleteById(Long id);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/AnswerRepositoryImpl.java b/back/src/main/java/com/example/capstone/domain/qna/repository/AnswerRepositoryImpl.java
new file mode 100644
index 0000000000..a075a0506a
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/AnswerRepositoryImpl.java
@@ -0,0 +1,82 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.dto.AnswerListResponse;
+import com.example.capstone.domain.qna.dto.AnswerSliceResponse;
+import com.example.capstone.domain.qna.dto.QuestionListResponse;
+import com.example.capstone.domain.qna.entity.QAnswer;
+import com.example.capstone.domain.qna.entity.QQuestion;
+import com.querydsl.core.types.Order;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.Projections;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.core.util.StringUtils;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.SliceImpl;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RequiredArgsConstructor
+public class AnswerRepositoryImpl implements AnswerListRepository {
+ private final JPAQueryFactory jpaQueryFactory;
+ private final QAnswer answer = QAnswer.answer;
+ private final QQuestion question = QQuestion.question;
+
+ @Override
+ public AnswerSliceResponse getAnswerListByPaging(Long cursorId, Pageable page, Long questionId, String sortBy) {
+ OrderSpecifier[] orderSpecifiers = createOrderSpecifier(sortBy);
+
+ List answerList = jpaQueryFactory
+ .select(
+ Projections.constructor(AnswerListResponse.class, answer.id, answer.question.id,
+ answer.author, answer.context,
+ answer.likeCount ,answer.createdDate)
+ )
+ .from(answer)
+ .leftJoin(answer.question, question)
+ .fetchJoin()
+ .where(cursorId(cursorId),
+ questionEq(questionId))
+ .orderBy(orderSpecifiers)
+ .limit(page.getPageSize() + 1)
+ .distinct()
+ .fetch();
+
+ boolean hasNext = false;
+ if(answerList.size() > page.getPageSize()) {
+ answerList.remove(page.getPageSize());
+ hasNext = true;
+ }
+
+ Long lastCursorId = null;
+
+ if(hasNext && answerList.size() != 0) {
+ lastCursorId = answerList.get(answerList.size() - 1).id();
+ }
+
+ return new AnswerSliceResponse(lastCursorId, hasNext, answerList);
+ }
+
+ private OrderSpecifier[] createOrderSpecifier(String sortBy) {
+ List orderSpecifierList = new ArrayList<>();
+
+ if(sortBy.equals("date")) {
+ orderSpecifierList.add(new OrderSpecifier(Order.DESC, answer.createdDate));
+ }
+ else if(sortBy.equals("like")) {
+ orderSpecifierList.add(new OrderSpecifier(Order.DESC, answer.likeCount));
+ }
+ return orderSpecifierList.toArray(new OrderSpecifier[orderSpecifierList.size()]);
+ }
+
+ private BooleanExpression cursorId(Long cursorId) {
+ return cursorId == null ? null : answer.id.gt(cursorId);
+ }
+
+ private BooleanExpression questionEq(Long questionId) { return questionId == null ? null : answer.question.id.eq(questionId); }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/FAQImageCustomRepository.java b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQImageCustomRepository.java
new file mode 100644
index 0000000000..b97a6204c8
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQImageCustomRepository.java
@@ -0,0 +1,8 @@
+package com.example.capstone.domain.qna.repository;
+
+import java.util.List;
+
+public interface FAQImageCustomRepository {
+ List findByFAQId(Long faqId);
+ void deleteByFAQId(Long faqId);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/FAQImageRepository.java b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQImageRepository.java
new file mode 100644
index 0000000000..a8f7cb9905
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQImageRepository.java
@@ -0,0 +1,14 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.entity.FAQImage;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface FAQImageRepository extends JpaRepository, FAQImageCustomRepository {
+ FAQImage save(FAQImage faqImage);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/FAQImageRepositoryImpl.java b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQImageRepositoryImpl.java
new file mode 100644
index 0000000000..0fac4672da
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQImageRepositoryImpl.java
@@ -0,0 +1,38 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.entity.QFAQ;
+import com.example.capstone.domain.qna.entity.QFAQImage;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+public class FAQImageRepositoryImpl implements FAQImageCustomRepository{
+ private final JPAQueryFactory jpaQueryFactory;
+
+ private final QFAQImage faqImage = QFAQImage.fAQImage;
+
+ private final QFAQ faq = QFAQ.fAQ;
+
+ @Override
+ public List findByFAQId(Long faqId) {
+ List urlList = jpaQueryFactory
+ .select(faqImage.url)
+ .from(faqImage)
+ .leftJoin(faqImage.faqId, faq)
+ .fetchJoin()
+ .where(faqImage.faqId.id.eq(faqId))
+ .distinct()
+ .fetch();
+ return urlList;
+ }
+
+ @Override
+ public void deleteByFAQId(Long faqId) {
+ jpaQueryFactory
+ .delete(faqImage)
+ .where(faqImage.faqId.id.eq(faqId))
+ .execute();
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/FAQListRepository.java b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQListRepository.java
new file mode 100644
index 0000000000..daa7f29f8f
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQListRepository.java
@@ -0,0 +1,12 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.dto.FAQListResponse;
+import com.example.capstone.domain.qna.dto.FAQSliceResponse;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+
+import java.util.Map;
+
+public interface FAQListRepository {
+ FAQSliceResponse getFAQListByPaging(Long cursorId, Pageable page, String language, String word, String tag);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/FAQRepository.java b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQRepository.java
new file mode 100644
index 0000000000..c0a9eb4fcd
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQRepository.java
@@ -0,0 +1,17 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.entity.FAQ;
+import com.example.capstone.domain.qna.entity.Question;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface FAQRepository extends JpaRepository, FAQListRepository {
+ FAQ save(FAQ faq);
+
+ Optional findById(Long id);
+
+ void deleteById(Long id);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/FAQRepositoryImpl.java b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQRepositoryImpl.java
new file mode 100644
index 0000000000..f01f40f752
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/FAQRepositoryImpl.java
@@ -0,0 +1,77 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.dto.FAQListResponse;
+import com.example.capstone.domain.qna.dto.FAQSliceResponse;
+import com.example.capstone.domain.qna.dto.QuestionListResponse;
+import com.example.capstone.domain.qna.entity.QFAQ;
+import com.querydsl.core.types.Projections;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.SliceImpl;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+import java.util.Map;
+
+@RequiredArgsConstructor
+public class FAQRepositoryImpl implements FAQListRepository {
+ private final JPAQueryFactory jpaQueryFactory;
+ private final QFAQ faq = QFAQ.fAQ;
+
+ @Override
+ public FAQSliceResponse getFAQListByPaging(Long cursorId, Pageable page, String language, String word, String tag) {
+ List faqList = jpaQueryFactory
+ .select(
+ Projections.constructor(
+ FAQListResponse.class ,faq.id, faq.title, faq.author,
+ faq.createdDate, faq.tag, faq.language)
+ )
+ .from(faq)
+ .where(cursorId(cursorId),
+ languageEq(language),
+ wordEq(word),
+ tagEq(tag))
+ .orderBy(faq.createdDate.desc())
+ .limit(page.getPageSize() + 1)
+ .fetch();
+
+ boolean hasNext = false;
+ if(faqList.size() > page.getPageSize()) {
+ faqList.remove(page.getPageSize());
+ hasNext = true;
+ }
+
+ Long lastCursorId = null;
+
+ if(hasNext && faqList.size() != 0) {
+ lastCursorId = faqList.get(faqList.size() - 1).id();
+ }
+
+ return new FAQSliceResponse(lastCursorId, hasNext, faqList);
+ }
+
+ private BooleanExpression cursorId(Long cursorId) {
+ return cursorId == null ? null : faq.id.gt(cursorId);
+ }
+ private BooleanExpression languageEq(String language) {
+ if(StringUtils.hasText(language)) {
+ return faq.language.eq(language);
+ }
+ return null;
+ }
+ private BooleanExpression wordEq(String word) {
+ if(StringUtils.hasText(word)) {
+ return faq.title.contains(word);
+ }
+ return null;
+ }
+ private BooleanExpression tagEq(String tag) {
+ if(StringUtils.hasText(tag)) {
+ return faq.tag.eq(tag);
+ }
+ return null;
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionImageCustomRepository.java b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionImageCustomRepository.java
new file mode 100644
index 0000000000..52eb140ebc
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionImageCustomRepository.java
@@ -0,0 +1,9 @@
+package com.example.capstone.domain.qna.repository;
+
+import java.util.List;
+
+public interface QuestionImageCustomRepository {
+ List findByQuestionId(Long questionId);
+
+ void deleteByQuestionId(Long questionId);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionImageRepository.java b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionImageRepository.java
new file mode 100644
index 0000000000..9cb9c00afe
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionImageRepository.java
@@ -0,0 +1,12 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.entity.QuestionImage;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface QuestionImageRepository extends JpaRepository, QuestionImageCustomRepository {
+ QuestionImage save(QuestionImage qImage);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionImageRepositoryImpl.java b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionImageRepositoryImpl.java
new file mode 100644
index 0000000000..fa312f92ae
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionImageRepositoryImpl.java
@@ -0,0 +1,38 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.entity.QQuestion;
+import com.example.capstone.domain.qna.entity.QQuestionImage;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+public class QuestionImageRepositoryImpl implements QuestionImageCustomRepository {
+ private final JPAQueryFactory jpaQueryFactory;
+
+ private final QQuestionImage questionImage = QQuestionImage.questionImage;
+
+ private final QQuestion question = QQuestion.question;
+
+ @Override
+ public List findByQuestionId(Long questionId) {
+ List urlList = jpaQueryFactory
+ .select(questionImage.url)
+ .from(questionImage)
+ .leftJoin(questionImage.questionId, question)
+ .fetchJoin()
+ .where(questionImage.questionId.id.eq(questionId))
+ .distinct()
+ .fetch();
+ return urlList;
+ }
+
+ @Override
+ public void deleteByQuestionId(Long questionId) {
+ jpaQueryFactory
+ .delete(questionImage)
+ .where(questionImage.questionId.id.eq(questionId))
+ .execute();
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionListRepository.java b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionListRepository.java
new file mode 100644
index 0000000000..3b24e396cf
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionListRepository.java
@@ -0,0 +1,12 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.dto.QuestionListResponse;
+import com.example.capstone.domain.qna.dto.QuestionSliceResponse;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+
+import java.util.Map;
+
+public interface QuestionListRepository {
+ QuestionSliceResponse getQuestionListByPaging(Long cursorId, Pageable page, String word, String tag);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionRepository.java b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionRepository.java
new file mode 100644
index 0000000000..273a8b38db
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionRepository.java
@@ -0,0 +1,20 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.entity.Question;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface QuestionRepository extends JpaRepository, QuestionListRepository {
+
+ Question save(Question quest);
+
+ Optional findById(Long id);
+
+ void deleteById(Long id);
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionRepositoryImpl.java b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionRepositoryImpl.java
new file mode 100644
index 0000000000..f4c84850f8
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/repository/QuestionRepositoryImpl.java
@@ -0,0 +1,91 @@
+package com.example.capstone.domain.qna.repository;
+
+import com.example.capstone.domain.qna.dto.QuestionListResponse;
+import com.example.capstone.domain.qna.dto.QuestionSliceResponse;
+import com.example.capstone.domain.qna.entity.QAnswer;
+import com.example.capstone.domain.qna.entity.QQuestion;
+import com.querydsl.core.types.Projections;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.SliceImpl;
+import org.springframework.util.StringUtils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RequiredArgsConstructor
+public class QuestionRepositoryImpl implements QuestionListRepository {
+ private final JPAQueryFactory jpaQueryFactory;
+ private final QQuestion question = QQuestion.question;
+ private final QAnswer answer = QAnswer.answer;
+
+ @Override
+ public QuestionSliceResponse getQuestionListByPaging(Long cursorId, Pageable page, String word, String tag) {
+ List questionList = jpaQueryFactory
+ .select(
+ Projections.constructor(
+ QuestionListResponse.class,
+ question.id, question.title,
+ question.author, question.context,
+ question.tag, question.country, question.createdDate
+ )
+ )
+ .from(question)
+ .where(cursorId(cursorId),
+ wordEq(word),
+ tagEq(tag))
+ .orderBy(question.createdDate.desc())
+ .limit(page.getPageSize() + 1)
+ .fetch();
+
+ Map answerCount = new HashMap<>();
+ for(QuestionListResponse response : questionList) {
+ Long count = jpaQueryFactory
+ .select(answer.count())
+ .from(answer)
+ .where(questionIdEq(response.id()))
+ .fetchFirst();
+ answerCount.put(response.id(), count);
+ }
+
+ boolean hasNext = false;
+ if(questionList.size() > page.getPageSize()) {
+ questionList.remove(page.getPageSize());
+ hasNext = true;
+ }
+
+ Long lastCursorId = null;
+
+ if(hasNext && questionList.size() != 0) {
+ lastCursorId = questionList.get(questionList.size() - 1).id();
+ }
+
+ return new QuestionSliceResponse(lastCursorId, hasNext, questionList, answerCount);
+ }
+
+ private BooleanExpression cursorId(Long cursorId) {
+ return cursorId == null ? null : question.id.gt(cursorId);
+ }
+
+ private BooleanExpression wordEq(String word) {
+ if(StringUtils.hasText(word)) {
+ return question.title.contains(word);
+ }
+ return null;
+ }
+
+ private BooleanExpression tagEq(String tag) {
+ if(StringUtils.hasText(tag)) {
+ return question.tag.eq(tag);
+ }
+ return null;
+ }
+
+ private BooleanExpression questionIdEq(Long id) {
+ return answer.question.id.eq(id);
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/service/AnswerService.java b/back/src/main/java/com/example/capstone/domain/qna/service/AnswerService.java
new file mode 100644
index 0000000000..ab68b119b3
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/service/AnswerService.java
@@ -0,0 +1,64 @@
+package com.example.capstone.domain.qna.service;
+
+import com.example.capstone.domain.qna.dto.*;
+import com.example.capstone.domain.qna.entity.Answer;
+import com.example.capstone.domain.qna.entity.Question;
+import com.example.capstone.domain.qna.repository.AnswerRepository;
+import com.example.capstone.domain.qna.repository.QuestionRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class AnswerService {
+ private final AnswerRepository answerRepository;
+ private final QuestionRepository questionRepository;
+
+ public AnswerResponse createAnswer(String userId, AnswerPostRequest request) {
+ LocalDateTime current = LocalDateTime.now();
+ Question questionId = questionRepository.findById(request.questionId()).get();
+ Answer answer = answerRepository.save(Answer.builder().question(questionId).author(request.author())
+ .context(request.context()).createdDate(current).updatedDate(current).likeCount(0L).uuid(UUID.fromString(userId)).build());
+ return answer.toDTO();
+ }
+
+ public AnswerSliceResponse getAnswerList(Long questionId, Long cursorId, String sortBy) {
+ Pageable page = PageRequest.of(0, 10);
+ if(cursorId == 0) cursorId = null;
+ AnswerSliceResponse answerList = answerRepository.getAnswerListByPaging(cursorId, page, questionId, sortBy);
+
+ return answerList;
+ }
+
+ @Transactional
+ public void updateAnswer(String userId, AnswerPutRequest request) {
+ LocalDateTime current = LocalDateTime.now();
+ Answer answer = answerRepository.findById(request.id()).get();
+ answer.update(request.context(), current);
+ }
+
+ public void eraseAnswer(Long id) {
+ answerRepository.deleteById(id);
+ }
+
+ @Transactional
+ public void upLikeCountById(Long id) {
+ Answer answer = answerRepository.findById(id).get();
+ answer.upLikeCount();
+ }
+
+ @Transactional
+ public void downLikeCountById(Long id) {
+ Answer answer = answerRepository.findById(id).get();
+ answer.downLikeCount();
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/service/FAQService.java b/back/src/main/java/com/example/capstone/domain/qna/service/FAQService.java
new file mode 100644
index 0000000000..1881edcdd1
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/service/FAQService.java
@@ -0,0 +1,51 @@
+package com.example.capstone.domain.qna.service;
+
+import com.example.capstone.domain.qna.dto.*;
+import com.example.capstone.domain.qna.entity.FAQ;
+import com.example.capstone.domain.qna.repository.FAQRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class FAQService {
+ private final FAQRepository faqRepository;
+
+ public FAQResponse createFAQ(FAQPostRequest request) {
+ LocalDateTime current = LocalDateTime.now();
+ FAQ faq = faqRepository.save(FAQ.builder().title(request.title()).author(request.author())
+ .question(request.question()).answer(request.answer())
+ .createdDate(current).updatedDate(current).tag(request.tag()).language(request.language()).build());
+ return faq.toDTO();
+ }
+
+ public FAQResponse getFAQ(Long id) {
+ FAQ faq = faqRepository.findById(id).get();
+ return faq.toDTO();
+ }
+
+ @Transactional
+ public void updateFAQ(FAQPutRequest request) {
+ LocalDateTime current = LocalDateTime.now();
+ FAQ faq = faqRepository.findById(request.id()).get();
+ faq.update(request.title(), request.question(), request.answer(), current);
+ }
+
+ public void eraseFAQ(Long id) {
+ faqRepository.deleteById(id);
+ }
+
+ public FAQSliceResponse getFAQList(Long cursorId, String language, String word, String tag) {
+ Pageable page = PageRequest.of(0, 20);
+ if(cursorId == 0) cursorId = null;
+ FAQSliceResponse faqListResponse = faqRepository.getFAQListByPaging(cursorId, page, language, word, tag);
+ return faqListResponse;
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/service/ImageService.java b/back/src/main/java/com/example/capstone/domain/qna/service/ImageService.java
new file mode 100644
index 0000000000..3b4e598675
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/service/ImageService.java
@@ -0,0 +1,159 @@
+package com.example.capstone.domain.qna.service;
+
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.model.*;
+import com.amazonaws.util.IOUtils;
+import com.example.capstone.domain.qna.entity.FAQ;
+import com.example.capstone.domain.qna.entity.FAQImage;
+import com.example.capstone.domain.qna.entity.Question;
+import com.example.capstone.domain.qna.entity.QuestionImage;
+import com.example.capstone.domain.qna.repository.FAQImageRepository;
+import com.example.capstone.domain.qna.repository.FAQRepository;
+import com.example.capstone.domain.qna.repository.QuestionImageRepository;
+import com.example.capstone.domain.qna.repository.QuestionRepository;
+import com.example.capstone.global.error.exception.BusinessException;
+import com.example.capstone.global.error.exception.ErrorCode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.util.*;
+
+@Service
+@RequiredArgsConstructor
+public class ImageService {
+ private final AmazonS3 amazonS3;
+
+ @Value("${s3.bucket.name}")
+ private String bucketName;
+
+ private final QuestionImageRepository questionImageRepository;
+
+ private final QuestionRepository questionRepository;
+
+ private final FAQImageRepository faqImageRepository;
+
+ private final FAQRepository faqRepository;
+
+ public List upload(List images, Long questionId, boolean isFAQ) {
+ List imgUrlList = new ArrayList<>();
+
+ for(MultipartFile image : images) {
+ if (image.isEmpty() || Objects.isNull(image.getOriginalFilename())) {
+ throw new BusinessException(ErrorCode.EMPTY_FILE_EXCEPTION);
+ }
+ String url = this.uploadImage(image);
+ imgUrlList.add(url);
+ }
+
+ if(isFAQ) {
+ FAQ faq = faqRepository.findById(questionId).get();
+ for(String url : imgUrlList) {
+ faqImageRepository.save(FAQImage.builder().faqId(faq).url(url).build());
+ }
+ }
+ else {
+ Question question = questionRepository.findById(questionId).get();
+ for(String url : imgUrlList) {
+ questionImageRepository.save(QuestionImage.builder().questionId(question).url(url).build());
+ }
+ }
+
+ return imgUrlList;
+ }
+
+ private String uploadImage(MultipartFile image) {
+ this.validateImageFileExtention(image.getOriginalFilename());
+ try {
+ return this.uploadImageToS3(image);
+ } catch (IOException e) {
+ throw new BusinessException(ErrorCode.IO_EXCEPTION_ON_IMAGE_UPLOAD);
+ }
+ }
+
+ private void validateImageFileExtention(String filename) {
+ int lastDotIndex = filename.lastIndexOf(".");
+ if (lastDotIndex == -1) {
+ throw new BusinessException(ErrorCode.NO_FILE_EXTENTION);
+ }
+
+ String extention = filename.substring(lastDotIndex + 1).toLowerCase();
+ List allowedExtentionList = Arrays.asList("jpg", "jpeg", "png", "gif");
+
+ if (!allowedExtentionList.contains(extention)) {
+ throw new BusinessException(ErrorCode.INVALID_FILE_EXTENTION);
+ }
+ }
+
+ private String uploadImageToS3(MultipartFile image) throws IOException {
+ String originalFilename = image.getOriginalFilename();
+ String extention = originalFilename.substring(originalFilename.lastIndexOf("."));
+
+ String s3FileName = UUID.randomUUID().toString().substring(0, 10) + originalFilename;
+
+ InputStream is = image.getInputStream();
+ byte[] bytes = IOUtils.toByteArray(is);
+
+ ObjectMetadata metadata = new ObjectMetadata();
+ metadata.setContentType("image/" + extention);
+ metadata.setContentLength(bytes.length);
+ ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
+
+ try {
+ PutObjectRequest putObjectRequest =
+ new PutObjectRequest(bucketName, s3FileName, byteArrayInputStream, metadata)
+ .withCannedAcl(CannedAccessControlList.PublicRead);
+ amazonS3.putObject(putObjectRequest);
+ } catch (Exception e) {
+ throw new BusinessException(ErrorCode.PUT_OBJECT_EXCEPTION);
+ } finally {
+ byteArrayInputStream.close();
+ is.close();
+ }
+
+ return amazonS3.getUrl(bucketName, s3FileName).toString();
+ }
+
+ public void deleteImageFromS3(String imageAddress) {
+ String key = getKeyFromImageAddress(imageAddress);
+ try {
+ amazonS3.deleteObject(new DeleteObjectRequest(bucketName, key));
+ } catch (Exception e) {
+ throw new BusinessException(ErrorCode.IO_EXCEPTION_ON_IMAGE_DELETE);
+ }
+ }
+
+ private String getKeyFromImageAddress(String imageAddress) {
+ try{
+ URL url = new URL(imageAddress);
+ String decodingKey = URLDecoder.decode(url.getPath(), "UTF-8");
+ return decodingKey.substring(1); // 맨 앞의 '/' 제거
+ }catch (MalformedURLException | UnsupportedEncodingException e){
+ throw new BusinessException(ErrorCode.IO_EXCEPTION_ON_IMAGE_DELETE);
+ }
+ }
+
+ public List getUrlListByQuestionId(Long questionId) {
+ return questionImageRepository.findByQuestionId(questionId);
+ }
+
+ public List getUrlListByFAQId(Long faqId) {
+ return faqImageRepository.findByFAQId(faqId);
+ }
+
+ public void deleteByQuestionId(Long questionId) {
+ questionImageRepository.deleteByQuestionId(questionId);
+ }
+
+ public void deleteByFAQId(Long faqId) {
+ faqImageRepository.deleteByFAQId(faqId);
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/qna/service/QuestionService.java b/back/src/main/java/com/example/capstone/domain/qna/service/QuestionService.java
new file mode 100644
index 0000000000..7ee4d8fdf9
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/qna/service/QuestionService.java
@@ -0,0 +1,57 @@
+package com.example.capstone.domain.qna.service;
+
+import com.example.capstone.domain.qna.dto.*;
+import com.example.capstone.domain.qna.entity.Question;
+import com.example.capstone.domain.qna.repository.AnswerRepository;
+import com.example.capstone.domain.qna.repository.QuestionRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class QuestionService {
+ private final QuestionRepository questionRepository;
+ private final AnswerRepository answerRepository;
+
+ public QuestionResponse createQuestion(String userId, QuestionPostRequest request) {
+ LocalDateTime current = LocalDateTime.now();
+ Question quest = questionRepository.save(Question.builder().title(request.title()).context(request.context())
+ .author(request.author()).createdDate(current).updatedDate(current).tag(request.tag())
+ .country(request.country()).uuid(UUID.fromString(userId)).build());
+
+ return quest.toDTO();
+ }
+
+ public QuestionResponse getQuestion(Long id) {
+ Question quest = questionRepository.findById(id).get();
+ return quest.toDTO();
+ }
+
+ @Transactional
+ public void updateQuestion(String userId, QuestionPutRequest request) {
+ LocalDateTime current = LocalDateTime.now();
+ Question quest = questionRepository.findById(request.id()).get();
+ quest.update(request.title(), request.context(), current);
+ }
+
+ public void eraseQuestion(Long id){
+ questionRepository.deleteById(id);
+ }
+
+ public QuestionSliceResponse getQuestionList(QuestionListRequest request) {
+ Pageable page = PageRequest.of(0, 20);
+ Long cursorId = request.cursorId();
+ if(cursorId == 0) cursorId = null;
+ QuestionSliceResponse questionList = questionRepository.getQuestionListByPaging(cursorId, page, request.word(), request.tag());
+
+ return questionList;
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/speech/WavStream.java b/back/src/main/java/com/example/capstone/domain/speech/WavStream.java
new file mode 100644
index 0000000000..3b772d4165
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/speech/WavStream.java
@@ -0,0 +1,139 @@
+package com.example.capstone.domain.speech;
+
+import com.microsoft.cognitiveservices.speech.audio.AudioStreamFormat;
+import com.microsoft.cognitiveservices.speech.audio.PullAudioInputStreamCallback;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+public class WavStream extends PullAudioInputStreamCallback {
+ private final InputStream stream;
+ private AudioStreamFormat format;
+
+ public WavStream(InputStream wavStream) {
+ try {
+ this.stream = parseWavHeader(wavStream);
+ } catch (Exception ex) {
+ throw new IllegalArgumentException(ex.getMessage());
+ }
+ }
+
+ @Override
+ public int read(byte[] dataBuffer) {
+ long ret = 0;
+
+ try {
+ ret = this.stream.read(dataBuffer, 0, dataBuffer.length);
+ } catch (Exception ex) {
+ System.out.println("Read " + ex);
+ }
+
+ return (int)Math.max(0, ret);
+ }
+
+ @Override
+ public void close() {
+ try {
+ this.stream.close();
+ } catch (IOException ex) {
+ // ignored
+ }
+ }
+
+ public AudioStreamFormat getFormat() {
+ return format;
+ }
+ // endregion
+
+ // region Wav File helper functions
+ private int ReadInt32(InputStream inputStream) throws IOException {
+ int n = 0;
+ for (int i = 0; i < 4; i++) {
+ n |= inputStream.read() << (i * 8);
+ }
+ return n;
+ }
+
+ private long ReadUInt32(InputStream inputStream) throws IOException {
+ long n = 0;
+ for (int i = 0; i < 4; i++) {
+ n |= inputStream.read() << (i * 8);
+ }
+ return n;
+ }
+
+ private int ReadUInt16(InputStream inputStream) throws IOException {
+ int n = 0;
+ for (int i = 0; i < 2; i++) {
+ n |= inputStream.read() << (i * 8);
+ }
+ return n;
+ }
+
+ private InputStream parseWavHeader(InputStream reader) throws IOException {
+ // Note: assumption about order of chunks
+ // Tag "RIFF"
+ byte data[] = new byte[4];
+ int numRead = reader.read(data, 0, 4);
+ ThrowIfFalse((numRead == 4) && (data[0] == 'R') && (data[1] == 'I') && (data[2] == 'F') && (data[3] == 'F'), "RIFF");
+
+ // Chunk size
+ /* int fileLength = */ReadInt32(reader);
+
+ // Subchunk, Wave Header
+ // Subchunk, Format
+ // Tag: "WAVE"
+ numRead = reader.read(data, 0, 4);
+ ThrowIfFalse((numRead == 4) && (data[0] == 'W') && (data[1] == 'A') && (data[2] == 'V') && (data[3] == 'E'), "WAVE");
+
+ // Tag: "fmt"
+ numRead = reader.read(data, 0, 4);
+ ThrowIfFalse((numRead == 4) && (data[0] == 'f') && (data[1] == 'm') && (data[2] == 't') && (data[3] == ' '), "fmt ");
+
+ // chunk format size
+ long formatSize = ReadInt32(reader);
+ ThrowIfFalse(formatSize >= 16, "formatSize");
+
+ int formatTag = ReadUInt16(reader);
+ int channels = ReadUInt16(reader);
+ int samplesPerSec = (int) ReadUInt32(reader);
+ @SuppressWarnings("unused")
+ int avgBytesPerSec = (int) ReadUInt32(reader);
+ @SuppressWarnings("unused")
+ int blockAlign = ReadUInt16(reader);
+ int bitsPerSample = ReadUInt16(reader);
+
+ // Speech SDK supports audio input streams in the following format: 8khz or 16khz sample rate, mono, 16 bit per sample
+ ThrowIfFalse(formatTag == 1, "PCM"); // PCM
+ ThrowIfFalse(channels == 1, "single channel");
+ ThrowIfFalse(samplesPerSec == 16000 || samplesPerSec == 8000, "samples per second");
+ ThrowIfFalse(bitsPerSample == 16, "bits per sample");
+
+ // Until now we have read 16 bytes in format, the rest is cbSize and is ignored
+ // for now.
+ if (formatSize > 16) {
+ numRead = reader.read(new byte[(int) (formatSize - 16)]);
+ ThrowIfFalse(numRead == (int)(formatSize - 16), "could not skip extended format");
+ }
+
+ // Second Chunk, data
+ // tag: data.
+ numRead = reader.read(data, 0, 4);
+ ThrowIfFalse((numRead == 4) && (data[0] == 'd') && (data[1] == 'a') && (data[2] == 't') && (data[3] == 'a'), "data");
+
+ // data chunk size
+ // Note: assumption is that only a single data chunk
+ /* int dataLength = */ReadInt32(reader);
+
+ // Save the stream format, as it may be needed later to configure an input stream to a recognizer
+ this.format = AudioStreamFormat.getWaveFormatPCM(samplesPerSec, (short)bitsPerSample, (short)channels);
+
+ return reader;
+ }
+
+ private static void ThrowIfFalse(Boolean condition, String message) {
+ if (!condition) {
+ throw new IllegalArgumentException(message);
+ }
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/speech/controller/SpeechController.java b/back/src/main/java/com/example/capstone/domain/speech/controller/SpeechController.java
new file mode 100644
index 0000000000..cc13e24e11
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/speech/controller/SpeechController.java
@@ -0,0 +1,37 @@
+package com.example.capstone.domain.speech.controller;
+
+import com.example.capstone.domain.speech.service.SpeechService;
+import com.example.capstone.global.dto.ApiResult;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+
+@Controller
+@RequestMapping("/api/speech")
+@RequiredArgsConstructor
+public class SpeechController {
+ private final SpeechService speechService;
+
+ @PostMapping(value = "/test",
+ consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+ @Operation(summary = "발음평가 매서드", description = "발음평가 음성파일(.wav)과 비교문을 통해 발음평가 결과를 반환합니다.")
+ @ApiResponse(responseCode = "200", description = "speech-text로 인식된 텍스트, 전체 텍스트 단위 평가 점수, 단어 종합 평가 점수, 각 단어별 평가 내용이 반환됩니다.")
+ public ResponseEntity>> uploadSpeech(@RequestPart("file") MultipartFile file, @RequestPart("context") String context)
+ throws ExecutionException, InterruptedException, IOException {
+ CompletableFuture> result = speechService.pronunciation(context, file);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully get speech result", result.get()));
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/speech/service/SpeechService.java b/back/src/main/java/com/example/capstone/domain/speech/service/SpeechService.java
new file mode 100644
index 0000000000..deed6fb119
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/speech/service/SpeechService.java
@@ -0,0 +1,240 @@
+package com.example.capstone.domain.speech.service;
+
+import com.example.capstone.domain.speech.WavStream;
+import com.microsoft.cognitiveservices.speech.*;
+import com.microsoft.cognitiveservices.speech.audio.*;
+import com.github.difflib.DiffUtils;
+import com.github.difflib.patch.AbstractDelta;
+import com.github.difflib.patch.DeltaType;
+import com.github.difflib.patch.Patch;
+import jakarta.json.Json;
+import jakarta.json.JsonArray;
+import jakarta.json.JsonObject;
+import jakarta.json.JsonReader;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringReader;
+import java.util.*;
+import java.util.concurrent.*;
+
+@Service
+@RequiredArgsConstructor
+public class SpeechService {
+ @Value("${azure.api.key}")
+ private String speechKey;
+ private String speechRegion = "koreacentral";
+ private String speechLang = "ko-KR";
+
+ private Semaphore stopRecognitionSemaphore;
+
+ @Async
+ public CompletableFuture> pronunciation(String compareText, MultipartFile file) throws InterruptedException, ExecutionException, IOException {
+ SpeechConfig speechConfig = SpeechConfig.fromSubscription(speechKey, speechRegion);
+
+ WavStream wavStream = new WavStream(file.getInputStream());
+ PullAudioInputStream inputStream = PullAudioInputStream.createPullStream(wavStream, wavStream.getFormat());
+
+ AudioConfig audioConfig = AudioConfig.fromStreamInput( inputStream );
+ stopRecognitionSemaphore = new Semaphore(0);
+ List recognizedWords = new ArrayList<>();
+ List pronWords = new ArrayList<>();
+ List finalWords = new ArrayList<>();
+ List fluencyScores = new ArrayList<>();
+ List durations = new ArrayList<>();
+
+ Map responseJson = new HashMap<>();
+
+ SpeechRecognizer recognizer = new SpeechRecognizer(speechConfig, speechLang, audioConfig);
+ {
+ recognizer.recognized.addEventListener((s, e) -> {
+ if (e.getResult().getReason() == ResultReason.RecognizedSpeech) {
+ System.out.println("RECOGNIZED: Text=" + e.getResult().getText());
+ PronunciationAssessmentResult pronResult = PronunciationAssessmentResult.fromResult(e.getResult());
+ System.out.println(
+ String.format(
+ " Accuracy score: %f, Prosody score: %f, Pronunciation score: %f, Completeness score : %f, FluencyScore: %f",
+ pronResult.getAccuracyScore(), pronResult.getProsodyScore(), pronResult.getPronunciationScore(),
+ pronResult.getCompletenessScore(), pronResult.getFluencyScore()));
+ responseJson.put("text", e.getResult().getText());
+ responseJson.put("accuracyScore", pronResult.getAccuracyScore());
+ responseJson.put("pronunciationScore", pronResult.getPronunciationScore());
+ responseJson.put("completenessScore", pronResult.getCompletenessScore());
+ responseJson.put("fluencyScore", pronResult.getFluencyScore());
+ fluencyScores.add(pronResult.getFluencyScore());
+ String jString = e.getResult().getProperties().getProperty(PropertyId.SpeechServiceResponse_JsonResult);
+ try {
+ JsonReader jsonReader = Json.createReader(new StringReader(jString));
+ JsonObject jsonObject = jsonReader.readObject();
+ JsonArray nBestArray = jsonObject.getJsonArray("NBest");
+
+ for (int i = 0; i < nBestArray.size(); i++) {
+ JsonObject nBestItem = nBestArray.getJsonObject(i);
+
+ JsonArray wordsArray = nBestItem.getJsonArray("Words");
+ long durationSum = 0;
+
+ for (int j = 0; j < wordsArray.size(); j++) {
+ JsonObject wordItem = wordsArray.getJsonObject(j);
+ recognizedWords.add(wordItem.getString("Word"));
+ durationSum += wordItem.getJsonNumber("Duration").longValue();
+
+ JsonObject pronAssessment = wordItem.getJsonObject("PronunciationAssessment");
+ pronWords.add(new Word(wordItem.getString("Word"), pronAssessment.getString("ErrorType"), pronAssessment.getJsonNumber("AccuracyScore").doubleValue()));
+ }
+ durations.add(durationSum);
+ jsonReader.close();
+ }
+ }
+ catch (Exception error){
+ System.out.println(error.getMessage());
+ }
+ }
+ else if (e.getResult().getReason() == ResultReason.NoMatch) {
+ System.out.println("NOMATCH: Speech could not be recognized.");
+ }
+ });
+
+ recognizer.canceled.addEventListener((s, e) -> {
+ System.out.println("CANCELED: Reason=" + e.getReason());
+
+ if (e.getReason() == CancellationReason.Error) {
+ System.out.println("CANCELED: ErrorCode=" + e.getErrorCode());
+ System.out.println("CANCELED: ErrorDetails=" + e.getErrorDetails());
+ System.out.println("CANCELED: Did you update the subscription info?");
+ }
+
+ stopRecognitionSemaphore.release();
+ });
+
+ recognizer.sessionStarted.addEventListener((s, e) -> {
+ System.out.println("\n Session started event.");
+ });
+
+ recognizer.sessionStopped.addEventListener((s, e) -> {
+ System.out.println("\n Session stopped event.");
+ });
+
+ boolean enableMiscue = true;
+ // 발음평가를 위해 참고할 원문
+ String referenceText = compareText;
+
+ PronunciationAssessmentConfig pronunciationAssessmentConfig = PronunciationAssessmentConfig.fromJson(String.format("{\"referenceText\":\"%s\",\"gradingSystem\":\"HundredMark\",\"granularity\":\"Word\",\"phonemeAlphabet\":\"IPA\"}", referenceText));
+ pronunciationAssessmentConfig.applyTo(recognizer);
+
+ recognizer.startContinuousRecognitionAsync().get();
+
+ stopRecognitionSemaphore.acquire();
+
+ recognizer.stopContinuousRecognitionAsync().get();
+
+ String[] referenceWords = referenceText.toLowerCase().split(" ");
+ for (int j = 0; j < referenceWords.length; j++) {
+ referenceWords[j] = referenceWords[j].replaceAll("^\\p{Punct}+|\\p{Punct}+$","");
+ }
+
+ if (enableMiscue) {
+ Patch diff = DiffUtils.diff(Arrays.asList(referenceWords), recognizedWords, true);
+
+ int currentIdx = 0;
+ for (AbstractDelta d : diff.getDeltas()) {
+ if (d.getType() == DeltaType.EQUAL) {
+ for (int i = currentIdx; i < currentIdx + d.getSource().size(); i++) {
+ finalWords.add(pronWords.get(i));
+ }
+ currentIdx += d.getTarget().size();
+ }
+ if (d.getType() == DeltaType.DELETE || d.getType() == DeltaType.CHANGE) {
+ for (String w : d.getSource().getLines()) {
+ finalWords.add(new Word(w, "Omission"));
+ }
+ }
+ if (d.getType() == DeltaType.INSERT || d.getType() == DeltaType.CHANGE) {
+ for (int i = currentIdx; i < currentIdx + d.getTarget().size(); i++) {
+ Word w = pronWords.get(i);
+ w.errorType = "Insertion";
+ finalWords.add(w);
+ }
+ currentIdx += d.getTarget().size();
+ }
+ }
+ }
+ else {
+ finalWords = pronWords;
+ }
+
+ double totalAccuracyScore = 0;
+ int accuracyCount = 0;
+ int validCount = 0;
+ for (Word word : finalWords) {
+ if (!"Insertion".equals(word.errorType)) {
+ totalAccuracyScore += word.accuracyScore;
+ accuracyCount += 1;
+ }
+
+ if ("None".equals(word.errorType)) {
+ validCount += 1;
+ }
+ }
+ double accuracyScore = Double.isNaN( totalAccuracyScore / accuracyCount) ? 0.0 : totalAccuracyScore / accuracyCount;
+
+ double fluencyScoreSum = 0;
+ long durationSum = 0;
+ for (int i = 0; i < durations.size(); i++) {
+ fluencyScoreSum += fluencyScores.get(i)*durations.get(i);
+ durationSum += durations.get(i);
+ }
+ double fluencyScore = Double.isNaN( fluencyScoreSum / durationSum) ? 0.0 : fluencyScoreSum / durationSum;
+
+ double completenessScore = Double.isNaN( (double)validCount / referenceWords.length * 100) ? 0.0 : (double)validCount / referenceWords.length * 100;
+ completenessScore = completenessScore <= 100 ? completenessScore : 100;
+
+ System.out.println("Paragraph accuracy score: " + accuracyScore +
+ ", completeness score: " +completenessScore +
+ " , fluency score: " + fluencyScore);
+ responseJson.put("paragraphAccuracy", accuracyScore);
+ responseJson.put("paragraphCompleteness", completenessScore);
+ responseJson.put("paragraphFluency", fluencyScore);
+
+ List> subWord = new ArrayList<>();
+
+ for (Word w : finalWords) {
+ System.out.println(" word: " + w.word + "\taccuracy score: " +
+ w.accuracyScore + "\terror type: " + w.errorType);
+ subWord.add(Map.of(
+ "word", w.word,
+ "errorType", w.errorType,
+ "accuracy", w.accuracyScore
+ ));
+ }
+ responseJson.put("wordList", subWord);
+ }
+ speechConfig.close();
+ audioConfig.close();
+ recognizer.close();
+ return CompletableFuture.completedFuture(responseJson);
+ }
+
+ public static class Word {
+ public String word;
+ public String errorType;
+ public double accuracyScore;
+ public Word(String word, String errorType) {
+ this.word = word;
+ this.errorType = errorType;
+ this.accuracyScore = 0;
+ }
+
+ public Word(String word, String errorType, double accuracyScore) {
+ this(word, errorType);
+ this.accuracyScore = accuracyScore;
+ }
+ }
+
+}
diff --git a/back/src/main/java/com/example/capstone/domain/user/controller/UserController.java b/back/src/main/java/com/example/capstone/domain/user/controller/UserController.java
new file mode 100644
index 0000000000..eb7c0f53d6
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/user/controller/UserController.java
@@ -0,0 +1,121 @@
+package com.example.capstone.domain.user.controller;
+
+import com.example.capstone.domain.auth.dto.TokenResponse;
+import com.example.capstone.domain.jwt.PrincipalDetails;
+import com.example.capstone.domain.user.dto.SigninRequest;
+import com.example.capstone.domain.user.dto.SignupRequest;
+import com.example.capstone.domain.user.dto.UserProfileUpdateRequest;
+import com.example.capstone.domain.user.entity.User;
+import com.example.capstone.domain.user.service.LoginService;
+import com.example.capstone.domain.user.service.UserService;
+import com.example.capstone.domain.user.util.UserMapper;
+import com.example.capstone.global.dto.ApiResult;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/user")
+@RequiredArgsConstructor
+public class UserController {
+ private final LoginService loginService;
+ private final UserService userService;
+
+ @PostMapping("/signup")
+ @Operation(summary = "회원가입", description = "FireBase로 인증된 유저를 회원가입 시킵니다.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "201", description = "회원가입 성공", useReturnTypeSchema = true),
+ @ApiResponse(responseCode = "400", description = "이미 존재하는 이메일", content = @Content(mediaType = "application/json")),
+ @ApiResponse(responseCode = "403", description = "HMAC 인증 실패", content = @Content(mediaType = "application/json"))
+ })
+ @ResponseStatus(HttpStatus.CREATED)
+ public ResponseEntity> signup(
+ @Parameter(description = "HMAC은 데이터 무결성을 위해서 반드시 Base64로 인코딩해서 보내야됩니다.", required = true)
+ @RequestHeader(name = "HMAC") String hmac,
+ @Parameter(description = "HMAC은 해당 Request의 Value들을 |로 구분자로 넣어서 만든 내용으로 만들면 됩니다.", required = true)
+ @RequestBody @Valid SignupRequest signupRequest) {
+ loginService.verifyHmac(hmac, signupRequest);
+ loginService.signUp(signupRequest);
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(new ApiResult<>("Successfully Signup", ""));
+ }
+
+ @PostMapping("/signin")
+ @Operation(summary = "로그인", description = "FireBase로 인증이 완료된 유저를 로그인 시키고 Token을 부여합니다.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "로그인 성공"),
+ @ApiResponse(responseCode = "400", description = "존재하지 않는 유저", content = @Content(mediaType = "application/json")),
+ @ApiResponse(responseCode = "403", description = "HMAC 인증 실패", content = @Content(mediaType = "application/json"))
+ })
+ public ResponseEntity> signin(@RequestHeader(name = "HMAC") String hmac,
+ @RequestBody @Valid SigninRequest signinRequest) {
+ loginService.verifyHmac(hmac, signinRequest);
+ TokenResponse response = loginService.signIn(signinRequest);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully Sign in", response));
+ }
+
+ @Operation(summary = "내 정보 받아오기", description = "내 정보를 받아옵니다.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "정보 받기 성공"),
+ @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰", content = @Content(mediaType = "application/json"))
+ })
+ @GetMapping("/me")
+ public ResponseEntity> getMyProfile(@AuthenticationPrincipal PrincipalDetails principalDetails) {
+ User user = UserMapper.INSTANCE.principalDetailsToUser(principalDetails);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully gey my info", user));
+ }
+
+
+ @PutMapping("/me")
+ @Operation(summary = "내 정보 수정하기", description = "내 정보를 수정합니다.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "정보 받기 성공"),
+ @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰", content = @Content(mediaType = "application/json")),
+ @ApiResponse(responseCode = "403", description = "권한 없음", content = @Content(mediaType = "application/json"))
+ })
+ public ResponseEntity> updateProfile(@AuthenticationPrincipal PrincipalDetails principalDetails,
+ @RequestBody @Valid final UserProfileUpdateRequest userProfileUpdateRequest) {
+ String UUID = principalDetails.getUuid();
+ User user = userService.updateUser(UUID, userProfileUpdateRequest);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully modify my info", user));
+ }
+
+ @GetMapping("/{userId}")
+ @Operation(summary = "특정 유저 정보 받기", description = "특정 유저 정보를 받아옵니다.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "정보 받기 성공"),
+ @ApiResponse(responseCode = "400", description = "존재하지 않는 유저", content = @Content(mediaType = "application/json")),
+ })
+ public ResponseEntity> getUserInfo(@PathVariable String userId) {
+ User user = userService.getUserInfo(userId);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully get user info", user));
+ }
+
+ @GetMapping("/test")
+ @Operation(summary = "토큰 내놔", description = "토큰 강제로 내놔.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "정보 받기 성공"),
+ @ApiResponse(responseCode = "400", description = "존재하지 않는 유저", content = @Content(mediaType = "application/json")),
+ })
+ public ResponseEntity> test(@RequestParam(value = "key") String key, @RequestParam(value = "id") String userId) {
+ loginService.testKeyCheck(key);
+ TokenResponse response = loginService.test(userId);
+ return ResponseEntity
+ .ok(new ApiResult<>("Successfully Sign in", response));
+ }
+}
\ No newline at end of file
diff --git a/back/src/main/java/com/example/capstone/domain/user/dto/SigninRequest.java b/back/src/main/java/com/example/capstone/domain/user/dto/SigninRequest.java
new file mode 100644
index 0000000000..a387b67e7c
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/user/dto/SigninRequest.java
@@ -0,0 +1,13 @@
+package com.example.capstone.domain.user.dto;
+
+import com.example.capstone.global.dto.HmacRequest;
+import jakarta.validation.constraints.Email;
+
+import java.util.UUID;
+
+public record SigninRequest(
+ String uuid,
+ @Email String email
+) implements HmacRequest {
+
+}
diff --git a/back/src/main/java/com/example/capstone/domain/auth/dto/SignupRequest.java b/back/src/main/java/com/example/capstone/domain/user/dto/SignupRequest.java
similarity index 60%
rename from back/src/main/java/com/example/capstone/domain/auth/dto/SignupRequest.java
rename to back/src/main/java/com/example/capstone/domain/user/dto/SignupRequest.java
index d750b43412..b8d32f3700 100644
--- a/back/src/main/java/com/example/capstone/domain/auth/dto/SignupRequest.java
+++ b/back/src/main/java/com/example/capstone/domain/user/dto/SignupRequest.java
@@ -1,5 +1,6 @@
-package com.example.capstone.domain.auth.dto;
+package com.example.capstone.domain.user.dto;
+import com.example.capstone.global.dto.HmacRequest;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
@@ -9,6 +10,8 @@ public record SignupRequest(
@Email String email,
@NotBlank String name,
@NotBlank String country,
- @Pattern(regexp = "[0-9]{10,11}") String phoneNumber,
+ @Pattern(regexp = "^010-\\d{4}-\\d{4}$") String phoneNumber,
@NotBlank String major
-){ }
+) implements HmacRequest {
+
+}
diff --git a/back/src/main/java/com/example/capstone/domain/user/dto/UserProfileUpdateRequest.java b/back/src/main/java/com/example/capstone/domain/user/dto/UserProfileUpdateRequest.java
new file mode 100644
index 0000000000..db8b89c64e
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/user/dto/UserProfileUpdateRequest.java
@@ -0,0 +1,12 @@
+package com.example.capstone.domain.user.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+
+public record UserProfileUpdateRequest(
+ @NotBlank String name,
+ @NotBlank String country,
+ @Pattern(regexp = "[0-9]{10,11}") String phoneNumber,
+ @NotBlank String major
+) {
+}
diff --git a/back/src/main/java/com/example/capstone/domain/user/entity/User.java b/back/src/main/java/com/example/capstone/domain/user/entity/User.java
index 0cfade8b46..07f705fa6f 100644
--- a/back/src/main/java/com/example/capstone/domain/user/entity/User.java
+++ b/back/src/main/java/com/example/capstone/domain/user/entity/User.java
@@ -1,12 +1,15 @@
package com.example.capstone.domain.user.entity;
-import jakarta.persistence.*;
+import com.example.capstone.global.entity.BaseTimeEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
-import java.util.UUID;
@Entity
@Table(name = "users")
@@ -14,7 +17,7 @@
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
-public class User {
+public class User extends BaseTimeEntity {
@Id
@Column(name = "user_id")
private String id;
@@ -37,22 +40,14 @@ public class User {
@Builder.Default
private String role = "ROLE_USER";
- @CreationTimestamp
- @Column(name = "create_date", nullable = false, updatable = false)
- private LocalDateTime createDate;
-
- @UpdateTimestamp
- @Column(name = "update_at", nullable = false)
- private LocalDateTime updateAt;
-
- public void updateProfile(String name, String major, String country, String phoneNumber){
+ public void updateProfile(String name, String major, String country, String phoneNumber) {
this.name = name;
this.major = major;
this.country = country;
this.phoneNumber = phoneNumber;
}
- public void setId(String id){
+ public void setId(String id) {
this.id = id;
}
}
diff --git a/back/src/main/java/com/example/capstone/domain/user/exception/AlreadyEmailExistException.java b/back/src/main/java/com/example/capstone/domain/user/exception/AlreadyEmailExistException.java
new file mode 100644
index 0000000000..a5a1280bb7
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/user/exception/AlreadyEmailExistException.java
@@ -0,0 +1,11 @@
+package com.example.capstone.domain.user.exception;
+
+import com.example.capstone.global.error.exception.InvalidValueException;
+
+import static com.example.capstone.global.error.exception.ErrorCode.ALREADY_EMAIL_EXIST;
+
+public class AlreadyEmailExistException extends InvalidValueException {
+ public AlreadyEmailExistException(final String email) {
+ super(email, ALREADY_EMAIL_EXIST);
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/user/exception/UserNotFoundException.java b/back/src/main/java/com/example/capstone/domain/user/exception/UserNotFoundException.java
new file mode 100644
index 0000000000..4b57e13882
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/user/exception/UserNotFoundException.java
@@ -0,0 +1,9 @@
+package com.example.capstone.domain.user.exception;
+
+import com.example.capstone.global.error.exception.EntityNotFoundException;
+
+public class UserNotFoundException extends EntityNotFoundException {
+ public UserNotFoundException(String target) {
+ super(target + " is not found");
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/user/service/LoginService.java b/back/src/main/java/com/example/capstone/domain/user/service/LoginService.java
new file mode 100644
index 0000000000..4da226b261
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/user/service/LoginService.java
@@ -0,0 +1,139 @@
+package com.example.capstone.domain.user.service;
+
+import com.example.capstone.domain.user.dto.SigninRequest;
+import com.example.capstone.domain.auth.dto.TokenResponse;
+import com.example.capstone.domain.user.dto.SignupRequest;
+import com.example.capstone.domain.user.dto.UserProfileUpdateRequest;
+import com.example.capstone.domain.user.exception.AlreadyEmailExistException;
+import com.example.capstone.domain.jwt.JwtTokenProvider;
+import com.example.capstone.domain.jwt.PrincipalDetails;
+import com.example.capstone.domain.user.entity.User;
+import com.example.capstone.domain.user.exception.UserNotFoundException;
+import com.example.capstone.domain.user.repository.UserRepository;
+import com.example.capstone.domain.user.util.UserMapper;
+import com.example.capstone.global.dto.HmacRequest;
+import com.example.capstone.global.error.exception.BusinessException;
+import com.example.capstone.global.error.exception.EntityNotFoundException;
+import com.example.capstone.global.error.exception.ErrorCode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import org.apache.coyote.Request;
+import org.apache.tomcat.util.buf.HexUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.crypto.Mac;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Objects;
+
+import static com.example.capstone.global.error.exception.ErrorCode.*;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class LoginService {
+ private final UserRepository userRepository;
+ private final JwtTokenProvider jwtTokenProvider;
+
+ @Value("${hmac.secret}")
+ private String key;
+
+ @Value("${hmac.algorithm}")
+ private String algorithm;
+
+ public void verifyHmac(String hmac, HmacRequest request) {
+ try{
+ ObjectMapper objectMapper = new ObjectMapper();
+ String hashed = calculateHMAC(objectMapper.writeValueAsString(request));
+ byte[] decodedBytes = Base64.getDecoder().decode(hmac);
+ String decoded = HexUtils.toHexString(decodedBytes);
+
+ if(!decoded.equals(hashed)) {
+ throw new BusinessException(HMAC_NOT_VALID);
+ }
+ }
+ catch (Exception e){
+ throw new BusinessException(HMAC_NOT_VALID);
+ }
+ }
+
+ private String calculateHMAC(String data) throws NoSuchAlgorithmException, InvalidKeyException {
+ byte[] decodedKey = Base64.getDecoder().decode(key);
+ SecretKey secretKey = new SecretKeySpec(decodedKey, algorithm);
+ Mac hasher = Mac.getInstance(algorithm);
+ hasher.init(secretKey);
+ byte[] rawHmac = hasher.doFinal(data.getBytes(StandardCharsets.UTF_8));
+ String hashed = HexUtils.toHexString(rawHmac);
+
+ return hashed;
+ }
+
+ public void emailExists(String email) {
+ boolean exist = userRepository.existsByEmail(email);
+ if (exist) throw new AlreadyEmailExistException(email);
+ }
+
+ @Transactional
+ public void signUp(SignupRequest dto) {
+ User user = UserMapper.INSTANCE.signupReqeustToUser(dto);
+ System.out.println(user.getId());
+ System.out.println(user.getEmail());
+ emailExists(user.getEmail());
+ userRepository.save(user);
+ }
+
+ @Transactional
+ public TokenResponse signIn(SigninRequest dto) {
+ String uuid = dto.uuid();
+ User user = userRepository.findUserById(uuid)
+ .orElseThrow(() -> new UserNotFoundException(uuid));
+
+ PrincipalDetails principalDetails = new PrincipalDetails(user.getId(),
+ user.getName(), user.getEmail(), user.getMajor(), user.getCountry(), user.getPhoneNumber(),
+ false, Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")));
+
+ String accessToken = jwtTokenProvider.createAccessToken(principalDetails);
+ String refreshToken = jwtTokenProvider.createRefreshToken(principalDetails);
+
+ return TokenResponse
+ .builder()
+ .accessToken(accessToken)
+ .refreshToken(refreshToken)
+ .build();
+ }
+
+ @Transactional
+ public TokenResponse test(String id) {
+ User user = userRepository.findUserById(id)
+ .orElseThrow(() -> new UserNotFoundException(id));
+
+ PrincipalDetails principalDetails = new PrincipalDetails(user.getId(),
+ user.getName(), user.getEmail(), user.getMajor(), user.getCountry(), user.getPhoneNumber(),
+ false, Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")));
+
+ String accessToken = jwtTokenProvider.createAccessToken(principalDetails);
+ String refreshToken = jwtTokenProvider.createRefreshToken(principalDetails);
+
+ return TokenResponse
+ .builder()
+ .accessToken(accessToken)
+ .refreshToken(refreshToken)
+ .build();
+ }
+
+ @Value("${test.key}")
+ private String testKey;
+
+ public boolean testKeyCheck(String key) {
+ if (key.equals(testKey)) return true;
+ else throw new BusinessException(TEST_KEY_NOT_VALID);
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/user/service/UserService.java b/back/src/main/java/com/example/capstone/domain/user/service/UserService.java
new file mode 100644
index 0000000000..b0ef764897
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/domain/user/service/UserService.java
@@ -0,0 +1,29 @@
+package com.example.capstone.domain.user.service;
+
+import com.example.capstone.domain.user.dto.UserProfileUpdateRequest;
+import com.example.capstone.domain.user.entity.User;
+import com.example.capstone.domain.user.exception.UserNotFoundException;
+import com.example.capstone.domain.user.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class UserService {
+ private final UserRepository userRepository;
+ @Transactional
+ public User updateUser(String UUID, UserProfileUpdateRequest dto){
+ User user = userRepository.findUserById(UUID)
+ .orElseThrow(() -> new UserNotFoundException(UUID));
+ user.updateProfile(dto.name(), dto.major(), dto.country(), dto.phoneNumber());
+ return user;
+ }
+
+ @Transactional
+ public User getUserInfo(String userId){
+ return userRepository.findUserById(userId)
+ .orElseThrow(() -> new UserNotFoundException(userId));
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/domain/user/util/UserMapper.java b/back/src/main/java/com/example/capstone/domain/user/util/UserMapper.java
index 02cf839ae9..730c2fc5dc 100644
--- a/back/src/main/java/com/example/capstone/domain/user/util/UserMapper.java
+++ b/back/src/main/java/com/example/capstone/domain/user/util/UserMapper.java
@@ -1,6 +1,7 @@
package com.example.capstone.domain.user.util;
-import com.example.capstone.domain.auth.dto.SignupRequest;
+import com.example.capstone.domain.jwt.PrincipalDetails;
+import com.example.capstone.domain.user.dto.SignupRequest;
import com.example.capstone.domain.user.entity.User;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@@ -11,5 +12,10 @@ public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
@Mapping(target = "id", source = "uuid")
+ @Mapping(target = "role", ignore = true)
User signupReqeustToUser(SignupRequest request);
+
+ @Mapping(target = "id", source = "uuid")
+ @Mapping(target = "role", ignore = true)
+ User principalDetailsToUser(PrincipalDetails principalDetails);
}
diff --git a/back/src/main/java/com/example/capstone/global/config/AsyncConfig.java b/back/src/main/java/com/example/capstone/global/config/AsyncConfig.java
new file mode 100644
index 0000000000..7a6fa2e329
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/global/config/AsyncConfig.java
@@ -0,0 +1,25 @@
+package com.example.capstone.global.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.AsyncConfigurer;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+@Configuration
+@EnableAsync
+public class AsyncConfig implements AsyncConfigurer {
+ @Override
+ public ThreadPoolTaskExecutor getAsyncExecutor(){
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(5);
+ executor.setQueueCapacity(20);
+ executor.setMaxPoolSize(50);
+ executor.setThreadNamePrefix("Executor-");
+ executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/global/config/QuerydslConfig.java b/back/src/main/java/com/example/capstone/global/config/QuerydslConfig.java
new file mode 100644
index 0000000000..6d6cdcdb62
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/global/config/QuerydslConfig.java
@@ -0,0 +1,20 @@
+package com.example.capstone.global.config;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+@Configuration
+public class QuerydslConfig {
+
+ @PersistenceContext
+ public EntityManager entityManager;
+
+ @Bean
+ public JPAQueryFactory jpaQueryFactory() {
+ return new JPAQueryFactory(this.entityManager);
+ }
+
+}
diff --git a/back/src/main/java/com/example/capstone/global/config/RedisConfig.java b/back/src/main/java/com/example/capstone/global/config/RedisConfig.java
new file mode 100644
index 0000000000..fe3cfb494a
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/global/config/RedisConfig.java
@@ -0,0 +1,36 @@
+package com.example.capstone.global.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+@Configuration
+public class RedisConfig {
+
+ @Value("${spring.data.redis.port}")
+ private int port;
+
+ @Value("${spring.data.redis.host}")
+ private String host;
+
+ @Bean
+ public RedisConnectionFactory redisConnectionFactory() {
+ return new LettuceConnectionFactory(host, port);
+ }
+
+
+ @Bean
+ @Primary
+ public RedisTemplate redisTemplate() {
+ RedisTemplate redisTemplate = new RedisTemplate<>();
+ redisTemplate.setKeySerializer(new StringRedisSerializer());
+ redisTemplate.setValueSerializer(new StringRedisSerializer());
+ redisTemplate.setConnectionFactory(redisConnectionFactory());
+ return redisTemplate;
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/global/config/S3Config.java b/back/src/main/java/com/example/capstone/global/config/S3Config.java
new file mode 100644
index 0000000000..c7c49f35f1
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/global/config/S3Config.java
@@ -0,0 +1,34 @@
+package com.example.capstone.global.config;
+
+import com.amazonaws.auth.AWSCredentials;
+import com.amazonaws.auth.AWSStaticCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.AmazonS3ClientBuilder;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class S3Config {
+ @Value("${s3.access.key}")
+ private String accessKey;
+
+ @Value("${s3.secret.key}")
+ private String secretKey;
+
+ @Value("${s3.region.static}")
+ private String region;
+
+ @Bean
+ public AmazonS3 amazonS3() {
+ AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
+
+ return AmazonS3ClientBuilder
+ .standard()
+ .withCredentials(new AWSStaticCredentialsProvider(credentials))
+ .withRegion(region)
+ .build();
+ }
+
+}
diff --git a/back/src/main/java/com/example/capstone/global/config/SecurityConfig.java b/back/src/main/java/com/example/capstone/global/config/SecurityConfig.java
index 372265d18f..09bfff4179 100644
--- a/back/src/main/java/com/example/capstone/global/config/SecurityConfig.java
+++ b/back/src/main/java/com/example/capstone/global/config/SecurityConfig.java
@@ -29,7 +29,6 @@ public class SecurityConfig {
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
.requestMatchers("/favicon.ico")
- .requestMatchers(PathRequest.toH2Console())
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
@@ -42,10 +41,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/favicon.ico").permitAll()
- .requestMatchers(PathRequest.toH2Console()).permitAll()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers( "/swagger-ui/**", "/v3/api-docs/**").permitAll()
+ .requestMatchers("/api/user/me").authenticated()
+ .requestMatchers("/api/user/**").permitAll()
+ .requestMatchers("/api/announcement/**").permitAll()
+ .requestMatchers("/api/auth/reissue").permitAll()
.requestMatchers("/api/auth/**").permitAll()
+ .requestMatchers("/api/menu/**").permitAll()
+ .requestMatchers("/api/speech/**").permitAll()
.anyRequest().permitAll())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception -> exception
diff --git a/back/src/main/java/com/example/capstone/global/config/SwaggerConfig.java b/back/src/main/java/com/example/capstone/global/config/SwaggerConfig.java
index 0b2596dba9..ecbb52ebe1 100644
--- a/back/src/main/java/com/example/capstone/global/config/SwaggerConfig.java
+++ b/back/src/main/java/com/example/capstone/global/config/SwaggerConfig.java
@@ -32,4 +32,4 @@ private Info apiInfo() {
.description("Capstone Project API Docs")
.version("1.0.0");
}
-}
+}
\ No newline at end of file
diff --git a/back/src/main/java/com/example/capstone/global/dto/ApiResult.java b/back/src/main/java/com/example/capstone/global/dto/ApiResult.java
new file mode 100644
index 0000000000..5aa64811a5
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/global/dto/ApiResult.java
@@ -0,0 +1,42 @@
+package com.example.capstone.global.dto;
+
+import com.example.capstone.global.error.exception.ErrorCode;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+@Builder
+@JsonPropertyOrder({"success", "code", "message", "response"})
+public class ApiResult {
+ private final boolean success;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private String message;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private String code;
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private T response;
+
+ public ApiResult(String message){
+ this.success = true;
+ this.message = message;
+ }
+
+ public ApiResult(String message, T response){
+ this.success = true;
+ this.message = message;
+ this.response = response;
+ }
+
+ public ApiResult(ErrorCode error){
+ this.success = false;
+ this.code = error.getCode();
+ this.message = error.getMessage();
+ }
+}
diff --git a/back/src/main/java/com/example/capstone/global/dto/HmacRequest.java b/back/src/main/java/com/example/capstone/global/dto/HmacRequest.java
new file mode 100644
index 0000000000..64da64a9bd
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/global/dto/HmacRequest.java
@@ -0,0 +1,5 @@
+package com.example.capstone.global.dto;
+
+public interface HmacRequest {
+
+}
diff --git a/back/src/main/java/com/example/capstone/global/entity/BaseTimeEntity.java b/back/src/main/java/com/example/capstone/global/entity/BaseTimeEntity.java
new file mode 100644
index 0000000000..8d952f3955
--- /dev/null
+++ b/back/src/main/java/com/example/capstone/global/entity/BaseTimeEntity.java
@@ -0,0 +1,24 @@
+package com.example.capstone.global.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Getter;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+
+@Getter
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+public abstract class BaseTimeEntity {
+
+ @CreatedDate
+ @Column(updatable = false)
+ private LocalDateTime createdDate;
+
+ @LastModifiedDate
+ private LocalDateTime modifiedDate;
+}
diff --git a/back/src/main/java/com/example/capstone/global/error/ErrorResponse.java b/back/src/main/java/com/example/capstone/global/error/ErrorResponse.java
deleted file mode 100644
index f18aafd436..0000000000
--- a/back/src/main/java/com/example/capstone/global/error/ErrorResponse.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.example.capstone.global.error;
-
-import com.example.capstone.global.error.exception.ErrorCode;
-import lombok.Builder;
-import lombok.Getter;
-import org.springframework.validation.BindingResult;
-
-@Getter
-@Builder
-public class ErrorResponse {
-
- private final int status;
- private final String code;
- private final String message;
-
- public static ErrorResponse of(final ErrorCode error) {
- return new ErrorResponse(error.getStatus(), error.getCode(), error.getMessage());
- }
-}
diff --git a/back/src/main/java/com/example/capstone/global/error/GlobalExceptionHandler.java b/back/src/main/java/com/example/capstone/global/error/GlobalExceptionHandler.java
index d9d106ec02..c906d941dd 100644
--- a/back/src/main/java/com/example/capstone/global/error/GlobalExceptionHandler.java
+++ b/back/src/main/java/com/example/capstone/global/error/GlobalExceptionHandler.java
@@ -2,10 +2,12 @@
import com.example.capstone.domain.jwt.JwtTokenProvider;
import com.example.capstone.domain.jwt.exception.JwtTokenInvalidException;
+import com.example.capstone.global.dto.ApiResult;
import com.example.capstone.global.error.exception.BusinessException;
import com.example.capstone.global.error.exception.ErrorCode;
import com.example.capstone.global.error.exception.InvalidValueException;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpRequestMethodNotSupportedException;
@@ -24,17 +26,21 @@ public class GlobalExceptionHandler {
* 지원하지 않은 HTTP method 호출 할 경우 발생합니다.
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
- protected ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
+ protected ResponseEntity> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
log.error("handleHttpRequestMethodNotSupportedException", e);
- final ErrorResponse response = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED);
- return new ResponseEntity<>(response, HttpStatus.METHOD_NOT_ALLOWED);
+ final ErrorCode errorCode = ErrorCode.METHOD_NOT_ALLOWED;
+ return ResponseEntity
+ .status(errorCode.getStatus())
+ .body(new ApiResult<>(errorCode));
}
@ExceptionHandler(AccessDeniedException.class)
- protected ResponseEntity handleAccessDeniedException(final AccessDeniedException e) {
+ protected ResponseEntity> handleAccessDeniedException(final AccessDeniedException e) {
log.error("handleAccessDeniedException", e);
- final ErrorResponse response = ErrorResponse.of(ErrorCode.HANDLE_ACCESS_DENIED);
- return new ResponseEntity<>(response, HttpStatus.valueOf(ErrorCode.HANDLE_ACCESS_DENIED.getStatus()));
+ final ErrorCode errorCode = ErrorCode.HANDLE_ACCESS_DENIED;
+ return ResponseEntity
+ .status(errorCode.getStatus())
+ .body(new ApiResult<>(errorCode));
}
/**
@@ -42,10 +48,12 @@ protected ResponseEntity handleAccessDeniedException(final Access
* Controller 단에서 발생하여 Error가 넘어옵니다.
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
- public ResponseEntity handleMethodArgumentNotValidException(final MethodArgumentNotValidException e) {
+ public ResponseEntity> handleMethodArgumentNotValidException(final MethodArgumentNotValidException e) {
log.error("handleMethodArgumentNotValidException", e);
- final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE);
- return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
+ final ErrorCode errorCode = ErrorCode.INVALID_JWT_TOKEN;
+ return ResponseEntity
+ .status(errorCode.getStatus())
+ .body(new ApiResult<>(errorCode));
}
/**
@@ -53,48 +61,63 @@ public ResponseEntity handleMethodArgumentNotValidException(final
* {@link JwtTokenProvider}에서 try catch에 의해 넘어옵니다.
*/
@ExceptionHandler(JwtTokenInvalidException.class)
- protected ResponseEntity handleJwtTokenInvalidException(final JwtTokenInvalidException e){
+ protected ResponseEntity> handleJwtTokenInvalidException(final JwtTokenInvalidException e){
log.error("handleJwtTokenInvalid", e);
final ErrorCode errorCode = e.getErrorCode();
- final ErrorResponse response = ErrorResponse.of(errorCode);
- return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
+ return ResponseEntity
+ .status(errorCode.getStatus())
+ .body(new ApiResult<>(errorCode));
+ }
+
+ @ExceptionHandler(RedisConnectionFailureException.class)
+ protected ResponseEntity> handleRedisConnectionFailureException(final RedisConnectionFailureException e){
+ log.error("handleJwtTokenInvalid", e);
+ final ErrorCode erroCode = ErrorCode.REDIS_CONNECTION_FAIL;
+ return ResponseEntity
+ .status(erroCode.getStatus())
+ .body(new ApiResult<>(erroCode));
}
@ExceptionHandler(InvalidValueException.class)
- protected ResponseEntity handleInvalidValueException(final InvalidValueException e){
+ protected ResponseEntity> handleInvalidValueException(final InvalidValueException e){
log.error("handleInvalidValueException", e);
log.error(e.getValue());
final ErrorCode errorCode = e.getErrorCode();
- final ErrorResponse response = ErrorResponse.of(errorCode);
- return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
+ return ResponseEntity
+ .status(errorCode.getStatus())
+ .body(new ApiResult<>(errorCode));
}
@ExceptionHandler(BusinessException.class)
- protected ResponseEntity handleBusinessException(final BusinessException e) {
+ protected ResponseEntity> handleBusinessException(final BusinessException e) {
log.error("handleBusinessException", e);
final ErrorCode errorCode = e.getErrorCode();
- final ErrorResponse response = ErrorResponse.of(errorCode);
- return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
+ return ResponseEntity
+ .status(errorCode.getStatus())
+ .body(new ApiResult<>(errorCode));
}
@ExceptionHandler(NoHandlerFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
- public ResponseEntity handle404(NoHandlerFoundException e){
+ protected ResponseEntity> handle404(NoHandlerFoundException e){
return ResponseEntity
- .status(e.getStatusCode())
- .body(ErrorResponse.builder()
- .status(e.getStatusCode().value())
+ .status(e.getStatusCode().value())
+ .body(ApiResult.builder()
+ .success(false)
.message(e.getMessage())
- .build());
+ .build()
+ );
}
/**
* 예상치 못한 오류들은 다 이 곳에서 처리됩니다.
*/
@ExceptionHandler(Exception.class)
- protected ResponseEntity handleUnExpectedException(final Exception e) {
+ protected ResponseEntity> handleUnExpectedException(final Exception e) {
log.error("handleUnExpectedException", e);
- final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
- return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
+ final ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
+ return ResponseEntity
+ .status(errorCode.getStatus())
+ .body(new ApiResult<>(errorCode));
}
}
diff --git a/back/src/main/java/com/example/capstone/global/error/JwtAuthenticationEntryPoint.java b/back/src/main/java/com/example/capstone/global/error/JwtAuthenticationEntryPoint.java
index 42fa23cfb5..195e7b2c49 100644
--- a/back/src/main/java/com/example/capstone/global/error/JwtAuthenticationEntryPoint.java
+++ b/back/src/main/java/com/example/capstone/global/error/JwtAuthenticationEntryPoint.java
@@ -1,21 +1,51 @@
package com.example.capstone.global.error;
+import com.example.capstone.domain.jwt.JwtAuthenticationFilter;
+import com.example.capstone.domain.jwt.exception.JwtTokenInvalidException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerExceptionResolver;
import java.io.IOException;
+import static com.example.capstone.domain.jwt.JwtAuthenticationFilter.EXCEPTION;
+
/**
- * UNAUTHORIZED (401) 응답
+ * Filter에서 발생한 오류는 ControllerAdvice단에서 잡을 수 없습니다.
+ * 따라서 JWT Token이 문제가 있어도 ExceptionHandling이 불가능합니다.
+ * 그래서 Resolver를 통해서 Handler로 넘겨주는 과정입니다.
+ *
+ * @link https://velog.io/@dltkdgns3435/%EC%8A%A4%ED%94%84%EB%A7%81%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-JWT-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC
*/
+@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ private final HandlerExceptionResolver resolver;
+
+ public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
+ this.resolver = resolver;
+ }
+
+
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
- // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
- response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ Exception exception = (Exception) request.getAttribute(EXCEPTION);
+ if (exception == null) return;
+
+ if (exception instanceof JwtTokenInvalidException jwtTokenInvalidException) {
+ resolver.resolveException(request, response, null, jwtTokenInvalidException);
+ } else if (exception instanceof RedisConnectionFailureException redisConnectionFailureException) {
+ resolver.resolveException(request, response, null, redisConnectionFailureException);
+ } else {
+ log.error("{}: {}", exception.getClass(), exception.getMessage());
+ resolver.resolveException(request, response, null, exception);
+ }
}
}
diff --git a/back/src/main/java/com/example/capstone/global/error/exception/EntityNotFoundException.java b/back/src/main/java/com/example/capstone/global/error/exception/EntityNotFoundException.java
index 57368944c4..3938c19231 100644
--- a/back/src/main/java/com/example/capstone/global/error/exception/EntityNotFoundException.java
+++ b/back/src/main/java/com/example/capstone/global/error/exception/EntityNotFoundException.java
@@ -2,7 +2,7 @@
public class EntityNotFoundException extends BusinessException {
- public EntityNotFoundException() {
- super(ErrorCode.ENTITY_NOT_FOUND);
+ public EntityNotFoundException(String message) {
+ super(message, ErrorCode.ENTITY_NOT_FOUND);
}
}
diff --git a/back/src/main/java/com/example/capstone/global/error/exception/ErrorCode.java b/back/src/main/java/com/example/capstone/global/error/exception/ErrorCode.java
index 1d54e59f70..dc6740790a 100644
--- a/back/src/main/java/com/example/capstone/global/error/exception/ErrorCode.java
+++ b/back/src/main/java/com/example/capstone/global/error/exception/ErrorCode.java
@@ -9,7 +9,7 @@ public enum ErrorCode {
// Common Error
INVALID_INPUT_VALUE(400, "C001", "Invalid Input Value"),
- METHOD_NOT_ALLOWED(405, "C002", "Invalid Input Value"),
+ METHOD_NOT_ALLOWED(405, "C002", "Method Not Allowed"),
ENTITY_NOT_FOUND(400, "C003", "Entity Not Found"),
INTERNAL_SERVER_ERROR(500, "C004", "Server Error"),
INVALID_TYPE_VALUE(400, "C005", "Invalid Type Value"),
@@ -17,10 +17,34 @@ public enum ErrorCode {
// JWT Error
INVALID_JWT_TOKEN(401, "J001", "Invalid JWT Token"),
+ NOT_EXIST_REFRESH_TOKEN(401, "J002", "Not Existing Refresh Token"),
// User Error
ALREADY_EMAIL_EXIST(400, "U001", "Already email exists"),
- USER_NOT_FOUND(400, "U002", "User Not Found")
+ USER_NOT_FOUND(400, "U002", "User Not Found"),
+
+ // Database Error
+ REDIS_CONNECTION_FAIL(400, "D001", "Redis Connection Failed"),
+
+ // Crawling Error
+ Crawling_FAIL(400, "CR001", "Crawling Failed"),
+
+ // TestKey Error
+ TEST_KEY_NOT_VALID(403, "T001", "Test Key is not valid"),
+
+ // S3 Error
+ EMPTY_FILE_EXCEPTION(400, "S301", "File is empty"),
+ IO_EXCEPTION_ON_IMAGE_UPLOAD(400, "S302", "Io exception on image"),
+ NO_FILE_EXTENTION(400, "S303", "Not found file"),
+ INVALID_FILE_EXTENTION(400, "S304", "File is invalid"),
+ PUT_OBJECT_EXCEPTION(400, "S305", "Object can not put"),
+ IO_EXCEPTION_ON_IMAGE_DELETE(400, "S306", "Io exception on image delete"),
+
+ // HMAC
+ HMAC_NOT_VALID(403, "HM001", "HMAC is not valid"),
+
+ // Search Error
+ SEARCH_TOO_SHORT(400, "S001", "Search key word is too short")
;
private int status;
diff --git a/back/src/main/resources/application.properties b/back/src/main/resources/application.properties
index 4003a78bdf..02070bf241 100644
--- a/back/src/main/resources/application.properties
+++ b/back/src/main/resources/application.properties
@@ -1,13 +1,39 @@
-spring.application.name=capstone
-
-spring.web.resources.add-mappings=false
-
-spring.datasource.url=jdbc:h2:mem:capstone
-spring.jpa.properties.hibernate.show_sql=true
-spring.jpa.properties.hibernate.format_sql=true
-
-jwt.secret=${JWT_SECRET}
-jwt.token.access-expiration-time =${JWT_ACCESS_EXPIRATION_TIME}
-
-logging.level.com.example.capstone=debug
-logging.level.org.springframework.security=trace
\ No newline at end of file
+spring.application.name=capstone
+
+spring.web.resources.add-mappings=false
+
+spring.datasource.url=jdbc:mysql://${DB_ENDPOINT}:${DB_PORT}/${DB_NAME}
+spring.datasource.username=${MYSQL_USERNAME}
+spring.datasource.password=${MYSQL_PASSWORD}
+spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
+spring.jpa.properties.hibernate.show_sql=true
+spring.jpa.properties.hibernate.format_sql=true
+spring.jpa.hibernate.ddl-auto=update
+
+spring.data.redis.host=${REDIS_HOST}
+spring.data.redis.port=${REDIS_PORT}
+
+jwt.secret=${JWT_SECRET}
+jwt.token.access-expiration-time=${JWT_ACCESS_EXPIRATION_TIME}
+jwt.token.refresh-expiration-time=${JWT_REFRESH_EXPIRATION_TIME}
+
+deepl.api.key=${DeepL_API_KEY}
+azure.api.key=${Azure_API_KEY}
+
+s3.access.key=${S3_ACCESS_KEY}
+s3.secret.key=${S3_SECRET_KEY}
+s3.bucket.name=capstone-30-backend
+s3.region.static=ap-northeast-2
+s3.stack.auto=false
+
+hmac.secret=${HMAC_SECRET}
+hmac.algorithm=${HMAC_ALGORITHM}
+
+test.key=${TEST_KEY}
+
+logging.level.com.example.capstone=debug
+logging.level.org.springframework.security=trace
+
+springdoc.swagger-ui.path=/api/swagger-ui.html
+springdoc.api-docs.path=/api/api-docs
+
diff --git a/back/src/test/java/com/example/capstone/BaseIntegrationTest.java b/back/src/test/java/com/example/capstone/BaseIntegrationTest.java
new file mode 100644
index 0000000000..371c8a52e7
--- /dev/null
+++ b/back/src/test/java/com/example/capstone/BaseIntegrationTest.java
@@ -0,0 +1,50 @@
+package com.example.capstone;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.MySQLContainer;
+import org.testcontainers.utility.DockerImageName;
+
+@SpringBootTest
+@Transactional
+@AutoConfigureMockMvc
+public abstract class BaseIntegrationTest {
+
+ @Autowired
+ protected MockMvc mockMvc;
+ @Autowired protected ObjectMapper objectMapper;
+
+ private static final String MYSQL_IMAGE = "mysql:8";
+ private static final String REDIS_IMAGE = "redis:7-alpine";
+
+ private static final MySQLContainer> mySqlContainer;
+
+ private static final GenericContainer> redisContainer;
+
+ static {
+ mySqlContainer = new MySQLContainer<>(MYSQL_IMAGE);
+ mySqlContainer.start();
+
+ redisContainer = new GenericContainer<>(DockerImageName.parse(REDIS_IMAGE))
+ .withExposedPorts(6379)
+ .withReuse(true);
+ redisContainer.start();
+ }
+
+ @DynamicPropertySource
+ private static void configureProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", mySqlContainer::getJdbcUrl);
+ registry.add("spring.datasource.username", mySqlContainer::getUsername);
+ registry.add("spring.datasource.password", mySqlContainer::getPassword);
+ registry.add("spring.data.redis.host", redisContainer::getHost);
+ registry.add("spring.data.redis.port", () -> String.valueOf(redisContainer.getMappedPort(6379)));
+ }
+}
+
diff --git a/back/src/test/java/com/example/capstone/CapstoneApplicationTests.java b/back/src/test/java/com/example/capstone/CapstoneApplicationTests.java
index 23d7a86d71..f0036cb09e 100644
--- a/back/src/test/java/com/example/capstone/CapstoneApplicationTests.java
+++ b/back/src/test/java/com/example/capstone/CapstoneApplicationTests.java
@@ -6,8 +6,4 @@
@SpringBootTest
class CapstoneApplicationTests {
- @Test
- void contextLoads() {
- }
-
}
diff --git a/back/src/test/java/com/example/capstone/domain/announcement/controller/AnnouncementControllerTest.java b/back/src/test/java/com/example/capstone/domain/announcement/controller/AnnouncementControllerTest.java
new file mode 100644
index 0000000000..36220aa2e5
--- /dev/null
+++ b/back/src/test/java/com/example/capstone/domain/announcement/controller/AnnouncementControllerTest.java
@@ -0,0 +1,11 @@
+package com.example.capstone.domain.announcement.controller;
+
+import com.example.capstone.BaseIntegrationTest;
+import org.junit.Before;
+import org.junit.jupiter.api.BeforeEach;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class AnnouncementControllerTest extends BaseIntegrationTest {
+
+}
\ No newline at end of file
diff --git a/back/src/test/java/com/example/capstone/domain/user/controller/UserControllerTest.java b/back/src/test/java/com/example/capstone/domain/user/controller/UserControllerTest.java
new file mode 100644
index 0000000000..dab1489ca7
--- /dev/null
+++ b/back/src/test/java/com/example/capstone/domain/user/controller/UserControllerTest.java
@@ -0,0 +1,167 @@
+package com.example.capstone.domain.user.controller;
+
+import com.example.capstone.BaseIntegrationTest;
+import com.example.capstone.domain.user.dto.SignupRequest;
+import com.example.capstone.domain.user.entity.User;
+import com.example.capstone.domain.user.repository.UserRepository;
+import org.apache.tomcat.util.buf.HexUtils;
+import org.junit.Before;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.transaction.BeforeTransaction;
+import org.springframework.test.web.servlet.ResultActions;
+
+import javax.crypto.Mac;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class UserControllerTest extends BaseIntegrationTest {
+
+ @Value("${HMAC_SECRET}")
+ private String key;
+
+ @Value("${HMAC_ALGORITHM}")
+ private String algorithm;
+
+ final String BASE_URI = "/api/user";
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @BeforeTransaction
+ @DisplayName("사전 유저 데이터 생성")
+ void set_user(){
+ final User user = User.builder()
+ .id("miku")
+ .email("dragonborn@naver.com")
+ .phoneNumber("010-1234-5678")
+ .major("skyrim")
+ .name("Hatsune Miku")
+ .country("Manchestor")
+ .build();
+
+ userRepository.save(user);
+ }
+
+ @Test
+ @DisplayName("회원가입 성공")
+ @WithMockUser
+ void sign_up_success() throws Exception {
+ //given
+ final SignupRequest request = new SignupRequest(
+ "qwrjkjdslkfjlkfs",
+ "hongildong@naver.com",
+ "Hong Gill Dong",
+ "North Korea",
+ "010-1234-5678",
+ "A O Ji"
+ );
+
+ String data = objectMapper.writeValueAsString(request);
+
+ byte[] decodedKey = Base64.getDecoder().decode(key);
+ SecretKey secretKey = new SecretKeySpec(decodedKey, algorithm);
+ Mac hasher = Mac.getInstance(algorithm);
+ hasher.init(secretKey);
+ byte[] rawHmac = hasher.doFinal(data.getBytes(StandardCharsets.UTF_8));
+ String HMAC = Base64.getEncoder().encodeToString(rawHmac);
+
+ //when
+ final ResultActions resultActions = mockMvc.perform(
+ post(BASE_URI + "/signup")
+ .content(data)
+ .contentType(MediaType.APPLICATION_JSON)
+ .header("HMAC", HMAC)
+ .accept(MediaType.APPLICATION_JSON)
+ );
+
+ resultActions
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.message").value("Successfully Signup"));
+ }
+
+ @Test
+ @DisplayName("HMAC 오류로 회원가입 실패")
+ @WithMockUser
+ void signup_fail_hmac() throws Exception {
+ //given
+ final SignupRequest request = new SignupRequest(
+ "qwrjkjdslkfjlkfs",
+ "hongildong@naver.com",
+ "Hong Gill Dong",
+ "North Korea",
+ "010-1234-5678",
+ "A O Ji"
+ );
+
+ String data = objectMapper.writeValueAsString(request);
+
+ byte[] decodedKey = Base64.getDecoder().decode(key);
+ SecretKey secretKey = new SecretKeySpec(decodedKey, algorithm);
+ Mac hasher = Mac.getInstance(algorithm);
+ hasher.init(secretKey);
+ byte[] rawHmac = hasher.doFinal(data.getBytes(StandardCharsets.UTF_8));
+ System.out.println(HexUtils.toHexString(rawHmac));
+ String HMAC = Base64.getEncoder().encodeToString(rawHmac);
+
+ //when
+ final ResultActions resultActions = mockMvc.perform(
+ post(BASE_URI + "/signup")
+ .content(data)
+ .contentType(MediaType.APPLICATION_JSON)
+ .header("HMAC", HMAC + "test")
+ .accept(MediaType.APPLICATION_JSON)
+ );
+
+ resultActions
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ @DisplayName("중복 회원 오류로 회원가입 실패")
+ @WithMockUser
+ void signup_fail_duplicate() throws Exception {
+ //given
+ final SignupRequest request = new SignupRequest(
+ "miku",
+ "dragonborn@naver.com",
+ "Hatsune Miku",
+ "Manchestor",
+ "010-1234-5678",
+ "skyrim"
+ );
+
+ String data = objectMapper.writeValueAsString(request);
+
+ byte[] decodedKey = Base64.getDecoder().decode(key);
+ SecretKey secretKey = new SecretKeySpec(decodedKey, algorithm);
+ Mac hasher = Mac.getInstance(algorithm);
+ hasher.init(secretKey);
+ byte[] rawHmac = hasher.doFinal(data.getBytes(StandardCharsets.UTF_8));
+ String HMAC = Base64.getEncoder().encodeToString(rawHmac);
+
+ //when
+ final ResultActions resultActions = mockMvc.perform(
+ post(BASE_URI + "/signup")
+ .content(data)
+ .contentType(MediaType.APPLICATION_JSON)
+ .header("HMAC", HMAC)
+ .accept(MediaType.APPLICATION_JSON)
+ );
+
+ resultActions
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("U001"));
+ }
+}
\ No newline at end of file
diff --git a/back/src/test/resources/application.properties b/back/src/test/resources/application.properties
new file mode 100644
index 0000000000..a79add455c
--- /dev/null
+++ b/back/src/test/resources/application.properties
@@ -0,0 +1,32 @@
+spring.application.name=capstone
+
+spring.web.resources.add-mappings=false
+spring.jpa.properties.hibernate.show_sql=true
+spring.jpa.properties.hibernate.format_sql=true
+spring.jpa.hibernate.ddl-auto=create-drop
+spring.data.redis.repositories.enabled=false
+
+jwt.secret=${JWT_SECRET}
+jwt.token.access-expiration-time=${JWT_ACCESS_EXPIRATION_TIME}
+jwt.token.refresh-expiration-time=${JWT_REFRESH_EXPIRATION_TIME}
+
+deepl.api.key=${DeepL_API_KEY}
+azure.api.key=${6471}
+
+hmac.secret=${HMAC_SECRET}
+hmac.algorithm=${HMAC_ALGORITHM}
+
+s3.access.key=${S3_ACCESS_KEY}
+s3.secret.key=${S3_SECRET_KEY}
+s3.bucket.name=capstone-30-backend
+s3.region.static=ap-northeast-2
+s3.stack.auto=false
+
+test.key=${TEST_KEY}
+
+logging.level.com.example.capstone=debug
+logging.level.org.springframework.security=trace
+
+springdoc.swagger-ui.path=/api/swagger-ui.html
+springdoc.api-docs.path=/api/api-docs
+