diff --git a/.github/workflows/backend_pr_bot.yml b/.github/workflows/backend_pr_bot.yml new file mode 100644 index 00000000..2b7ecaa4 --- /dev/null +++ b/.github/workflows/backend_pr_bot.yml @@ -0,0 +1,67 @@ +name: Java Spring Build Checker + +on: + pull_request: + paths: + - 'backend/src/**' + - 'backend/build.gradle' + branches: + - backend + workflow_dispatch: + inputs: + comment: + description: '수동 trigger 사유' + default: 'ex) 서버 설정 변경 등' + +permissions: + checks: write + pull-requests: write + +jobs: + build: + name: Build check + runs-on: ubuntu-latest + env: + working-directory: backend + steps: + - name: Checkout the code + uses: actions/checkout@v3 + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + cache: 'gradle' + + - name: Setup MySQL + uses: mirromutth/mysql-action@v1.1 + with: + mysql database: 'techpick_db' + mysql user: ${{ secrets.DOCKER_MYSQL_USERNAME }} + mysql password: ${{ secrets.DOCKER_MYSQL_PASSWORD }} + +# - name: Make application.yaml +# run: | +# cd ./src/main/resources +# touch ./application.yaml +# echo "${{ secrets.APPLICATION_YAML }}" >> ./application.yaml +# shell: bash +# working-directory: ${{ env.working-directory }} + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + working-directory: ${{ env.working-directory }} + + - name: Build with Gradle + run: ./gradlew clean build --debug --exclude-task test + working-directory: ${{ env.working-directory }} \ No newline at end of file diff --git a/.github/workflows/test-server-deploy.yml b/.github/workflows/test-server-deploy.yml new file mode 100644 index 00000000..91f2aa52 --- /dev/null +++ b/.github/workflows/test-server-deploy.yml @@ -0,0 +1,122 @@ + +# Reference +# - (1) https://docs.docker.com/build/ci/github-actions/#examples +# - (2) https://github.com/Kernel360/E2E2-LOOK-US/blob/deploy/.github/workflows/deploy.yml + +name: Tech-pick V2 Testing Server Deploy + +on: + push: + branches: + - "backend-v2" # backend v2 브랜치 +env: + api-version: 'v2' # 브랜치 명과 반드시 동일할 것!!! + +jobs: + build: + runs-on: ubuntu-latest + steps: + - + # 저장소 Checkout + name: Checkout source code + uses: actions/checkout@v4 + - + # Gradle 실행 권한 부여 + name: Grant execute permission to gradlew + run: chmod +x ./backend/gradlew + - + # JDK 설치 + name: Set up JDK corretto:17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + - + # Spring boot application 빌드 + name: Build with gradle + run: | + cd ./backend + ./gradlew clean build -x test + - + # Docker image 빌드 + name: Build docker image + run: | + cd ./backend + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-${{ github.sha }} . + - + # Docker hub 로그인 + name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + # Docker hub 업로드 + name: Publish to docker hub + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-${{ github.sha }} + - + # 서버 ssh 접속 후 방금 올린 이미지 pull 받고 실행 + name: Deploy on Test-Server + uses: appleboy/ssh-action@master + with: + host: minlife.me # test home server + port: 4242 + username: root # root user + password: ${{ secrets.SSH_TEST_SERVER_KYEU_PASSWORD }} + script: | + + echo "login docker hub for private repository access ..." + echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + echo "docker - pulling..." + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-${{ github.sha }} + + echo "docker - changing image name and tag ..." + docker tag ${{ secrets.DOCKERHUB_USERNAME }}/techpick:${{ env.api-version }}-${{ github.sha }} techpick:${{ env.api-version }}-staging + + cd /home/project/techpick + + echo "create .env file with github repository's ENV setting ..." + + cd /home/project/techpick/database + rm -rf .env + touch .env + echo "DOCKER_MYSQL_USERNAME=${{ secrets.DOCKER_MYSQL_USERNAME }}" >> .env + echo "DOCKER_MYSQL_PASSWORD=${{ secrets.DOCKER_MYSQL_PASSWORD }}" >> .env + + cd /home/project/techpick/${{ env.api-version }} + rm -rf .env + touch .env + + echo "DOCKER_MYSQL_USERNAME=${{ secrets.DOCKER_MYSQL_USERNAME }}" >> .env + echo "DOCKER_MYSQL_PASSWORD=${{ secrets.DOCKER_MYSQL_PASSWORD }}" >> .env + echo "DOCKER_MYSQL_DATABASE=${{ secrets.DOCKER_MYSQL_DATABASE }}_${{ env.api-version }}" >> .env + echo "DOCKER_MYSQL_URL=jdbc:mysql://techpick-mysql:3306/${{ secrets.DOCKER_MYSQL_DATABASE }}_${{ env.api-version }}?createDatabaseIfNotExist=true" >> .env + echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env + echo "JWT_ISSUER=${{ secrets.JWT_ISSUER }}" >> .env + echo "GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" >> .env + echo "GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}" >> .env + echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env + echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" >> .env + echo "NAVER_CLIENT_ID=${{ secrets.NAVER_CLIENT_ID }}" >> .env + echo "NAVER_CLIENT_SECRET=${{ secrets.NAVER_CLIENT_SECRET }}" >> .env + echo "TECHPICK_BASE_URL=https://${{ env.api-version }}.${{ secrets.TECHPICK_DOMAIN }}" >> .env + + docker-compose down + docker compose up -d + + echo "docker - pruning images that passed 24h ..." + docker image prune -af --filter "until=24h" + - + # 배포 Action 실행 결과 깃허브 알림 + name: Discord Webhook Action + uses: sarisia/actions-status-discord@v1.14.7 + if: always() + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} + status: ${{ job.status }} + title: "Test API ${{ env.api-version }} Deployment Action Result" + description: "테스트 API ${{ env.api-version }} 서버 배포 완료" + color: 0xff91a4 + url: "https://github.com/sarisia/actions-status-discord" + username: GitHub Actions diff --git a/backend/.env.sample b/backend/.env.sample new file mode 100644 index 00000000..858071c3 --- /dev/null +++ b/backend/.env.sample @@ -0,0 +1,18 @@ +DOCKER_MYSQL_USERNAME=root +DOCKER_MYSQL_PASSWORD=1234 +DOCKER_MYSQL_DATABASE=TECHPICK_DB +DOCKER_MYSQL_URL=jdbc:mysql://localhost:3306/TECHPICK_DB?createDatabaseIfNotExist=true + +TECHPICK_BASE_URL=localhost:8080 + +JWT_ISSUER=TECHPICK +JWT_SECRET= + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +KAKAO_CLIENT_ID= +KAKAO_CLIENT_SECRET= + +NAVER_CLIENT_ID= +NAVER_CLIENT_SECRET= diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..4f088d4c --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,44 @@ +*.env +data/mysql-data + +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/ + +.DS_Store +._.DS_Store +**/.DS_Store +**/._.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..11171eec --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM amazoncorretto:17 + +LABEL authors="TechPick" + +ARG JAR_FILE=build/libs/*.jar + +WORKDIR /app + +COPY ${JAR_FILE} /app/app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "-Duser.timezone=Asia/Seoul", "/app/app.jar"] diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 00000000..422e1ecb --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,77 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.3' + id 'io.spring.dependency-management' version '1.1.6' +} + +group = 'kernel360' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + // spring boot + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.2' + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + runtimeOnly 'com.mysql:mysql-connector-j' + + // lombok annotation + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + // Sql logging formatter + // reference: https://www.baeldung.com/java-p6spy-intercept-sql-logging + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2' //이쁘게 + + // logback logger + implementation 'ch.qos.logback:logback-classic:1.4.12' + implementation 'org.slf4j:slf4j-api:2.0.3' + + // test environment + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.mysql:mysql-connector-j' + + // springdoc swagger dependency + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + // spring security and oauth client + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + testImplementation 'org.springframework.security:spring-security-test' + + // jwt + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' //xml 문서와 자바 객체 간 매핑을 자동화 + + // Thymeleaf 테스트 혹은 관리자에서 추후에 사용하기 위해서 추가 + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + // map struct + implementation 'org.mapstruct:mapstruct:1.5.3.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/backend/data/mysql-files/data.sql b/backend/data/mysql-files/data.sql new file mode 100644 index 00000000..e2328c9f --- /dev/null +++ b/backend/data/mysql-files/data.sql @@ -0,0 +1,9 @@ +-- BULK INSERT (STATIC DATA) + +LOAD DATA INFILE '/var/lib/mysql-files/develop/rss_blog.csv' + INTO TABLE rss_blog + FIELDS TERMINATED BY ',' + LINES TERMINATED BY '\n' + IGNORE 1 LINES + (@source_name, @url) + SET rss_feed_url = @url, created_at = now(), updated_at = now(); diff --git a/backend/data/mysql-files/rss_blog.csv b/backend/data/mysql-files/rss_blog.csv new file mode 100644 index 00000000..dfdd59e3 --- /dev/null +++ b/backend/data/mysql-files/rss_blog.csv @@ -0,0 +1,26 @@ +source_name,url +카카오페이,https://tech.kakaopay.com/rss +NHN Cloud,https://meetup.toast.com/rss +데브시스터즈,https://tech.devsisters.com/rss.xml +뱅크샐러드,https://blog.banksalad.com/rss.xml +마켓컬리,https://helloworld.kurly.com/feed +Gmarket,https://dev.gmarket.com/rss +무신사,https://medium.com/feed/musinsa-tech +여기어때,https://techblog.gccompany.co.kr/feed +올리브영,https://oliveyoung.tech/rss.xml +AWS,https://aws.amazon.com/ko/blogs/korea/feed/ +Toss,https://toss.tech/rss.xml +우아한형제들,https://techblog.woowahan.com/feed/ +카카오,https://tech.kakao.com/posts/feed +라인,https://techblog.lycorp.co.jp/ko/feed/index.xml +SK플래닛,https://techtopic.skplanet.com/rss +스마일게이트,https://smilegate.ai/feed/ +29CM,https://medium.com/feed/29cm +CJ OnStyle,https://medium.com/feed/cj-onstyle +브랜디,https://labs.brandi.co.kr/feed +넷마블,https://netmarble.engineering/rss +11번가,https://11st-tech.github.io/rss/ +원티드,https://medium.com/feed/wantedjobs +인프랩,https://tech.inflab.com/rss.xml +티빙,https://medium.com/feed/tving-team +리디,https://ridicorp.com/story-category/tech-blog/feed/ \ No newline at end of file diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml new file mode 100644 index 00000000..530ab130 --- /dev/null +++ b/backend/docker-compose.yaml @@ -0,0 +1,34 @@ +# 참고 설정 +# https://github.com/Kernel360/E2E2-TALKKA/blob/develop/docker-compose.yaml +# https://github.com/Kernel360/E2E2-LOOK-US/blob/develop/backend/docker-compose.yaml + +# Local 테스트 전용 Compose 입니다. +# 실행시 앱 이미지를 로컬에 빌드합니다. (배포 버전에서는 빌드하지 않고, 도커 허브에서 가져옴) + +# name: techpick-dev + +networks: + techpick-network: + driver: bridge + +services: + techpick-mysql: + image: mysql:8.0 + container_name: techpick-mysql + ports: + - "3306:3306" + env_file: + - .env + environment: + - MYSQL_ROOT_PASSWORD=${DOCKER_MYSQL_PASSWORD} + - MYSQL_DATABASE=${DOCKER_MYSQL_DATABASE} + - TZ=Asia/Seoul + volumes: + - ./data/develop/mysql-data:/var/lib/mysql + - ./data/develop/mysql-files:/var/lib/mysql-files/develop + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --lower_case_table_names=1 + networks: + - techpick-network \ No newline at end of file diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/backend/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..0aaefbca --- /dev/null +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/gradlew b/backend/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/backend/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 00000000..9d21a218 --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/project-setup/README.md b/backend/project-setup/README.md new file mode 100644 index 00000000..9deb3a81 --- /dev/null +++ b/backend/project-setup/README.md @@ -0,0 +1,21 @@ +### 팀 개발 환경 가이드 + +##### (0) JDK 버전 +- JDK : `amazoncorretto:17` + +##### (1) 포매터 설정 +- IntelliJ 설정 + - Code Formatter 설정 + - ./formatter 에 위치한 `naver-intellij-formatter.xml` 파일을 이용하여 설정 + - Settings -> CodeStyle -> Java -> Import Scheme + - CheckStyle 설정 + - Plugin CheckStyle 설치 + - ./formatter 에 위치한 `naver-checkstyle-rules.xml` 파일을 이용하여 설정 + - suppression file 의 경우 `./.idea/naver-checkstyle-suppresssions.xml` 지정 + - Format-on-save 기능 활성화 + - Settings -> Tools -> Actions on Save -> Reformat code 체크 + +##### (2) Env 설정 +1. `.env.sample` 파일로`.env`을 생성 합니다. +2. `run configuration` 설정에 아래와 같이 `.env` 파일을 추가 합니다. +![env-setup.png](env_setup_tutorial.png) diff --git a/backend/project-setup/env_setup_tutorial.png b/backend/project-setup/env_setup_tutorial.png new file mode 100644 index 00000000..3686f3a6 Binary files /dev/null and b/backend/project-setup/env_setup_tutorial.png differ diff --git a/backend/project-setup/naver-checkstyle-rules.xml b/backend/project-setup/naver-checkstyle-rules.xml new file mode 100644 index 00000000..2b160504 --- /dev/null +++ b/backend/project-setup/naver-checkstyle-rules.xml @@ -0,0 +1,439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/project-setup/naver-checkstyle-supperssions.xml b/backend/project-setup/naver-checkstyle-supperssions.xml new file mode 100644 index 00000000..a7b6fd1d --- /dev/null +++ b/backend/project-setup/naver-checkstyle-supperssions.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/backend/project-setup/naver-intellij-formatter.xml b/backend/project-setup/naver-intellij-formatter.xml new file mode 100644 index 00000000..78981540 --- /dev/null +++ b/backend/project-setup/naver-intellij-formatter.xml @@ -0,0 +1,62 @@ + + + diff --git a/backend/project-setup/rss_setting.md b/backend/project-setup/rss_setting.md new file mode 100644 index 00000000..22b0d139 --- /dev/null +++ b/backend/project-setup/rss_setting.md @@ -0,0 +1,11 @@ +### docker mysql 접속 + +1. docker exec -it techpick-mysql sh +2. mysql -u root -p +3. show databases; +4. use techpick_db; +5. show tables; + +### mysql에서 data.sql 파일 실행 및 rss csv 파일 insert + +6. source /var/lib/mysql-files/develop/data.sql; \ No newline at end of file diff --git a/backend/settings.gradle b/backend/settings.gradle new file mode 100644 index 00000000..0f5036dc --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'backend' diff --git a/backend/src/main/java/kernel360/techpick/TechpickApplication.java b/backend/src/main/java/kernel360/techpick/TechpickApplication.java new file mode 100644 index 00000000..56429f65 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/TechpickApplication.java @@ -0,0 +1,13 @@ +package kernel360.techpick; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TechpickApplication { + + public static void main(String[] args) { + SpringApplication.run(TechpickApplication.class, args); + } + +} diff --git a/backend/src/main/java/kernel360/techpick/core/config/JpaAuditingConfig.java b/backend/src/main/java/kernel360/techpick/core/config/JpaAuditingConfig.java new file mode 100644 index 00000000..bb57a2ac --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package kernel360.techpick.core.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/backend/src/main/java/kernel360/techpick/core/config/OAuth2AttributeConfigProvider.java b/backend/src/main/java/kernel360/techpick/core/config/OAuth2AttributeConfigProvider.java new file mode 100644 index 00000000..ba3999c5 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/config/OAuth2AttributeConfigProvider.java @@ -0,0 +1,21 @@ +package kernel360.techpick.core.config; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; + +@Component +@ConfigurationProperties("oauth2-attribute-config-provider") +@Getter // yaml 값을 getter 로 주입.. +public class OAuth2AttributeConfigProvider { + + private final Map> attributeConfig = new HashMap<>(); + + public Map getAttributeConfig(String providerId) { + return attributeConfig.get(providerId); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/config/P6SpySqlLoggerConfig.java b/backend/src/main/java/kernel360/techpick/core/config/P6SpySqlLoggerConfig.java new file mode 100644 index 00000000..32cb24ff --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/config/P6SpySqlLoggerConfig.java @@ -0,0 +1,64 @@ +package kernel360.techpick.core.config; + +import java.sql.SQLException; +import java.util.Locale; + +import static org.springframework.util.StringUtils.hasText; + +import org.hibernate.engine.jdbc.internal.FormatStyle; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import com.p6spy.engine.common.ConnectionInformation; +import com.p6spy.engine.event.JdbcEventListener; +import com.p6spy.engine.logging.Category; +import com.p6spy.engine.spy.P6SpyOptions; +import com.p6spy.engine.spy.appender.MessageFormattingStrategy; + +/** + * Jdbc가 DB Connection을 얻은 이후에 로깅 포맷을 P6SpyOptions가 가로채도록 하는 Bean입니다. + */ +@Profile({"default", "local", "dev"}) // WARN: Do not use in production mode. +@Component +public class P6SpySqlLoggerConfig extends JdbcEventListener implements MessageFormattingStrategy { + + @Override + public void onAfterGetConnection(ConnectionInformation connectionInformation, SQLException e) { + P6SpyOptions.getActiveInstance().setLogMessageFormat(this.getClass().getName()); + } + + @Override + public String formatMessage(int connectionId, String now, long elapsed, String category, + String prepared, String sql, String url) { + return highlight(format(category, sql)); + } + + private String highlight(String sql) { + return FormatStyle.HIGHLIGHT.getFormatter().format(sql); + } + + private String format(String category, String sql) { + if (hasText(sql) && isStatement(category)) { + if (isDdl(trim(sql))) { + return FormatStyle.DDL.getFormatter().format(sql); + } + return FormatStyle.BASIC.getFormatter().format(sql); + } + return sql; + } + + private static boolean isDdl(String trimmedSql) { + return trimmedSql.startsWith("create") + || trimmedSql.startsWith("alter") + || trimmedSql.startsWith("drop") + || trimmedSql.startsWith("comment"); + } + + private static String trim(String sql) { + return sql.trim().toLowerCase(Locale.ROOT); + } + + private static boolean isStatement(String category) { + return Category.STATEMENT.getName().equals(category); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/config/PasswordEncoderConfig.java b/backend/src/main/java/kernel360/techpick/core/config/PasswordEncoderConfig.java new file mode 100644 index 00000000..6d9754d9 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/config/PasswordEncoderConfig.java @@ -0,0 +1,13 @@ +package kernel360.techpick.core.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/config/RestTemplateConfig.java b/backend/src/main/java/kernel360/techpick/core/config/RestTemplateConfig.java new file mode 100644 index 00000000..436a895b --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/config/RestTemplateConfig.java @@ -0,0 +1,23 @@ +package kernel360.techpick.core.config; + +import java.time.Duration; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .additionalMessageConverters(new MappingJackson2XmlHttpMessageConverter()) + .defaultHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(5)) + .build(); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/config/SchedulingConfig.java b/backend/src/main/java/kernel360/techpick/core/config/SchedulingConfig.java new file mode 100644 index 00000000..5c7ec403 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package kernel360.techpick.core.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/backend/src/main/java/kernel360/techpick/core/config/SecurityConfig.java b/backend/src/main/java/kernel360/techpick/core/config/SecurityConfig.java new file mode 100644 index 00000000..64b794fa --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/config/SecurityConfig.java @@ -0,0 +1,77 @@ +package kernel360.techpick.core.config; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + @Value("${api.base-url}") + private String baseUrl; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // TODO: 이후 설정 추가 필요 + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests( + authRequest -> authRequest + .anyRequest().permitAll() + ) + .oauth2Login( + oauth -> oauth + .authorizationEndpoint(authorization -> authorization + .baseUri("/api/login") + // /* 붙이면 안됨 + ) + .redirectionEndpoint( + redirection -> redirection + .baseUri("/api/login/oauth2/code/*") + // 반드시 /* 으로 {registrationId}를 받아야 함 스프링 시큐리티의 문제!! + // https://github.com/spring-projects/spring-security/issues/13251 + ) + // .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2Service)) + // .successHandler(oAuth2SuccessHandler) + ) + ; + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); + config.setAllowedOrigins(List.of( + baseUrl, /* from env */ + "https://local.minlife.me:3000" /* Frontend Local */, + "chrome-extension://nijonkmmpngclkmeddmgjgdhjefmnmbm" /* Chrome Extension */ + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("*")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/config/SwaggerConfig.java b/backend/src/main/java/kernel360/techpick/core/config/SwaggerConfig.java new file mode 100644 index 00000000..a60bf1d2 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/config/SwaggerConfig.java @@ -0,0 +1,54 @@ +package kernel360.techpick.core.config; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +@Configuration +public class SwaggerConfig { + + @Value("${api.base-url}") + private String baseUrl; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(apiInfo()) + .components(new Components() + .addSecuritySchemes("basicAuth", securityScheme()) + ) + .servers(List.of(getServer())); + } + + private Info apiInfo() { + return new Info() + .title("TechPick API") + .description("TechPick API 명세서") + .version("1.0.0"); + } + + /** + * Swagger Security 설정 추가 + * Authentication 방식을 OpenAPI 에 추가 + */ + private SecurityScheme securityScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .name("access_token") + .in(SecurityScheme.In.COOKIE); + } + + private Server getServer() { + // TODO: AWS배포 이후 local prod에 따라 다른 url 적용하도록 리팩토링 필요 + // 현재는 홈서버 반환 + return new Server().url(baseUrl); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorBody.java b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorBody.java new file mode 100644 index 00000000..25791ab3 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorBody.java @@ -0,0 +1,6 @@ +package kernel360.techpick.core.exception.base; + +public record ApiErrorBody( + String code, + String message +) {} diff --git a/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorCode.java b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorCode.java new file mode 100644 index 00000000..12b863fa --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorCode.java @@ -0,0 +1,20 @@ +package kernel360.techpick.core.exception.base; + +import org.springframework.http.HttpStatus; + +import kernel360.techpick.core.exception.level.ErrorLevel; + +public interface ApiErrorCode { + + String getCode(); + + String getMessage(); + + HttpStatus getHttpStatus(); + + ErrorLevel getErrorLevel(); + + default String convertCodeToString(ApiErrorCode errorCode) { + return String.format("[ 에러 코드 %s : %s ]", errorCode.getCode(), errorCode.getMessage()); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorResponse.java b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorResponse.java new file mode 100644 index 00000000..e8063e4d --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiErrorResponse.java @@ -0,0 +1,26 @@ +package kernel360.techpick.core.exception.base; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class ApiErrorResponse extends ResponseEntity { + + private ApiErrorResponse(ApiErrorCode apiErrorCode) { + super( + new ApiErrorBody(apiErrorCode.getCode(), apiErrorCode.getMessage()), + apiErrorCode.getHttpStatus() + ); + } + + private ApiErrorResponse(String code, String message, HttpStatus status) { + super(new ApiErrorBody(code, message), status); + } + + public static ApiErrorResponse of(ApiErrorCode apiErrorCode) { + return new ApiErrorResponse(apiErrorCode); + } + + public static ApiErrorResponse UNKNOWN_SERVER_ERROR() { + return new ApiErrorResponse("UNKNOWN", "미확인 서버 에러", HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/exception/base/ApiException.java b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiException.java new file mode 100644 index 00000000..1ad30181 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiException.java @@ -0,0 +1,19 @@ +package kernel360.techpick.core.exception.base; + +public abstract class ApiException extends RuntimeException { + + private final ApiErrorCode errorCode; + + protected ApiException(ApiErrorCode errorCode) { + super(errorCode.toString()); + this.errorCode = errorCode; + } + + public final ApiErrorCode getApiErrorCode() { + return errorCode; + } + + public final void handleErrorByLevel() { + errorCode.getErrorLevel().handleError(this); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/exception/base/ApiExceptionHandler.java b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiExceptionHandler.java new file mode 100644 index 00000000..2ee48ae0 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/exception/base/ApiExceptionHandler.java @@ -0,0 +1,35 @@ +package kernel360.techpick.core.exception.base; + +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import kernel360.techpick.core.exception.level.ErrorLevel; +import lombok.extern.slf4j.Slf4j; + +@RestControllerAdvice +@Slf4j +public class ApiExceptionHandler { + + /** + * ApiException 에서 잡지 못한 예외는 + * 5xx 코드 오류 입니다. + */ + @ExceptionHandler(Exception.class) + public ApiErrorResponse handleGlobalException(Exception exception) { + + ErrorLevel.MUST_NEVER_HAPPEN().handleError(exception); + + return ApiErrorResponse.UNKNOWN_SERVER_ERROR(); + } + + /** + * ApiException 을 공통 Response 형태로 변환 합니다. + */ + @ExceptionHandler(ApiException.class) + public ApiErrorResponse handleApiException(ApiException exception) { + + exception.handleErrorByLevel(); + + return ApiErrorResponse.of(exception.getApiErrorCode()); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/exception/level/ErrorLevel.java b/backend/src/main/java/kernel360/techpick/core/exception/level/ErrorLevel.java new file mode 100644 index 00000000..e9b4ba97 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/exception/level/ErrorLevel.java @@ -0,0 +1,39 @@ +package kernel360.techpick.core.exception.level; + +import kernel360.techpick.core.exception.base.ApiException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class ErrorLevel { + + /** + * (1) 정상 예외 이며, 따로, 대응 하지 않아도 되는 예외. + * - 서버가 잘못된 사용자 요청을 잘 처리한 경우 + */ + public static ErrorLevel CAN_HAPPEN() { + return new NormalErrorLevel(); + } + + /** + * (2) 대응 하지 않아도 되지만, 예의 주시 해야 하는 예외. + * - 정상 운영은 되지만, 애초에 그 요청이 발생 해선 안되는 경우. Ex. 프론트 엔드 버그 + */ + public static ErrorLevel SHOULD_NOT_HAPPEN() { + return new WarningErrorLevel(); + } + + /** + * (3) 즉시 대응이 필요한 예외 + * - 운영환경 에서 절대 발생해선 안되며, 발생 시 서버를 즉시 종료해야 하는 경우. + */ + public static ErrorLevel MUST_NEVER_HAPPEN() { + return new FatalErrorLevel(); + } + + /* Must be implemented per error level */ + public abstract void handleError(ApiException exception); + + public final void handleError(Exception exception) { + log.error(exception.getMessage(), exception); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/exception/level/FatalErrorLevel.java b/backend/src/main/java/kernel360/techpick/core/exception/level/FatalErrorLevel.java new file mode 100644 index 00000000..4e495241 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/exception/level/FatalErrorLevel.java @@ -0,0 +1,13 @@ +package kernel360.techpick.core.exception.level; + +import kernel360.techpick.core.exception.base.ApiException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class FatalErrorLevel extends ErrorLevel { + + @Override + public void handleError(ApiException exception) { + log.error(exception.getMessage(), exception); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/exception/level/NormalErrorLevel.java b/backend/src/main/java/kernel360/techpick/core/exception/level/NormalErrorLevel.java new file mode 100644 index 00000000..2a80fce4 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/exception/level/NormalErrorLevel.java @@ -0,0 +1,13 @@ +package kernel360.techpick.core.exception.level; + +import kernel360.techpick.core.exception.base.ApiException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class NormalErrorLevel extends ErrorLevel { + + @Override + public void handleError(ApiException exception) { + log.info(exception.getMessage()); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/exception/level/WarningErrorLevel.java b/backend/src/main/java/kernel360/techpick/core/exception/level/WarningErrorLevel.java new file mode 100644 index 00000000..aa49c773 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/exception/level/WarningErrorLevel.java @@ -0,0 +1,13 @@ +package kernel360.techpick.core.exception.level; + +import kernel360.techpick.core.exception.base.ApiException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class WarningErrorLevel extends ErrorLevel { + + @Override + public void handleError(ApiException exception) { + log.warn(exception.getMessage(), exception); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/common/BaseEntity.java b/backend/src/main/java/kernel360/techpick/core/model/common/BaseEntity.java new file mode 100644 index 00000000..ac69a0bb --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/common/BaseEntity.java @@ -0,0 +1,28 @@ +package kernel360.techpick.core.model.common; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + // 생성 시간 자동 부여 + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + protected LocalDateTime createdAt; + + // 수정 시간 자동 부여 + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + protected LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/folder/Folder.java b/backend/src/main/java/kernel360/techpick/core/model/folder/Folder.java new file mode 100644 index 00000000..62e6243f --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/folder/Folder.java @@ -0,0 +1,95 @@ +package kernel360.techpick.core.model.folder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import kernel360.techpick.core.model.common.BaseEntity; +import kernel360.techpick.core.model.user.User; +import kernel360.techpick.core.util.OrderConverter; +import kernel360.techpick.feature.domain.folder.exception.ApiFolderException; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "folder") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Folder extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "folder_type", nullable = false) + private FolderType folderType; + + @ManyToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.CASCADE) // 부모 폴더가 삭제 되면 자식 폴더 또한 삭제 + @JoinColumn(name = "parent_folder_id") + private Folder parentFolder; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + // 폴더에 속한 자식 folder id들을 공백으로 분리된 String으로 변환하여 db에 저장 + // ex) [6,3,2,23,1] -> "6 3 2 23 1" + @Convert(converter = OrderConverter.class) + @Column(name = "child_folder_order", columnDefinition = "longblob", nullable = false) + private List childFolderOrderList; + + // 폴더에 속한 pick id들을 공백으로 분리된 String으로 변환하여 db에 저장 + // ex) [6,3,2,23,1] -> "6 3 2 23 1" + @Convert(converter = OrderConverter.class) + @Column(name = "pick_order", columnDefinition = "longblob", nullable = false) + private List childPickOrderList; + + public void updateChildPickOrder(Long pickId, int destination) { + childPickOrderList.remove(pickId); + childPickOrderList.add(destination, pickId); + } + + public void removeChildPickOrder(Long pickId) { + this.childPickOrderList.remove(pickId); + } + + @Builder + private Folder( + String name, + FolderType folderType, + Folder parentFolder, + User user, + List childFolderOrderList, + List childPickOrderList + ) { + this.name = name; + this.folderType = folderType; + this.parentFolder = parentFolder; + this.user = user; + this.childFolderOrderList = childFolderOrderList; + this.childPickOrderList = childPickOrderList; + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/folder/FolderRepository.java b/backend/src/main/java/kernel360/techpick/core/model/folder/FolderRepository.java new file mode 100644 index 00000000..c01f4494 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/folder/FolderRepository.java @@ -0,0 +1,6 @@ +package kernel360.techpick.core.model.folder; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FolderRepository extends JpaRepository { +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/folder/FolderType.java b/backend/src/main/java/kernel360/techpick/core/model/folder/FolderType.java new file mode 100644 index 00000000..377dac5a --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/folder/FolderType.java @@ -0,0 +1,30 @@ +package kernel360.techpick.core.model.folder; + +import java.util.EnumSet; + +public enum FolderType { + + UNCLASSIFIED("미분류 폴더"), + RECYCLE_BIN("휴지통 폴더"), + ROOT("루트 폴더"), + GENERAL("일반 폴더"), + ; + + private final String label; + + FolderType(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + + public static EnumSet getBasicFolderTypes() { + return EnumSet.of(UNCLASSIFIED, RECYCLE_BIN, ROOT); + } + + public static EnumSet getUnclassifiedFolderTypes() { + return EnumSet.of(UNCLASSIFIED); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/link/Link.java b/backend/src/main/java/kernel360/techpick/core/model/link/Link.java new file mode 100644 index 00000000..f548d955 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/link/Link.java @@ -0,0 +1,71 @@ +package kernel360.techpick.core.model.link; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "link") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Link { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // URL + // TODO: VARCHAR 최대 크기를 몇으로 할지 토의 필요합니다. + // The index key prefix length limit is 3072 bytes for InnoDB -> VARCHAR(1000) + utf8 = 4000byte + // 일단 medium 기준 가장 길었던 url 320 글자의 약 2배인 VARCHAR(600)으로 변경 + // Techpick 노션 기술 부채에 VARCHAR, TEXT 부분 참고. + @Column(name = "url", nullable = false, columnDefinition = "VARCHAR(600)", unique = true) + private String url; + + @Column(name = "title", columnDefinition = "VARCHAR(100)") + private String title; + + @Column(name = "description", columnDefinition = "VARCHAR(600)") + private String description; + + @Column(name = "imageUrl", columnDefinition = "VARCHAR(600)") + private String imageUrl; + + @Column(name = "invalidatedAt_at") + private LocalDateTime invalidatedAt; + + public Link updateMetadata(String title, String description, String imageUrl) { + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + return this; + } + + public Link markAsInvalid() { + this.invalidatedAt = LocalDateTime.now(); + return this; + } + + public boolean isValidLink() { + return (this.invalidatedAt == null); + } + + @Builder + private Link(String url, String title, String description, String imageUrl, LocalDateTime invalidatedAt) { + this.url = url; + this.title = title; + this.description = description; + this.imageUrl = imageUrl; + this.invalidatedAt = invalidatedAt; + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/link/LinkRepository.java b/backend/src/main/java/kernel360/techpick/core/model/link/LinkRepository.java new file mode 100644 index 00000000..107ddab6 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/link/LinkRepository.java @@ -0,0 +1,10 @@ +package kernel360.techpick.core.model.link; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LinkRepository extends JpaRepository { + + Optional findByUrl(String url); +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/pick/Pick.java b/backend/src/main/java/kernel360/techpick/core/model/pick/Pick.java new file mode 100644 index 00000000..f67f878b --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/pick/Pick.java @@ -0,0 +1,102 @@ +package kernel360.techpick.core.model.pick; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import kernel360.techpick.core.model.common.BaseEntity; +import kernel360.techpick.core.model.folder.Folder; +import kernel360.techpick.core.model.link.Link; +import kernel360.techpick.core.model.user.User; +import kernel360.techpick.core.util.OrderConverter; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "pick") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Pick extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 사용자 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + // 북마크 대상 링크 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "link_id", nullable = false) + private Link link; + + // 부모 폴더가 삭제되면 자식픽 또한 삭제됨, OnDelete 옵션을 위해 FK필요 + @ManyToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "parent_folder_id", nullable = false) + private Folder parentFolder; + + // 사용자가 수정 가능한 Pick 제목. 기본값은 원문 제목과 동일 + @Column(name = "title", nullable = false) + private String title = ""; + + // 픽에 속한 tag id들을 공백으로 분리된 String으로 변환하여 db에 저장. Ex) [6,3,2,23,1] -> "6 3 2 23 1" + @Convert(converter = OrderConverter.class) + @Column(name = "tag_order", columnDefinition = "longblob", nullable = false) + private List tagOrder = new ArrayList<>(); + + // 사용자가 링크에 대해 남기는 메모 (update시 null과 ""를 구분하기 위함) + @Column(name = "memo", nullable = false) + private String memo = ""; + + @Builder + private Pick(User user, Link link, Folder parentFolder, String title, List tagOrder, String memo) { + this.user = user; + this.link = link; + this.parentFolder = parentFolder; + this.title = title; + this.tagOrder = tagOrder; + this.memo = memo; + } + + public Pick updateTagOrder(List tagOrder) { + if (tagOrder == null) return this; + this.tagOrder = tagOrder; + return this; + } + + public Pick updateParentFolder(Folder parentFolder) { + if (parentFolder == null) return this; + this.parentFolder = parentFolder; + return this; + } + + public Pick updateTitle(String title) { + if (title == null) return this; + this.title = title; + return this; + } + + public Pick updateMemo(String memo) { + if (memo == null) return this; + this.memo = memo; + return this; + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/pick/PickRepository.java b/backend/src/main/java/kernel360/techpick/core/model/pick/PickRepository.java new file mode 100644 index 00000000..a1c35570 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/pick/PickRepository.java @@ -0,0 +1,12 @@ +package kernel360.techpick.core.model.pick; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import kernel360.techpick.core.model.link.Link; +import kernel360.techpick.core.model.user.User; + +public interface PickRepository extends JpaRepository { + Optional findByUserAndLink(User user, Link link); +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/pick/PickTag.java b/backend/src/main/java/kernel360/techpick/core/model/pick/PickTag.java new file mode 100644 index 00000000..e1d661bc --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/pick/PickTag.java @@ -0,0 +1,48 @@ +package kernel360.techpick.core.model.pick; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import kernel360.techpick.core.model.tag.Tag; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "pick_tag") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PickTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 사용자 FK + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pick_id", nullable = false) + private Pick pick; + + // 사용자 정의 태그 FK + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id", nullable = false) + private Tag tag; + + // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요 + + private PickTag(Pick pick, Tag tag) { + this.pick = pick; + this.tag = tag; + } + + public static PickTag of(Pick pick, Tag tag) { + return new PickTag(pick, tag); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/pick/PickTagRepository.java b/backend/src/main/java/kernel360/techpick/core/model/pick/PickTagRepository.java new file mode 100644 index 00000000..2e99a654 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/pick/PickTagRepository.java @@ -0,0 +1,10 @@ +package kernel360.techpick.core.model.pick; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PickTagRepository extends JpaRepository { + + void deleteByPick(Pick pick); + + void deleteByTagId(Long tagId); +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/rss/RssBlogRepository.java b/backend/src/main/java/kernel360/techpick/core/model/rss/RssBlogRepository.java new file mode 100644 index 00000000..f45983d1 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/rss/RssBlogRepository.java @@ -0,0 +1,6 @@ +package kernel360.techpick.core.model.rss; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RssBlogRepository extends JpaRepository { +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawData.java b/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawData.java new file mode 100644 index 00000000..10c2d2bf --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawData.java @@ -0,0 +1,84 @@ +package kernel360.techpick.core.model.rss; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import kernel360.techpick.core.model.common.BaseEntity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "rss_raw_data") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RssRawData extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 제목 + @Column(name = "title") + private String title; + + // 원문 주소 + // TODO: VARCHAR 최대 크기를 몇으로 할지 토의 필요합니다. + // medium 기준 가장 길었던 url 320 글자의 약 2배인 VARCHAR(600)으로 변경 + @Column(name = "url", columnDefinition = "VARCHAR(600)") + private String url; + + @Column(name = "guid") + private String guid; + + // 작성 일자 + @Column(name = "published_at") + private String publishedAt; + + // 요약 설명 + @Column(name = "description", columnDefinition = "longblob") // nullable + private String description; + + // 작성자 + @Column(name = "creator") // nullable + private String creator; + + // 복수 카테고리를 쉼표(,)로 구분 (ex. "Foo,Bar,Baz") + @Column(name = "joined_category") // nullable + private String joinedCategory; + + @Column(name = "rssSupportingBlogId") + private Long rssSupportingBlogId; + + // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요 + + private RssRawData( + String title, + String url, + String guid, + String publishedAt, + String description, + String creator, + String joinedCategory, + Long rssSupportingBlogId + ) { + this.title = title; + this.url = url; + this.guid = guid; + this.publishedAt = publishedAt; + this.description = description; + this.creator = creator; + this.joinedCategory = joinedCategory; + this.rssSupportingBlogId = rssSupportingBlogId; + } + + public static RssRawData create(String title, String url, String guid, String publishedAt, String description, + String creator, + String joinedCategory, Long rssSupportingBlogId) { + return new RssRawData(title, url, guid, publishedAt, description, creator, joinedCategory, rssSupportingBlogId); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawDataRepository.java b/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawDataRepository.java new file mode 100644 index 00000000..96ec2607 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/rss/RssRawDataRepository.java @@ -0,0 +1,6 @@ +package kernel360.techpick.core.model.rss; + +import org.springframework.data.repository.CrudRepository; + +public interface RssRawDataRepository extends CrudRepository { +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/rss/RssSupportingBlog.java b/backend/src/main/java/kernel360/techpick/core/model/rss/RssSupportingBlog.java new file mode 100644 index 00000000..8d0b351d --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/rss/RssSupportingBlog.java @@ -0,0 +1,37 @@ +package kernel360.techpick.core.model.rss; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import kernel360.techpick.core.model.common.BaseEntity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "rss_blog") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RssSupportingBlog extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // Rss 피드 주소 + @Column(name = "rss_feed_url", nullable = false, unique = true) + private String rssFeedUrl; + + // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요 + private RssSupportingBlog(String rssFeedUrl) { + this.rssFeedUrl = rssFeedUrl; + } + + public static RssSupportingBlog create(String rssFeedUrl) { + return new RssSupportingBlog(rssFeedUrl); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/tag/Tag.java b/backend/src/main/java/kernel360/techpick/core/model/tag/Tag.java new file mode 100644 index 00000000..03c0400d --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/tag/Tag.java @@ -0,0 +1,58 @@ +package kernel360.techpick.core.model.tag; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import kernel360.techpick.core.model.user.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "tag") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 태그명 + @Column(name = "name", nullable = false) + private String name; + + // 프론트가 쓸 컬러 넘버 (숫자 - 색상 매핑은 프론트에서 처리, 무조건 처리) + @Column(name = "color_number", nullable = false) + private Integer colorNumber; + + // 사용자 FK + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder + private Tag(String name, Integer colorNumber, User user) { + this.name = name; + this.colorNumber = colorNumber; + this.user = user; + } + + // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요 + + public void updateTagName(String name) { + this.name = name; + } + + public void updateColorNumber(Integer colorNumber) { + this.colorNumber = colorNumber; + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/tag/TagRepository.java b/backend/src/main/java/kernel360/techpick/core/model/tag/TagRepository.java new file mode 100644 index 00000000..9e041995 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/tag/TagRepository.java @@ -0,0 +1,12 @@ +package kernel360.techpick.core.model.tag; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TagRepository extends JpaRepository { + + boolean existsByUserIdAndName(Long userId, String name); + + List findAllByUserId(Long userId); +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/user/Role.java b/backend/src/main/java/kernel360/techpick/core/model/user/Role.java new file mode 100644 index 00000000..a287642d --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/user/Role.java @@ -0,0 +1,7 @@ +package kernel360.techpick.core.model.user; + +public enum Role { + ROLE_GUEST, // 로그인 안한 사용자 + ROLE_USER, + ROLE_ADMIN, +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/user/SocialType.java b/backend/src/main/java/kernel360/techpick/core/model/user/SocialType.java new file mode 100644 index 00000000..6dc12c42 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/user/SocialType.java @@ -0,0 +1,25 @@ +package kernel360.techpick.core.model.user; + +// 로그인 타입 +public enum SocialType { + GOOGLE("google"), + KAKAO("kakao"), + NAVER("naver"), + + ; + + private final String providerId; + + SocialType(String providerId) { + this.providerId = providerId; + } + + public static SocialType providerIdOf(String providerId) throws IllegalArgumentException { + for (SocialType socialType : SocialType.values()) { + if (socialType.providerId.equals(providerId)) { + return socialType; + } + } + throw new IllegalArgumentException(); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/user/User.java b/backend/src/main/java/kernel360/techpick/core/model/user/User.java new file mode 100644 index 00000000..f945fca1 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/user/User.java @@ -0,0 +1,116 @@ +package kernel360.techpick.core.model.user; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import kernel360.techpick.core.model.common.BaseEntity; +import kernel360.techpick.core.util.OrderConverter; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@SQLDelete(sql = "UPDATE user SET deleted_at = CURRENT_TIMESTAMP WHERE user_id=?") +@SQLRestriction("deleted_at IS NULL") +@Table(name = "user") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity /* implements UserDetails --> 시큐리티 도입시 추가 */ { + + private static final String SOCIAL_USER_HAS_NO_PASSWORD = null; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + // 닉네임 (없으면 랜덤 생성 - Ex. "노래하는피치#145") + @Column(name = "nickname", nullable = false /*, unique = true */) + private String nickname; + + // 이메일 (WARN: 두 소셜 로그인이 같은 이메일을 가질 수 있음) + @Column(name = "email", nullable = false) + private String email; + + // 유저 권한 + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + private Role role; + + // 비밀번호 (소셜 로그인 사용자는 null) + @Column(name = "password") // nullable + private String password; + + // 소셜 제공자 (null일 경우 자체 가입 회원) + @Enumerated(EnumType.STRING) + @Column(name = "social_provider") // nullable + private SocialType socialProvider; + + // 소셜 제공자 Id + @Column(name = "social_provider_id") // nullable + private String socialProviderId; + + // Soft Delete - 삭제 시간 + @Column(name = "deleted_at") // nullable + private LocalDateTime deletedAt; + + // 유저의 tag id들을 공백으로 분리된 String으로 변환하여 db에 저장 + // ex) [6,3,2,23,1] -> "6 3 2 23 1" + @Convert(converter = OrderConverter.class) + @Column(name = "tag_order", columnDefinition = "longblob", nullable = false) + private List tagOrderList = new ArrayList<>(); + + public void updateTagOrderList(List tagOrderList) { + this.tagOrderList = tagOrderList; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof User user)) { + return false; + } + return Objects.equals(id, user.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + private User( + SocialType socialProvider, + String socialProviderId, + String nickname, + String password, + String email, + Role role, + List tagOrderList + ) { + this.socialProviderId = socialProviderId; + this.socialProvider = socialProvider; + this.nickname = nickname; + this.password = password; + this.email = email; + this.role = role; + this.tagOrderList = tagOrderList; + } + + // TODO: 엔티티 사용자가 정적 팩토리 메소드로 필요한 함수를 구현 하세요 +} diff --git a/backend/src/main/java/kernel360/techpick/core/model/user/UserRepository.java b/backend/src/main/java/kernel360/techpick/core/model/user/UserRepository.java new file mode 100644 index 00000000..8041e1c6 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/model/user/UserRepository.java @@ -0,0 +1,6 @@ +package kernel360.techpick.core.model.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} diff --git a/backend/src/main/java/kernel360/techpick/core/util/CookieUtil.java b/backend/src/main/java/kernel360/techpick/core/util/CookieUtil.java new file mode 100644 index 00000000..f856d842 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/util/CookieUtil.java @@ -0,0 +1,71 @@ +package kernel360.techpick.core.util; + +import java.util.Base64; + +import org.springframework.http.ResponseCookie; +import org.springframework.util.SerializationUtils; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class CookieUtil { + + //요청값(이름,값,만료기간)을 바탕으로 쿠키추가 + public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + ResponseCookie responseCookie = ResponseCookie.from(name, value) + .maxAge(maxAge) + .path("/") + .httpOnly(true) + .secure(true) + .domain("minlife.me") + // .sameSite("None") + .build(); + response.addHeader("Set-Cookie", responseCookie.toString()); + + // 로그인 확인용 쿠키 (techPickLogin = true) 추가 + ResponseCookie techPickLoginCookie = ResponseCookie.from("techPickLogin", "true") + .maxAge(maxAge) + .path("/") + .secure(true) + .domain("minlife.me") + // .sameSite("None") + .build(); + response.addHeader("Set-Cookie", techPickLoginCookie.toString()); + + } + + //쿠키의 이름을 입력받아 쿠키 삭제 + public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) { + Cookie[] cookies = request.getCookies(); + + if (cookies == null) { + return; + } + + for (Cookie cookie : cookies) { + if (name.equals(cookie.getName())) { + cookie.setValue(""); + cookie.setPath("/"); + cookie.setMaxAge(0); + cookie.setHttpOnly(true); + response.addCookie(cookie); + } + } + } + + //객체를 직렬화해 쿠키의 값으로 변환 + public static String serialize(Object obj) { + return Base64.getUrlEncoder() + .encodeToString(SerializationUtils.serialize(obj)); + } + + //쿠키를 역직렬화해 객체로 변환 + public static T deserialize(Cookie cookie, Class cls) { + return cls.cast( + SerializationUtils.deserialize( + Base64.getUrlDecoder().decode(cookie.getValue()) + ) + ); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/util/JwtUtil.java b/backend/src/main/java/kernel360/techpick/core/util/JwtUtil.java new file mode 100644 index 00000000..5128cee2 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/util/JwtUtil.java @@ -0,0 +1,69 @@ +package kernel360.techpick.core.util; + +import java.time.Duration; +import java.util.Date; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import kernel360.techpick.core.model.user.Role; +import kernel360.techpick.core.model.user.User; + +// 패키지 위치에 대한 고민 필요 +@Component +public class JwtUtil { + + @Value("${spring.jwt.issuer}") + private String issuer; + @Value("${spring.jwt.secret}") + private String secret; + + /* + userId만 토큰에 포함됨 + */ + public String getToken(User user, Duration expiry) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiry.toMillis()); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setIssuer(issuer) + .setIssuedAt(now) + .setExpiration(expiryDate) + .claim("id", user.getId()) + // TODO: role enum name? value? 변경 필요 + .claim("role", user.getRole()) + .signWith(SignatureAlgorithm.HS256, secret) + .compact(); + } + + public boolean isValidToken(String token) { + try { + Jwts.parser() + .setSigningKey(secret) + .parseClaimsJws(token); + return true; + } catch (Exception e) { + return false; + } + } + + public Role getRole(String token) { + return Role.valueOf(getClaims(token).get("role", String.class)); + } + + public Long getUserId(String token) { + return getClaims(token).get("id", Long.class); + } + + private Claims getClaims(String token) { + return Jwts.parser() + .setSigningKey(secret) + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/backend/src/main/java/kernel360/techpick/core/util/OrderConverter.java b/backend/src/main/java/kernel360/techpick/core/util/OrderConverter.java new file mode 100644 index 00000000..654e9253 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/core/util/OrderConverter.java @@ -0,0 +1,31 @@ +package kernel360.techpick.core.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringTokenizer; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class OrderConverter implements AttributeConverter, String> { + + @Override + public String convertToDatabaseColumn(List idList) { + StringBuilder sb = new StringBuilder(); + for (Long id : idList) { + sb.append(id).append(" "); + } + return sb.toString().trim(); + } + + @Override + public List convertToEntityAttribute(String s) { + List idList = new ArrayList<>(); + StringTokenizer st = new StringTokenizer(s); + while (st.hasMoreTokens()) { + idList.add(Long.parseLong(st.nextToken())); + } + return idList; + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiController.java b/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiController.java new file mode 100644 index 00000000..2b34be40 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiController.java @@ -0,0 +1,12 @@ +package kernel360.techpick.feature.api.folder.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/folders") +public class FolderApiController implements FolderApiSpecification { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiSpecification.java b/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiSpecification.java new file mode 100644 index 00000000..54deca04 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/folder/controller/FolderApiSpecification.java @@ -0,0 +1,58 @@ +package kernel360.techpick.feature.api.folder.controller; + +public interface FolderApiSpecification { + + // [ 최초 필수 폴더 정보 획득 (폴더가 닫힌 상태 임을 가정) ] + // 클라이언트에서 예외 발생시, 모두 강제로 폴더가 닫히도록 유도 + // + // GET api/folders/ + // -------------------- No Request Body + // -------------------- Response Body = [ + // type, --> root | unclassified | recycle + // folder_id, + // name, + // ] + + // [ 폴더의 자식 정보 획득 ] + // 이때, 폴더의 이름 + 자식 구조 1 depth 까지만 결과를 반환. + // 클라이언트에서 펼처진 폴더에 대한 GET 요청을 연쇄적으로 날릴 것. + // GET api/folders/children/{folder_id} + // -------------------- No Request Body + // -------------------- Response Body = { List of "depth-1 child" folder (id & name) / pick (id & title) } + + // [ 폴더 정보 수정 ] + // PATCH api/folders/data/{folder_id} + // -------------------- Request Body = { + // name : nullable --> null일 경우 아무 것도 안하며, 빈 문자열일 경우 예외 발생 + // } + // -------------------- No Response Body + + // [ 폴더 위치 수정 - 값이 null일 때 "생성"과 다르게 적용되는 점 주의 ] + // PATCH api/folders/location/{folder_id}/location + // -------------------- Request Body = { + // parent_folder_id: nullable --> 있다면 해당 폴더로 소속되게, 없으면 변경 없음 + // order_idx : nullable --> 0이면 폴더중 최상단 위치, 없으면 변경 없음 + // } + // -------------------- No Response Body + // * 이때, parent_folder_id가 휴지통에 있는 id거나 휴지통 id 일 수 있다. + // * 휴지통으로 이동하는 것은 삭제로 처리하지 않는다. + + // [ 폴더 생성 ] + // POST api/folders/ + // -------------------- Request Body = { + // data: { + // name : --> 빈 문자열이거나, null일 경우 예외 발생 + // }, + // location: { + // parent_folder_id: nullable --> 있다면 해당 폴더로 소속되게, 없으면 root로 + // order_idx : nullable --> 0이면 폴더중 최상단 위치, 없으면 제일 마지막. + // } + // } + // + // -------------------- Response Body = { created_folder_id } + + // [폴더 삭제 - 휴지통으로 부터 삭제] + // DELETE api/folders/{folder_id} + // -------------------- no request body + // -------------------- no response body +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiMapper.java b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiMapper.java new file mode 100644 index 00000000..710dbf54 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiMapper.java @@ -0,0 +1,13 @@ +package kernel360.techpick.feature.api.folder.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface FolderApiMapper { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiRequest.java b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiRequest.java new file mode 100644 index 00000000..0ae92502 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiRequest.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.api.folder.dto; + +public class FolderApiRequest { + + public record Create() {} + public record Read() {} + public record Update() {} + public record Delete() {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiResponse.java b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiResponse.java new file mode 100644 index 00000000..d571d096 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/folder/dto/FolderApiResponse.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.api.folder.dto; + +public class FolderApiResponse { + + public record Create() {} + public record Read() {} + public record Update() {} + public record Delete() {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiController.java b/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiController.java new file mode 100644 index 00000000..5cb25ef4 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiController.java @@ -0,0 +1,12 @@ +package kernel360.techpick.feature.api.pick.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/picks") +public class PickApiController implements PickApiSpecification { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiSpecification.java b/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiSpecification.java new file mode 100644 index 00000000..63276ea6 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/pick/controller/PickApiSpecification.java @@ -0,0 +1,53 @@ +package kernel360.techpick.feature.api.pick.controller; + +public interface PickApiSpecification { + + // [ 내가 해당 링크를 픽한 적이 있는지 확인 ] + // GET api/picks?link={url} + // -------------------- no request body + // -------------------- Response Body = { + // pick_id: nullable (null == 픽 안함) + // } + + // [ 나의 픽 상세 조회 ] + // GET api/picks/{pick_id} + // -------------------- Request Body = X + // -------------------- Response Body = { parent_folder_id, 태그, 제목 ... 등등 } + + // [ 나의 픽 정보 수정 - null이 아닌 값만 업데이트 ] + // PATCH api/picks/data/{pick_id} + // -------------------- Request Body = { + // memo: nullable, + // title: nullable, + // tags: nullable + // } + + // [ 나의 픽 위치 수정 - 값이 null일 때 "생성"과 다르게 적용되는 점 주의 ] + // PATCH api/picks/location/{pick_id} + // -------------------- Request Body = { + // parent_folder_id: nullable --> 있다면 해당 폴더로 소속되게, 없으면 변경 없음 + // order_idx : 호출 된 이상, null일 수 없음! + // } + + // [ 나의 픽 생성 ] + // POST api/picks/ + // -------------------- Request Body = { + // data: { + // title: nullable, --> null일 경우 빈 문자열로 설정 + // memo: nullable, --> null일 경우 빈 문자열로 설정 + // tags: nullable, --> null일 경우 빈 태그로 설정 + // url: , --> null일 경우 예외 발생 + // ... + // }, + // location: { + // parent_folder_id: nullable --> 있다면 해당 폴더로 소속되게, 없으면 unclassified로 + // order_idx : nullable --> 0이면 픽들 중 최상단 위치, 없으면 제일 마지막. + // } + // } + // -------------------- Response Body = { created_pick_id } + + // [ 나의 픽 삭제 조회 ] + // GET api/picks/{pick_id} + // -------------------- no request body + // -------------------- no response body +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiMapper.java b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiMapper.java new file mode 100644 index 00000000..c1044cad --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiMapper.java @@ -0,0 +1,13 @@ +package kernel360.techpick.feature.api.pick.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface PickApiMapper { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiRequest.java b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiRequest.java new file mode 100644 index 00000000..38e6127b --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiRequest.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.api.pick.dto; + +public class PickApiRequest { + + public record Create() {} + public record Read() {} + public record Update() {} + public record Delete() {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiResponse.java b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiResponse.java new file mode 100644 index 00000000..07d92dc9 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/pick/dto/PickApiResponse.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.api.pick.dto; + +public class PickApiResponse { + + public record Create() {} + public record Read() {} + public record Update() {} + public record Delete() {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiController.java b/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiController.java new file mode 100644 index 00000000..a122628b --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiController.java @@ -0,0 +1,12 @@ +package kernel360.techpick.feature.api.tag.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/tags") +public class TagApiController implements TagApiSpecification { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiSpecification.java b/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiSpecification.java new file mode 100644 index 00000000..0e3f072c --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/tag/controller/TagApiSpecification.java @@ -0,0 +1,34 @@ +package kernel360.techpick.feature.api.tag.controller; + +public interface TagApiSpecification { + + // [ 나의 태그 리스트 조회 ] + // GET api/tags/ + // -------------------- Request Body = X + // -------------------- Response Body = { 태그 리스트 } + + // [ 나의 태그 정보 수정 ] + // PUT api/tags/ + // -------------------- Request Body = + // [ + // { + // "tagId": 0, + // "tagName": "string", + // "tagIdList": 0, + // "colorNumber": 0 + // } + // ] + + // [ 나의 태그 생성 ] + // POST api/tags/ + // { + // "tagName": "string", + // "colorNumber": 0 + // } + + // [ 나의 태그 삭제 ] + // DELETE api/tags/{tag_id} + // -------------------- no request body + // -------------------- no response body + +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiMapper.java b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiMapper.java new file mode 100644 index 00000000..3b806340 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiMapper.java @@ -0,0 +1,30 @@ +package kernel360.techpick.feature.api.tag.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +import kernel360.techpick.feature.domain.tag.dto.TagCommand; +import kernel360.techpick.feature.domain.tag.dto.TagResult; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface TagApiMapper { + + TagCommand.Read toReadCommand(TagApiRequest.Read request); + + TagCommand.Create toCreateCommand(TagApiRequest.Create request); + + TagCommand.Update toUpdateCommand(TagApiRequest.Update request); + + TagCommand.Delete toDeleteCommand(TagApiRequest.Delete request); + + TagApiResponse.Read toReadResponse(TagResult result); + + TagApiResponse.Create toCreateResponse(TagResult result); + + TagApiResponse.Update toUpdateResponse(TagResult result); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiRequest.java b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiRequest.java new file mode 100644 index 00000000..ced64753 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiRequest.java @@ -0,0 +1,27 @@ +package kernel360.techpick.feature.api.tag.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public class TagApiRequest { + + public record Create( + @NotBlank String name, + @NotNull Integer colorNumber) { + } + + public record Read( + @NotNull Long tagId) { + } + + public record Update( + @NotNull Long tagId, + @NotEmpty String name, + @NotNull Integer colorNumber) { + } + + public record Delete( + @NotNull Long tagId) { + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiResponse.java b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiResponse.java new file mode 100644 index 00000000..44a4011a --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/api/tag/dto/TagApiResponse.java @@ -0,0 +1,25 @@ +package kernel360.techpick.feature.api.tag.dto; + +public class TagApiResponse { + + public record Create( + Long id, + String name, + String colorNumber + ) { + } + + public record Read( + Long id, + String name, + String colorNumber + ) { + } + + public record Update( + Long id, + String name, + String colorNumber + ) { + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/application/FolderFacade.java b/backend/src/main/java/kernel360/techpick/feature/application/FolderFacade.java new file mode 100644 index 00000000..686fd772 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/application/FolderFacade.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.application; + +public class FolderFacade { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/application/PickFacade.java b/backend/src/main/java/kernel360/techpick/feature/application/PickFacade.java new file mode 100644 index 00000000..c14b4c11 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/application/PickFacade.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.application; + +public class PickFacade { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/application/TagFacade.java b/backend/src/main/java/kernel360/techpick/feature/application/TagFacade.java new file mode 100644 index 00000000..ba678a69 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/application/TagFacade.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.application; + +public class TagFacade { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderCommand.java new file mode 100644 index 00000000..8891112f --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderCommand.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.domain.folder.dto; + +public class FolderCommand { + + public record Create() {} + public record Read() {} + public record Update() {} + public record Delete() {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderMapper.java new file mode 100644 index 00000000..4999a2cf --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderMapper.java @@ -0,0 +1,13 @@ +package kernel360.techpick.feature.domain.folder.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface FolderMapper { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderResult.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderResult.java new file mode 100644 index 00000000..cc5df198 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/dto/FolderResult.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.domain.folder.dto; + +public class FolderResult { + + public record Create() {} + public record Read() {} + public record Update() {} + public record Delete() {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderErrorCode.java new file mode 100644 index 00000000..0b92808b --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderErrorCode.java @@ -0,0 +1,65 @@ +package kernel360.techpick.feature.domain.folder.exception; + +import org.springframework.http.HttpStatus; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.level.ErrorLevel; + +public enum ApiFolderErrorCode implements ApiErrorCode { + + /** + * Folder Error Code (FO) + */ + FOLDER_NOT_FOUND + ("FO-000", HttpStatus.BAD_REQUEST, "존재하지 않는 폴더", ErrorLevel.SHOULD_NOT_HAPPEN()), + FOLDER_ALREADY_EXIST + ("FO-001", HttpStatus.BAD_REQUEST, "이미 존재하는 폴더 이름", ErrorLevel.CAN_HAPPEN()), + FOLDER_ACCESS_DENIED + ("FO-002", HttpStatus.FORBIDDEN, "접근할 수 없는 폴더", ErrorLevel.SHOULD_NOT_HAPPEN()), + BASIC_FOLDER_CANNOT_CHANGED + ("FO-003", HttpStatus.BAD_REQUEST, "기본폴더는 변경(수정/삭제/이동)할 수 없음", ErrorLevel.MUST_NEVER_HAPPEN()), + CANNOT_DELETE_FOLDER_NOT_IN_RECYCLE_BIN + ("FO-004", HttpStatus.BAD_REQUEST, "휴지통 안에 있는 폴더만 삭제할 수 있음", ErrorLevel.MUST_NEVER_HAPPEN()), + INVALID_FOLDER_TYPE + ("F0-005", HttpStatus.NOT_IMPLEMENTED, "미구현 폴더 타입에 대한 서비스 요청", ErrorLevel.MUST_NEVER_HAPPEN()), + BASIC_FOLDER_ALREADY_EXISTS + ("F0-006", HttpStatus.NOT_ACCEPTABLE, "기본 폴더는 1개만 존재할 수 있음.", ErrorLevel.MUST_NEVER_HAPPEN()), + INVALID_MOVE_OPERATION + ("F0-007", HttpStatus.NOT_ACCEPTABLE, "잘못된 이동 행위", ErrorLevel.SHOULD_NOT_HAPPEN()), + ; + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel errorLevel; + + ApiFolderErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.errorLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.errorLevel; + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderException.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderException.java new file mode 100644 index 00000000..cb076dcc --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/exception/ApiFolderException.java @@ -0,0 +1,46 @@ +package kernel360.techpick.feature.domain.folder.exception; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.base.ApiException; + +public class ApiFolderException extends ApiException { + + private ApiFolderException(ApiErrorCode errorCode) { + super(errorCode); + } + + /** + * TODO: Implement static factory method + */ + public static ApiFolderException FOLDER_NOT_FOUND() { + return new ApiFolderException(ApiFolderErrorCode.FOLDER_NOT_FOUND); + } + + public static ApiFolderException FOLDER_ALREADY_EXIST() { + return new ApiFolderException(ApiFolderErrorCode.FOLDER_ALREADY_EXIST); + } + + public static ApiFolderException FOLDER_ACCESS_DENIED() { + return new ApiFolderException(ApiFolderErrorCode.FOLDER_ACCESS_DENIED); + } + + public static ApiFolderException BASIC_FOLDER_CANNOT_CHANGED() { + return new ApiFolderException(ApiFolderErrorCode.BASIC_FOLDER_CANNOT_CHANGED); + } + + public static ApiFolderException CANNOT_DELETE_FOLDER_NOT_IN_RECYCLE_BIN() { + return new ApiFolderException(ApiFolderErrorCode.CANNOT_DELETE_FOLDER_NOT_IN_RECYCLE_BIN); + } + + public static ApiFolderException INVALID_FOLDER_TYPE() { + return new ApiFolderException(ApiFolderErrorCode.INVALID_FOLDER_TYPE); + } + + public static ApiFolderException BASIC_FOLDER_ALREADY_EXISTS() { + return new ApiFolderException(ApiFolderErrorCode.BASIC_FOLDER_ALREADY_EXISTS); + } + + public static ApiFolderException INVALID_PICK_MOVE_OPERATION() { + return new ApiFolderException(ApiFolderErrorCode.INVALID_MOVE_OPERATION); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderService.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderService.java new file mode 100644 index 00000000..b9176182 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderService.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.domain.folder.service; + +public interface FolderService { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderServiceImpl.java new file mode 100644 index 00000000..0acd7f3f --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/folder/service/FolderServiceImpl.java @@ -0,0 +1,10 @@ +package kernel360.techpick.feature.domain.folder.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class FolderServiceImpl implements FolderService { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkCommand.java new file mode 100644 index 00000000..6ef29576 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkCommand.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.domain.link.dto; + +public class LinkCommand { + + public record Create() {} + public record Read() {} + public record Update() {} + public record Delete() {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkInfo.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkInfo.java new file mode 100644 index 00000000..8c3c715c --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkInfo.java @@ -0,0 +1,8 @@ +package kernel360.techpick.feature.domain.link.dto; + +public record LinkInfo( + String url, + String title, + String description, + String imageUrl +) {} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkMapper.java new file mode 100644 index 00000000..58412fc0 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/dto/LinkMapper.java @@ -0,0 +1,21 @@ +package kernel360.techpick.feature.domain.link.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +import kernel360.techpick.core.model.link.Link; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface LinkMapper { + + @Mapping(target = "invalidatedAt", ignore = true) + Link of(LinkInfo linkInfo); + + LinkInfo of(Link link); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkErrorCode.java new file mode 100644 index 00000000..c260c54c --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkErrorCode.java @@ -0,0 +1,67 @@ +package kernel360.techpick.feature.domain.link.exception; + +import org.springframework.http.HttpStatus; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.level.ErrorLevel; + +public enum ApiLinkErrorCode implements ApiErrorCode { + + /** + * Link Error Code (LI) + */ + LINK_NOT_FOUND + ("LI-000", HttpStatus.NOT_FOUND, "존재하지 않는 링크", ErrorLevel.SHOULD_NOT_HAPPEN()), + + LINK_HAS_PICKS + ("LI-001", HttpStatus.BAD_REQUEST, "링크를 픽한 사람이 존재", ErrorLevel.SHOULD_NOT_HAPPEN()), + + LINK_ALREADY_EXIST + ("LI-002", HttpStatus.BAD_REQUEST, "이미 존재하는 링크(URL)", ErrorLevel.CAN_HAPPEN()), + + ; + + // ------------------------------------------------------------ + // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다. + // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요. + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel errorLevel; + + ApiLinkErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.errorLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.errorLevel; + } + + @Override + public String toString() { + return convertCodeToString(this); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkException.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkException.java new file mode 100644 index 00000000..3b7fd744 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/exception/ApiLinkException.java @@ -0,0 +1,26 @@ +package kernel360.techpick.feature.domain.link.exception; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.base.ApiException; + +public class ApiLinkException extends ApiException { + + private ApiLinkException(ApiErrorCode errorCode) { + super(errorCode); + } + + /** + * TODO: Implement static factory method + */ + public static ApiLinkException LINK_NOT_FOUND() { + return new ApiLinkException(ApiLinkErrorCode.LINK_NOT_FOUND); + } + + public static ApiLinkException LINK_HAS_PICKS() { + return new ApiLinkException(ApiLinkErrorCode.LINK_HAS_PICKS); + } + + public static ApiLinkException LINK_ALREADY_EXISTS() { + return new ApiLinkException(ApiLinkErrorCode.LINK_ALREADY_EXIST); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkService.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkService.java new file mode 100644 index 00000000..fe3666ce --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkService.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.domain.link.service; + +public interface LinkService { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkServiceImpl.java new file mode 100644 index 00000000..4668ba62 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/link/service/LinkServiceImpl.java @@ -0,0 +1,10 @@ +package kernel360.techpick.feature.domain.link.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class LinkServiceImpl implements LinkService { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickCommand.java new file mode 100644 index 00000000..fdb6d2af --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickCommand.java @@ -0,0 +1,18 @@ +package kernel360.techpick.feature.domain.pick.dto; + +import java.util.List; + +import kernel360.techpick.feature.domain.link.dto.LinkInfo; + +public class PickCommand { + + public record Read(Long pickId) {} + + public record Create(String title, String memo, List tagOrder, Long parentFolderId, LinkInfo linkInfo) {} + + public record Update(Long pickId, String title, String memo, List tagIdList) {} + + public record Move(Long pickId, Long parentFolderId, int orderIdx) {} + + public record Delete(Long pickId) {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickMapper.java new file mode 100644 index 00000000..37d164bb --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickMapper.java @@ -0,0 +1,34 @@ +package kernel360.techpick.feature.domain.pick.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +import kernel360.techpick.core.model.folder.Folder; +import kernel360.techpick.core.model.link.Link; +import kernel360.techpick.core.model.pick.Pick; +import kernel360.techpick.core.model.user.User; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface PickMapper { + + @Mapping(source = "pick.link", target = "linkInfo") + PickResult toCreateResult(Pick pick); + + @Mapping(source = "pick.link", target = "linkInfo") + PickResult toReadResult(Pick pick); + + @Mapping(source = "pick.link", target = "linkInfo") + PickResult toUpdateResult(Pick pick); + + @Mapping(source = "pick.link", target = "linkInfo") + PickResult toMoveResult(Pick pick); + + @Mapping(source = "command.title", target = "title") + Pick toEntity(PickCommand.Create command, User user, Folder parentFolder, Link link); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickResult.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickResult.java new file mode 100644 index 00000000..016cd9c9 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/dto/PickResult.java @@ -0,0 +1,13 @@ +package kernel360.techpick.feature.domain.pick.dto; + +import java.util.List; + +import kernel360.techpick.feature.domain.link.dto.LinkInfo; + +public record PickResult( + Long id, + String title, + String memo, + LinkInfo linkInfo, + List tagOrder +) {} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickErrorCode.java new file mode 100644 index 00000000..45f56d0b --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickErrorCode.java @@ -0,0 +1,61 @@ +package kernel360.techpick.feature.domain.pick.exception; + +import org.springframework.http.HttpStatus; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.level.ErrorLevel; + +public enum ApiPickErrorCode implements ApiErrorCode { + + /** + * Pick Error Code (PK) + */ + PICK_NOT_FOUND + ("PK-000", HttpStatus.NOT_FOUND, "존재하지 않는 Pick", ErrorLevel.CAN_HAPPEN()), + PICK_ALREADY_EXIST + ("PK-001", HttpStatus.BAD_REQUEST, "이미 존재하는 Pick", ErrorLevel.CAN_HAPPEN()), + PICK_UNAUTHORIZED_ACCESS + ("PK-002", HttpStatus.UNAUTHORIZED, "잘못된 Pick 접근, 다른 사용자의 Pick에 접근", ErrorLevel.SHOULD_NOT_HAPPEN()), + + ; + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel logLevel; + + ApiPickErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.logLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.logLevel; + } + + @Override + public String toString() { + return convertCodeToString(this); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickException.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickException.java new file mode 100644 index 00000000..fc9506d0 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/exception/ApiPickException.java @@ -0,0 +1,23 @@ +package kernel360.techpick.feature.domain.pick.exception; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.base.ApiException; + +public class ApiPickException extends ApiException { + + private ApiPickException(ApiErrorCode errorCode) { + super(errorCode); + } + + public static ApiPickException PICK_NOT_FOUND() { + throw new ApiPickException(ApiPickErrorCode.PICK_NOT_FOUND); + } + + public static ApiPickException PICK_MUST_BE_UNIQUE_FOR_A_URL() { + throw new ApiPickException(ApiPickErrorCode.PICK_ALREADY_EXIST); + } + + public static ApiPickException PICK_UNAUTHORIZED_ACCESS() { + throw new ApiPickException(ApiPickErrorCode.PICK_UNAUTHORIZED_ACCESS); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickService.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickService.java new file mode 100644 index 00000000..63446049 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickService.java @@ -0,0 +1,17 @@ +package kernel360.techpick.feature.domain.pick.service; + +import kernel360.techpick.feature.domain.pick.dto.PickCommand; +import kernel360.techpick.feature.domain.pick.dto.PickResult; + +public interface PickService { + + PickResult getPick(PickCommand.Read command); + + PickResult saveNewPick(PickCommand.Create command); + + PickResult updatePick(PickCommand.Update command); + + PickResult movePick(PickCommand.Move command); + + void deletePick(PickCommand.Delete command); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickServiceImpl.java new file mode 100644 index 00000000..591fb926 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/pick/service/PickServiceImpl.java @@ -0,0 +1,88 @@ +package kernel360.techpick.feature.domain.pick.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import kernel360.techpick.core.model.folder.Folder; +import kernel360.techpick.feature.domain.pick.dto.PickCommand; +import kernel360.techpick.feature.domain.pick.dto.PickMapper; +import kernel360.techpick.feature.domain.pick.dto.PickResult; +import kernel360.techpick.feature.infrastructure.folder.reader.FolderReader; +import kernel360.techpick.feature.infrastructure.link.writer.LinkWriter; +import kernel360.techpick.feature.infrastructure.pick.reader.PickReader; +import kernel360.techpick.feature.infrastructure.pick.writer.PickWriter; +import kernel360.techpick.feature.infrastructure.user.reader.UserReader; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PickServiceImpl implements PickService { + private final UserReader userReader; + private final FolderReader folderReader; + private final LinkWriter linkWriter; + private final PickReader pickReader; + private final PickWriter pickWriter; + private final PickMapper pickMapper; + + @Override + @Transactional(readOnly = true) + public PickResult getPick(PickCommand.Read command) { + var user = userReader.readCurrentUser(); + var pick = pickReader.readPick(user, command.pickId()); + return pickMapper.toReadResult(pick); + } + + @Override + @Transactional + public PickResult saveNewPick(PickCommand.Create command) { + var user = userReader.readCurrentUser(); + var folder = folderReader.readFolder(user, command.parentFolderId()); + var link = linkWriter.writeLink(command.linkInfo()); + var pick = pickWriter.writePick(pickMapper.toEntity(command, user, folder, link)); + return pickMapper.toCreateResult(pick); + } + + @Override + @Transactional + public PickResult updatePick(PickCommand.Update command) { + var user = userReader.readCurrentUser(); + var pick = pickReader.readPick(user, command.pickId()) + .updateMemo(command.memo()) + .updateTagOrder(command.tagIdList()) + .updateTitle(command.title()); + return pickMapper.toUpdateResult(pick); + } + + @Override + @Transactional + public PickResult movePick(PickCommand.Move command) { + var user = userReader.readCurrentUser(); + var pick = pickReader.readPick(user, command.pickId()); + var originalParentFolder = pick.getParentFolder(); + + if (isParentFolderNotChanged(command, originalParentFolder)) { + originalParentFolder.updateChildPickOrder(command.pickId(), command.orderIdx()); + return pickMapper.toMoveResult(pick); + } + // if moving to another folder + originalParentFolder.removeChildPickOrder(command.pickId()); + var newParentFolder = folderReader.readFolder(user, command.parentFolderId()); + newParentFolder.updateChildPickOrder(command.pickId(), command.orderIdx()); + pick.updateParentFolder(newParentFolder); + return pickMapper.toMoveResult(pick); + } + + @Override + @Transactional + public void deletePick(PickCommand.Delete command) { + var user = userReader.readCurrentUser(); + var pick = pickReader.readPick(user, command.pickId()); + var folder = pick.getParentFolder(); + folder.removeChildPickOrder(command.pickId()); + pickWriter.removePick(pick); + } + + private boolean isParentFolderNotChanged(PickCommand.Move command, Folder originalFolder) { + return (command.parentFolderId() == null || originalFolder.getId().equals(command.parentFolderId())); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssCommand.java new file mode 100644 index 00000000..4cfbd7a0 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssCommand.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.domain.rss.dto; + +public class RssCommand { + + public record Create() {} + public record Read() {} + public record Update() {} + public record Delete() {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssMapper.java new file mode 100644 index 00000000..5496c9df --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssMapper.java @@ -0,0 +1,13 @@ +package kernel360.techpick.feature.domain.rss.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface RssMapper { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssResult.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssResult.java new file mode 100644 index 00000000..201109ac --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/dto/RssResult.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.domain.rss.dto; + +public class RssResult { + + public record Create() {} + public record Read() {} + public record Update() {} + public record Delete() {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssErrorCode.java new file mode 100644 index 00000000..665d4dbd --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssErrorCode.java @@ -0,0 +1,63 @@ +package kernel360.techpick.feature.domain.rss.exception; + +import org.springframework.http.HttpStatus; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.level.ErrorLevel; + +public enum ApiRssErrorCode implements ApiErrorCode { + + /** + * Rss Error Code (RS) + */ + RSS_NOT_FOUND("RS-000", HttpStatus.NOT_FOUND, "RSS 피드 렌더링 오류 또는 RSS 지원하지 않는 사이트", ErrorLevel.SHOULD_NOT_HAPPEN()); + + // ------------------------------------------------------------ + // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다. + // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요. + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel errorLevel; + + ApiRssErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.errorLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.errorLevel; + } + + @Override + public String convertCodeToString(ApiErrorCode errorCode) { + return ApiErrorCode.super.convertCodeToString(errorCode); + } + + @Override + public String toString() { + return convertCodeToString(this); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssException.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssException.java new file mode 100644 index 00000000..e2aa4b74 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/exception/ApiRssException.java @@ -0,0 +1,16 @@ +package kernel360.techpick.feature.domain.rss.exception; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.base.ApiException; + +public class ApiRssException extends ApiException { + + private ApiRssException(ApiErrorCode errorCode) { + super(errorCode); + } + + public static ApiRssException RSS_NOT_FOUND() { + return new ApiRssException(ApiRssErrorCode.RSS_NOT_FOUND); + } + +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssService.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssService.java new file mode 100644 index 00000000..fbdaace0 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssService.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.domain.rss.service; + +public interface RssService { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssServiceImpl.java new file mode 100644 index 00000000..2e099608 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/rss/service/RssServiceImpl.java @@ -0,0 +1,10 @@ +package kernel360.techpick.feature.domain.rss.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RssServiceImpl implements RssService { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagCommand.java new file mode 100644 index 00000000..0613ef53 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagCommand.java @@ -0,0 +1,34 @@ +package kernel360.techpick.feature.domain.tag.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public class TagCommand { + + public record Create( + String name, + Integer colorNumber) { + } + + public record Read( + Long tagId) { + } + + public record Update( + Long tagId, + String name, + Integer colorNumber) { + } + + public record Move( + Long tagId, + int orderIdx + ) { + } + + public record Delete( + Long tagId + ) { + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagMapper.java new file mode 100644 index 00000000..b1f21f46 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagMapper.java @@ -0,0 +1,22 @@ +package kernel360.techpick.feature.domain.tag.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +import kernel360.techpick.core.model.tag.Tag; +import kernel360.techpick.core.model.user.User; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface TagMapper { + + @Mapping(source = "tag.user.id", target = "userId") + TagResult toResult(Tag tag); + + Tag toEntity(TagCommand.Create create, User user); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagResult.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagResult.java new file mode 100644 index 00000000..aac5911f --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/dto/TagResult.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.domain.tag.dto; + +public record TagResult( + Long id, + String name, + Integer colorNumber, + Long userId +) { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagErrorCode.java new file mode 100644 index 00000000..ca82a35e --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagErrorCode.java @@ -0,0 +1,72 @@ +package kernel360.techpick.feature.domain.tag.exception; + +import org.springframework.http.HttpStatus; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.level.ErrorLevel; + +public enum ApiTagErrorCode implements ApiErrorCode { + + /** + * Tag Error Code (TG) + */ + TAG_NOT_FOUND + ("TG-000", HttpStatus.BAD_REQUEST, "존재하지 않는 태그", ErrorLevel.CAN_HAPPEN()), + + TAG_ALREADY_EXIST + ("TG-001", HttpStatus.BAD_REQUEST, "이미 존재하는 태그", ErrorLevel.CAN_HAPPEN()), + + TAG_INVALID_NAME + ("TG-002", HttpStatus.BAD_REQUEST, "유효하지 않은 태그 이름", ErrorLevel.CAN_HAPPEN()), + + UNAUTHORIZED_TAG_ACCESS + ("TG-003", HttpStatus.UNAUTHORIZED, "잘못된 태그 접근", ErrorLevel.SHOULD_NOT_HAPPEN()), + + TAG_INVALID_ORDER + ("TG-004", HttpStatus.BAD_REQUEST, "유효하지 않은 태그 순서", ErrorLevel.SHOULD_NOT_HAPPEN()), + ; + + // ------------------------------------------------------------ + // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다. + // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요. + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel logLevel; + + ApiTagErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.logLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.logLevel; + } + + @Override + public String toString() { + return convertCodeToString(this); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagException.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagException.java new file mode 100644 index 00000000..a37d0428 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/exception/ApiTagException.java @@ -0,0 +1,36 @@ +package kernel360.techpick.feature.domain.tag.exception; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.base.ApiException; + +public class ApiTagException extends ApiException { + + private ApiTagException(ApiErrorCode errorCode) { + super(errorCode); + } + + /** + * TODO: Implement static factory method + */ + + public static ApiTagException TAG_NOT_FOUND() { + return new ApiTagException(ApiTagErrorCode.TAG_NOT_FOUND); + } + + public static ApiTagException TAG_ALREADY_EXIST() { + return new ApiTagException(ApiTagErrorCode.TAG_ALREADY_EXIST); + } + + public static ApiTagException TAG_INVALID_NAME() { + return new ApiTagException(ApiTagErrorCode.TAG_INVALID_NAME); + } + + public static ApiTagException UNAUTHORIZED_TAG_ACCESS() { + return new ApiTagException(ApiTagErrorCode.UNAUTHORIZED_TAG_ACCESS); + } + + public static ApiTagException TAG_INVALID_ORDER() { + return new ApiTagException(ApiTagErrorCode.TAG_INVALID_ORDER); + } + +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagService.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagService.java new file mode 100644 index 00000000..82ea0459 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagService.java @@ -0,0 +1,21 @@ +package kernel360.techpick.feature.domain.tag.service; + +import java.util.List; + +import kernel360.techpick.feature.domain.tag.dto.TagCommand; +import kernel360.techpick.feature.domain.tag.dto.TagResult; + +public interface TagService { + + TagResult readTag(TagCommand.Read command); + + List readUserTagList(); + + TagResult createTag(TagCommand.Create command); + + TagResult updateTag(TagCommand.Update command); + + void moveUserTag(TagCommand.Move command); + + void deleteTag(TagCommand.Delete command); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagServiceImpl.java new file mode 100644 index 00000000..83bf8a8a --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/tag/service/TagServiceImpl.java @@ -0,0 +1,82 @@ +package kernel360.techpick.feature.domain.tag.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import kernel360.techpick.core.model.tag.Tag; +import kernel360.techpick.core.model.user.User; +import kernel360.techpick.feature.domain.tag.dto.TagCommand; +import kernel360.techpick.feature.domain.tag.dto.TagMapper; +import kernel360.techpick.feature.domain.tag.dto.TagResult; +import kernel360.techpick.feature.domain.tag.exception.ApiTagException; +import kernel360.techpick.feature.infrastructure.pick.writer.PickWriter; +import kernel360.techpick.feature.infrastructure.tag.reader.TagReader; +import kernel360.techpick.feature.infrastructure.tag.writer.TagWriter; +import kernel360.techpick.feature.infrastructure.user.reader.UserReader; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class TagServiceImpl implements TagService { + + private final TagReader tagReader; + private final TagWriter tagWriter; + private final TagMapper tagMapper; + private final PickWriter pickWriter; + private final UserReader userReader; + + @Override + @Transactional(readOnly = true) + public TagResult readTag(TagCommand.Read command) { + Long userId = userReader.readCurrentUserId(); + Tag tag = tagReader.readTag(userId, command.tagId()); + return tagMapper.toResult(tag); + } + + @Override + @Transactional(readOnly = true) + public List readUserTagList() { + Long userId = userReader.readCurrentUserId(); + List tagList = tagReader.readTagList(userId); + return tagList.stream().map(tagMapper::toResult).toList(); + } + + @Override + @Transactional + public TagResult createTag(TagCommand.Create command) { + User user = userReader.readCurrentUser(); + Tag tag = tagMapper.toEntity(command, user); + tagWriter.writeTag(tag); + return tagMapper.toResult(tag); + } + + @Override + @Transactional + public TagResult updateTag(TagCommand.Update command) { + Long userId = userReader.readCurrentUserId(); + Tag tag = tagReader.readTag(userId, command.tagId()); + tag.updateTagName(command.name()); + tag.updateColorNumber(command.colorNumber()); + return tagMapper.toResult(tag); + } + + @Override + @Transactional + public void moveUserTag(TagCommand.Move command) { + User user = userReader.readCurrentUser(); + List userTagOrderList = user.getTagOrderList(); + + userTagOrderList.remove(command.tagId()); + userTagOrderList.add(command.orderIdx(), command.tagId()); + user.updateTagOrderList(userTagOrderList); + } + + @Override + @Transactional + public void deleteTag(TagCommand.Delete command) { + tagWriter.removeTag(command.tagId()); + pickWriter.removePickTag(command.tagId()); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserCommand.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserCommand.java new file mode 100644 index 00000000..18bbec6e --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserCommand.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.domain.user.dto; + +public class UserCommand { + + public record Create() {} + public record Read() {} + public record Update() {} + public record Delete() {} +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserMapper.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserMapper.java new file mode 100644 index 00000000..09e2d095 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/dto/UserMapper.java @@ -0,0 +1,13 @@ +package kernel360.techpick.feature.domain.user.dto; + +import org.mapstruct.InjectionStrategy; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + injectionStrategy = InjectionStrategy.CONSTRUCTOR, + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface UserMapper { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserErrorCode.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserErrorCode.java new file mode 100644 index 00000000..19cbdc56 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserErrorCode.java @@ -0,0 +1,61 @@ +package kernel360.techpick.feature.domain.user.exception; + +import org.springframework.http.HttpStatus; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.level.ErrorLevel; + +public enum ApiUserErrorCode implements ApiErrorCode { + + /** + * User Error Code (U) + * */ + USER_NOT_FOUND + ("U-000", HttpStatus.BAD_REQUEST, "사용자 없음", ErrorLevel.CAN_HAPPEN()), + + ; + + // ------------------------------------------------------------ + // 하단 코드는 모든 ApiErrorCode 들에 반드시 포함되야 합니다. + // 새로운 ErrorCode 구현시 복사 붙여넣기 해주세요. + + private final String code; + + private final HttpStatus httpStatus; + + private final String errorMessage; + + private final ErrorLevel logLevel; + + ApiUserErrorCode(String code, HttpStatus status, String message, ErrorLevel errorLevel) { + this.code = code; + this.httpStatus = status; + this.errorMessage = message; + this.logLevel = errorLevel; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getMessage() { + return this.errorMessage; + } + + @Override + public HttpStatus getHttpStatus() { + return this.httpStatus; + } + + @Override + public ErrorLevel getErrorLevel() { + return this.logLevel; + } + + @Override + public String toString() { + return convertCodeToString(this); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserException.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserException.java new file mode 100644 index 00000000..c9b3a2dc --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/exception/ApiUserException.java @@ -0,0 +1,18 @@ +package kernel360.techpick.feature.domain.user.exception; + +import kernel360.techpick.core.exception.base.ApiErrorCode; +import kernel360.techpick.core.exception.base.ApiException; + +public class ApiUserException extends ApiException { + + private ApiUserException(ApiErrorCode errorCode) { + super(errorCode); + } + + /** + * TODO: Implement static factory method + * */ + public static ApiUserException USER_NOT_FOUND() { + return new ApiUserException(ApiUserErrorCode.USER_NOT_FOUND); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserService.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserService.java new file mode 100644 index 00000000..1cd34552 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserService.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.domain.user.service; + +public interface UserService { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserServiceImpl.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserServiceImpl.java new file mode 100644 index 00000000..578064dc --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/service/UserServiceImpl.java @@ -0,0 +1,10 @@ +package kernel360.techpick.feature.domain.user.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/util/NameGenerator.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/util/NameGenerator.java new file mode 100644 index 00000000..92dfe71a --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/util/NameGenerator.java @@ -0,0 +1,5 @@ +package kernel360.techpick.feature.domain.user.util; + +public interface NameGenerator { + String generateName(); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/domain/user/util/SimpleNameGenerator.java b/backend/src/main/java/kernel360/techpick/feature/domain/user/util/SimpleNameGenerator.java new file mode 100644 index 00000000..5f046db7 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/domain/user/util/SimpleNameGenerator.java @@ -0,0 +1,8 @@ +package kernel360.techpick.feature.domain.user.util; + +public class SimpleNameGenerator implements NameGenerator { + @Override + public String generateName() { + return "RANDOM_NICKNAME"; + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReader.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReader.java new file mode 100644 index 00000000..7c75d492 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReader.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.infrastructure.folder.reader; + +import kernel360.techpick.core.model.folder.Folder; +import kernel360.techpick.core.model.user.User; + +public interface FolderReader { + + Folder readFolder(User user, Long folderId); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReaderImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReaderImpl.java new file mode 100644 index 00000000..1bd0e201 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/reader/FolderReaderImpl.java @@ -0,0 +1,25 @@ +package kernel360.techpick.feature.infrastructure.folder.reader; + +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Component; + +import kernel360.techpick.core.model.folder.Folder; +import kernel360.techpick.core.model.folder.FolderRepository; +import kernel360.techpick.core.model.user.User; +import kernel360.techpick.feature.domain.folder.exception.ApiFolderException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class FolderReaderImpl implements FolderReader { + FolderRepository folderRepository; + + @Override + public Folder readFolder(User user, Long folderId) { + Folder folder = folderRepository.findById(folderId).orElseThrow(ApiFolderException::FOLDER_NOT_FOUND); + if (ObjectUtils.notEqual(folder.getUser(), user)) { + throw ApiFolderException.FOLDER_ACCESS_DENIED(); + } + return folder; + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriter.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriter.java new file mode 100644 index 00000000..2d90d704 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriter.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.infrastructure.folder.writer; + +public interface FolderWriter { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriterImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriterImpl.java new file mode 100644 index 00000000..89946d6c --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/folder/writer/FolderWriterImpl.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.infrastructure.folder.writer; + +public class FolderWriterImpl implements FolderWriter { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReader.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReader.java new file mode 100644 index 00000000..26b0a0ac --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReader.java @@ -0,0 +1,8 @@ +package kernel360.techpick.feature.infrastructure.link.reader; + +import kernel360.techpick.core.model.link.Link; + +public interface LinkReader { + + Link read(String url); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReaderImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReaderImpl.java new file mode 100644 index 00000000..4721f9f4 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/reader/LinkReaderImpl.java @@ -0,0 +1,19 @@ +package kernel360.techpick.feature.infrastructure.link.reader; + +import org.springframework.stereotype.Component; + +import kernel360.techpick.core.model.link.Link; +import kernel360.techpick.core.model.link.LinkRepository; +import kernel360.techpick.feature.domain.link.exception.ApiLinkException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LinkReaderImpl implements LinkReader { + private final LinkRepository linkRepository; + + @Override + public Link read(String url) { + return linkRepository.findByUrl(url).orElseThrow(ApiLinkException::LINK_NOT_FOUND); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriter.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriter.java new file mode 100644 index 00000000..e1f26348 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriter.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.infrastructure.link.writer; + +import kernel360.techpick.core.model.link.Link; +import kernel360.techpick.feature.domain.link.dto.LinkInfo; + +public interface LinkWriter { + + Link writeLink(LinkInfo linkInfo); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriterImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriterImpl.java new file mode 100644 index 00000000..7f7d7730 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/link/writer/LinkWriterImpl.java @@ -0,0 +1,33 @@ +package kernel360.techpick.feature.infrastructure.link.writer; + +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import jakarta.transaction.Transactional; +import kernel360.techpick.core.model.link.Link; +import kernel360.techpick.core.model.link.LinkRepository; +import kernel360.techpick.core.model.pick.Pick; +import kernel360.techpick.core.model.pick.PickRepository; +import kernel360.techpick.feature.domain.link.dto.LinkInfo; +import kernel360.techpick.feature.domain.link.dto.LinkMapper; +import kernel360.techpick.feature.domain.pick.exception.ApiPickException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LinkWriterImpl implements LinkWriter { + private final LinkRepository linkRepository; + private final LinkMapper linkMapper; + + @Override + @Transactional + public Link writeLink(LinkInfo info) { + Optional link = linkRepository.findByUrl(info.url()); + if (link.isPresent()) { + link.get().updateMetadata(info.title(), info.description(), info.imageUrl()); + return link.get(); + } + return linkRepository.save(linkMapper.of(info)); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReader.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReader.java new file mode 100644 index 00000000..78b86992 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReader.java @@ -0,0 +1,9 @@ +package kernel360.techpick.feature.infrastructure.pick.reader; + +import kernel360.techpick.core.model.pick.Pick; +import kernel360.techpick.core.model.user.User; + +public interface PickReader { + + Pick readPick(User user, Long pickId); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReaderImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReaderImpl.java new file mode 100644 index 00000000..5311cdfe --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/reader/PickReaderImpl.java @@ -0,0 +1,25 @@ +package kernel360.techpick.feature.infrastructure.pick.reader; + +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Component; + +import kernel360.techpick.core.model.pick.Pick; +import kernel360.techpick.core.model.pick.PickRepository; +import kernel360.techpick.core.model.user.User; +import kernel360.techpick.feature.domain.pick.exception.ApiPickException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PickReaderImpl implements PickReader { + private final PickRepository pickRepository; + + @Override + public Pick readPick(User user, Long pickId) { + Pick pick = pickRepository.findById(pickId).orElseThrow(ApiPickException::PICK_NOT_FOUND); + if (ObjectUtils.notEqual(user, pick.getUser())) { + throw ApiPickException.PICK_UNAUTHORIZED_ACCESS(); + } + return pick; + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriter.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriter.java new file mode 100644 index 00000000..07fe8224 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriter.java @@ -0,0 +1,13 @@ +package kernel360.techpick.feature.infrastructure.pick.writer; + +import kernel360.techpick.core.model.pick.Pick; +import kernel360.techpick.feature.domain.pick.exception.ApiPickException; + +public interface PickWriter { + + Pick writePick(Pick pick) throws ApiPickException; + + void removePick(Pick pick); + + void removePickTag(Long tagId); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriterImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriterImpl.java new file mode 100644 index 00000000..655c2e68 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/pick/writer/PickWriterImpl.java @@ -0,0 +1,49 @@ +package kernel360.techpick.feature.infrastructure.pick.writer; + +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import kernel360.techpick.core.model.pick.Pick; +import kernel360.techpick.core.model.pick.PickRepository; +import kernel360.techpick.core.model.pick.PickTag; +import kernel360.techpick.core.model.pick.PickTagRepository; +import kernel360.techpick.core.model.tag.Tag; +import kernel360.techpick.core.model.tag.TagRepository; +import kernel360.techpick.feature.domain.pick.exception.ApiPickException; +import kernel360.techpick.feature.domain.tag.exception.ApiTagException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PickWriterImpl implements PickWriter { + private final PickRepository pickRepository; + private final PickTagRepository pickTagRepository; + private final TagRepository tagRepository; + + @Override + public Pick writePick(Pick pick) throws ApiPickException { + pickRepository.findByUserAndLink(pick.getUser(), pick.getLink()) + .ifPresent((__) -> { + throw ApiPickException.PICK_MUST_BE_UNIQUE_FOR_A_URL(); + }); + Pick savedPick = pickRepository.save(pick); + + for (Long tagId : pick.getTagOrder()) { + Tag tag = tagRepository.findById(tagId).orElseThrow(ApiPickException::PICK_SET_WITH_UNEXISTING_TAG); + pickTagRepository.save(PickTag.of(savedPick, tag)); + } + return savedPick; + } + + @Override + public void removePick(Pick pick) { + pickRepository.delete(pick); + pickTagRepository.deleteByPick(pick); + } + + @Override + public void removePickTag(Long tagId) { + pickTagRepository.deleteByTagId(tagId); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReader.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReader.java new file mode 100644 index 00000000..42d52186 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReader.java @@ -0,0 +1,14 @@ +package kernel360.techpick.feature.infrastructure.tag.reader; + +import java.util.List; + +import kernel360.techpick.core.model.tag.Tag; + +public interface TagReader { + + Tag readTag(Long userId, Long tagId); + + List readTagList(Long userId); + + boolean checkDuplicateTagName(Long userId, String name); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReaderImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReaderImpl.java new file mode 100644 index 00000000..becf3304 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/reader/TagReaderImpl.java @@ -0,0 +1,39 @@ +package kernel360.techpick.feature.infrastructure.tag.reader; + +import java.util.List; + +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Component; + +import kernel360.techpick.core.model.tag.Tag; +import kernel360.techpick.core.model.tag.TagRepository; +import kernel360.techpick.feature.domain.tag.exception.ApiTagException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TagReaderImpl implements TagReader { + + private TagRepository tagRepository; + + @Override + public Tag readTag(Long userId, Long tagId) { + Tag tag = tagRepository.findById(tagId).orElseThrow(ApiTagException::TAG_NOT_FOUND); + + if (ObjectUtils.notEqual(tag.getUser().getId(), userId)) { + throw ApiTagException.UNAUTHORIZED_TAG_ACCESS(); + } + + return tag; + } + + @Override + public List readTagList(Long userId) { + return tagRepository.findAllByUserId(userId); + } + + @Override + public boolean checkDuplicateTagName(Long userId, String name) { + return tagRepository.existsByUserIdAndName(userId, name); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriter.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriter.java new file mode 100644 index 00000000..591766f5 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriter.java @@ -0,0 +1,10 @@ +package kernel360.techpick.feature.infrastructure.tag.writer; + +import kernel360.techpick.core.model.tag.Tag; + +public interface TagWriter { + + Tag writeTag(Tag tag); + + void removeTag(Long tagId); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriterImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriterImpl.java new file mode 100644 index 00000000..d7fe5373 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/tag/writer/TagWriterImpl.java @@ -0,0 +1,32 @@ +package kernel360.techpick.feature.infrastructure.tag.writer; + +import org.springframework.stereotype.Component; + +import kernel360.techpick.core.model.tag.Tag; +import kernel360.techpick.core.model.tag.TagRepository; +import kernel360.techpick.feature.domain.tag.exception.ApiTagException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TagWriterImpl implements TagWriter { + + private final TagRepository tagRepository; + + @Override + public Tag writeTag(Tag tag) { + validateDuplicateTagName(tag.getUser().getId(), tag.getName()); + return tagRepository.save(tag); + } + + @Override + public void removeTag(Long tagId) { + tagRepository.deleteById(tagId); + } + + private void validateDuplicateTagName(Long userId, String name) throws ApiTagException { + if (tagRepository.existsByUserIdAndName(userId, name)) { + throw ApiTagException.TAG_ALREADY_EXIST(); + } + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReader.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReader.java new file mode 100644 index 00000000..9cc91526 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReader.java @@ -0,0 +1,12 @@ +package kernel360.techpick.feature.infrastructure.user.reader; + +import kernel360.techpick.core.model.user.User; + +public interface UserReader { + + User readUser(Long userId); + + User readCurrentUser(); + + Long readCurrentUserId(); +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReaderImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReaderImpl.java new file mode 100644 index 00000000..d2921007 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/reader/UserReaderImpl.java @@ -0,0 +1,33 @@ +package kernel360.techpick.feature.infrastructure.user.reader; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import kernel360.techpick.core.model.user.User; +import kernel360.techpick.core.model.user.UserRepository; +import kernel360.techpick.feature.domain.user.exception.ApiUserException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class UserReaderImpl implements UserReader { + UserRepository userRepository; + + @Override + public User readUser(Long userId) { + return userRepository.findById(userId).orElseThrow(ApiUserException::USER_NOT_FOUND); + } + + @Override + public User readCurrentUser() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + long userId = (long)authentication.getPrincipal(); + return userRepository.findById(userId).orElseThrow(ApiUserException::USER_NOT_FOUND); + } + + @Override + public Long readCurrentUserId() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + return (long)authentication.getPrincipal(); + } +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriter.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriter.java new file mode 100644 index 00000000..ace0ebd2 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriter.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.infrastructure.user.writer; + +public interface UserWriter { +} diff --git a/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriterImpl.java b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriterImpl.java new file mode 100644 index 00000000..ac3a2417 --- /dev/null +++ b/backend/src/main/java/kernel360/techpick/feature/infrastructure/user/writer/UserWriterImpl.java @@ -0,0 +1,4 @@ +package kernel360.techpick.feature.infrastructure.user.writer; + +public class UserWriterImpl implements UserWriter { +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml new file mode 100644 index 00000000..2aa2d519 --- /dev/null +++ b/backend/src/main/resources/application.yaml @@ -0,0 +1,44 @@ +# 참고 설정 +# https://github.com/Kernel360/E2E2-TALKKA/blob/develop/server/src/main/resources/application.yaml +# https://github.com/kimminkyeu/E2E2-LOOK-US/blob/develop/backend/src/main/resources/application.yml + +spring: + application: + name: techpick-server + output: + ansi: + enabled: always + sql: + init: + mode: never # schema.sql 실행시 always 키고 실행하시면 됩니다. option: never, always + + jpa: + properties: + hibernate: + format_sql: false + show_sql: false + hibernate: + ddl-auto: update # 이 부분 설정 주의 필요 + datasource: + url: ${DOCKER_MYSQL_URL} + username: ${DOCKER_MYSQL_USERNAME} + password: ${DOCKER_MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jwt: + secret: + ${JWT_SECRET} + issuer: + ${JWT_ISSUER} + # OAuth2 관련 설정 + config: + import: classpath:oauth2-config.yaml + +springdoc: + swagger-ui: + disable-swagger-default-url: true + api-docs: + path: /api-docs + show-actuator: true + +api: + base-url: ${TECHPICK_BASE_URL} diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..161b6928 --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + + + 100MB + + ${LOG_PATH}/${DATE_DIR}/application.%i.log + + 20GB + + 60 + + + + [%d{yyyy-MM-dd HH:mm:ss}:%-3relative][%thread] %-5level %logger{35}[%line] - %msg%n + + + + + + + + + 100MB + + ${LOG_PATH}/${DATE_DIR}/sql.%i.log + + 20GB + + 60 + + + + [%d{yyyy-MM-dd HH:mm:ss}:%-3relative][%thread] %-5level %logger{35}[%line] - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/oauth2-config.yaml b/backend/src/main/resources/oauth2-config.yaml new file mode 100644 index 00000000..008bfe0a --- /dev/null +++ b/backend/src/main/resources/oauth2-config.yaml @@ -0,0 +1,51 @@ +spring: + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${TECHPICK_BASE_URL}/api/login/oauth2/code/google + scope: + - email + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + client-name: Naver + redirect-uri: ${TECHPICK_BASE_URL}/api/login/oauth2/code/naver + authorization-grant-type: authorization_code + scope: + - email + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + client-name: Kakao + redirect-uri: ${TECHPICK_BASE_URL}/api/login/oauth2/code/kakao + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: + - account_email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + +oauth2-attribute-config-provider: + attributeConfig: + google: + name: "sub" + email: "email" + kakao: + name: "id" + email: "email" + naver: + name: "id" + email: "email" diff --git a/backend/src/main/resources/static/favicon.ico b/backend/src/main/resources/static/favicon.ico new file mode 100644 index 00000000..996c3406 Binary files /dev/null and b/backend/src/main/resources/static/favicon.ico differ diff --git a/backend/src/test/java/domain/pick/PickTest.java b/backend/src/test/java/domain/pick/PickTest.java new file mode 100644 index 00000000..de54519f --- /dev/null +++ b/backend/src/test/java/domain/pick/PickTest.java @@ -0,0 +1,7 @@ +package domain.pick; + +// TEST FOR PICK +// 1. Implement Pick Fixture +public class PickTest { + +} diff --git a/backend/src/test/java/kernel360/techpick/TechpickApplicationTests.java b/backend/src/test/java/kernel360/techpick/TechpickApplicationTests.java new file mode 100644 index 00000000..e08791e4 --- /dev/null +++ b/backend/src/test/java/kernel360/techpick/TechpickApplicationTests.java @@ -0,0 +1,11 @@ +package kernel360.techpick; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class TechpickApplicationTests { + + @Test + void contextLoads() {} +} \ No newline at end of file diff --git a/backend/src/test/resources/application-test.yaml b/backend/src/test/resources/application-test.yaml new file mode 100644 index 00000000..f97c90c2 --- /dev/null +++ b/backend/src/test/resources/application-test.yaml @@ -0,0 +1,17 @@ +spring: + config: + activate: + on-profile: test + import: file:.env[.properties] + datasource: + url: ${DOCKER_MYSQL_TEST_URL} + username: ${DOCKER_MYSQL_TEST_USERNAME} + password: ${DOCKER_MYSQL_TEST_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + properties: + hibernate: + format_sql: false + show_sql: true + hibernate: + ddl-auto: create-drop # 테스트용 db는 매 실행마다 초기화 되어야함 \ No newline at end of file