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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 +