diff --git a/.github/workflows/backend_cd.yml b/.github/workflows/backend_cd.yml index 7764b508f..0b3c07984 100644 --- a/.github/workflows/backend_cd.yml +++ b/.github/workflows/backend_cd.yml @@ -3,53 +3,73 @@ name: Backend CD on: push: branches: + - main - dev/be jobs: build: - runs-on: [self-hosted, develop, spring] + runs-on: + - ubuntu-latest steps: - - name: 브랜치명을 통해 개발 환경 알아내기 - run: | - cd ${{ secrets.SCRIPT_DIRECTORY }} - bash find-env-by-branch.sh - - name: 체크아웃 uses: actions/checkout@v4 + - name: JDK 설치 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + - name: gradle 캐싱 - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v4 - name: bootJar로 jar 파일 생성 - run: | - ./gradlew bootJar - mv build/libs/*.jar ${{ secrets.WORK_DIRECTORY }}/${{ env.ENVIRONMENT }} - working-directory: ./backend + run: ./gradlew bootJar + working-directory: backend - - name: 클린업 - if: always() - run: rm -rf ../2024-code-zap/* + - name: Artifact 업로드 + uses: actions/upload-artifact@v4 + with: + name: code-zap-jar + path: backend/build/libs/*.jar - deploy: + deploy_develop: needs: build - runs-on: [self-hosted, develop, spring] + if: ${{ github.ref == 'refs/heads/dev/be' }} + runs-on: + - self-hosted + - spring + - develop steps: - - name: 브랜치명을 통해 개발 환경 알아내기 - run: | - cd ${{ secrets.SCRIPT_DIRECTORY }} - bash find-env-by-branch.sh - - - name: 실행 프로세스 확인 - run: | - cd ${{ secrets.SCRIPT_DIRECTORY }} - bash check-old-pids.sh - + - name: Artifact 다운로드 + uses: actions/download-artifact@v4 + with: + name: code-zap-jar + path: ${{ secrets.SPRING_DIRECTORY }} - name: 배포 스크립트 실행 run: | - cd ${{ secrets.SCRIPT_DIRECTORY }} - RUNNER_TRACKING_ID="" && bash start.sh ${{ env.ENVIRONMENT }} + cd ${{ secrets.SPRING_DIRECTORY }} + mv code-zap*.jar ${{ secrets.JAR_NAME }} + docker compose restart - - name: 실행 프로세스 확인으로 배포 검증 + deploy_production: + needs: build + if: ${{ github.ref == 'refs/heads/main' }} + strategy: + matrix: + environment: [prod_a, prod_b] + runs-on: + - self-hosted + - spring + - ${{ matrix.environment }} + steps: + - name: Artifact 다운로드 + uses: actions/download-artifact@v4 + with: + name: code-zap-jar + path: ${{ secrets.SPRING_DIRECTORY }} + - name: 배포 스크립트 실행 run: | - cd ${{ secrets.SCRIPT_DIRECTORY }} - bash verify-deploy.sh + cd ${{ secrets.SPRING_DIRECTORY }} + mv code-zap*.jar ${{ secrets.JAR_NAME }} + docker compose restart diff --git a/.github/workflows/backend_cd_prod.yml b/.github/workflows/backend_cd_prod.yml deleted file mode 100644 index c583d0e18..000000000 --- a/.github/workflows/backend_cd_prod.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Backend CD - -on: - push: - branches: - - main - -jobs: - build: - runs-on: [self-hosted, production, spring] - steps: - - name: 체크아웃 - uses: actions/checkout@v4 - - - name: gradle 캐싱 - uses: gradle/actions/setup-gradle@v3 - - - name: bootJar로 jar 파일 생성 - run: | - ./gradlew bootJar - mv build/libs/*.jar ${{ secrets.JAR_DIRECTORY }} - working-directory: ./backend - - - name: 클린업 - if: always() - run: rm -rf ../2024-code-zap/* - - deploy: - needs: build - runs-on: [self-hosted, production, spring] - steps: - - name: 배포 스크립트 실행 - run: | - cd ${{ secrets.ZAP_DIRECTORY }} - docker compose restart diff --git a/.github/workflows/backend_ci.yml b/.github/workflows/backend_ci.yml index e5c34b7a9..ceb015d92 100644 --- a/.github/workflows/backend_ci.yml +++ b/.github/workflows/backend_ci.yml @@ -27,6 +27,9 @@ jobs: working-directory: ./backend/src/main/resources run: echo "${{ secrets.APPLICATION_DB_YAML }}" > application-db.yml + - name: gradle 캐싱 + uses: gradle/actions/setup-gradle@v4 + - name: JDK 17 설정 uses: actions/setup-java@v4 with: diff --git a/backend/.gitignore b/backend/.gitignore index f18c7b47b..d4af9460a 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -21,3 +21,7 @@ out/ ### YAML ### application-db.yml + +### compose ### +docker/app/*.jar +docker/.env diff --git a/backend/docker/app/application.yml b/backend/docker/app/application.yml new file mode 100644 index 000000000..b0444e73c --- /dev/null +++ b/backend/docker/app/application.yml @@ -0,0 +1,24 @@ +spring: + config: + activate: + on-profile: local + datasource: + url: jdbc:mysql://mysql:3306/${MYSQL_DATABASE}?serverTimezone=Asia/Seoul + username: root + password: ${MYSQL_ROOT_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + flyway: + enabled: false + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + show_sql: true + output: + ansi: + enabled: always + +#cors: +# allowed-origins: http://localhost:3000 diff --git a/backend/docker/compose.yml b/backend/docker/compose.yml new file mode 100644 index 000000000..77747abd5 --- /dev/null +++ b/backend/docker/compose.yml @@ -0,0 +1,35 @@ +name: code-zap + +services: + mysql: + image: mysql:8.4.2 + expose: + - ${MYSQL_PORT} + ports: + - ${HOST_PORT}:${MYSQL_PORT} + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + TZ: Asia/Seoul + volumes: + - code-zap:/var/lib/mysql + + spring: + image: amazoncorretto:17 + ports: + - "8080:8080" + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + TZ: Asia/Seoul + volumes: + - ./app:/app + entrypoint: [ + "java", "-jar", + "-Dspring.config.location=/app/application.yml", + "-Dspring.profiles.active=local", + "/app/zap.jar" + ] + +volumes: + code-zap: diff --git a/backend/docker/run.sh b/backend/docker/run.sh new file mode 100644 index 000000000..6e4270682 --- /dev/null +++ b/backend/docker/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +abs_path=$(readlink -f "$0"); +abs_dir=$(dirname "$abs_path"); + +backend_path=$(realpath "$abs_path/../..") + +cd "$backend_path" || exit + +./gradlew clean +./gradlew bootJar + +jar_path="$backend_path/build/libs" +jar_filename=$(ls -tr "$jar_path"/*.jar | grep ".*\.jar") + +mkdir "$abs_dir/app" +mv "$jar_filename" "$abs_dir/app/zap.jar" + +cd "$abs_dir" || exit +docker compose up -d diff --git a/backend/src/main/java/codezap/CodeZapApplication.java b/backend/src/main/java/codezap/CodeZapApplication.java index 3e53ac31c..195932a87 100644 --- a/backend/src/main/java/codezap/CodeZapApplication.java +++ b/backend/src/main/java/codezap/CodeZapApplication.java @@ -5,9 +5,7 @@ @SpringBootApplication public class CodeZapApplication { - public static void main(String[] args) { SpringApplication.run(CodeZapApplication.class, args); } - } diff --git a/backend/src/main/java/codezap/auth/configuration/AuthArgumentResolver.java b/backend/src/main/java/codezap/auth/configuration/AuthArgumentResolver.java index 2a49e178d..4a1febf92 100644 --- a/backend/src/main/java/codezap/auth/configuration/AuthArgumentResolver.java +++ b/backend/src/main/java/codezap/auth/configuration/AuthArgumentResolver.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.springframework.core.MethodParameter; +import org.springframework.lang.NonNull; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -10,7 +11,7 @@ import codezap.auth.manager.CredentialManager; import codezap.auth.provider.CredentialProvider; -import codezap.member.dto.MemberDto; +import codezap.member.domain.Member; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @@ -25,14 +26,14 @@ public boolean supportsParameter(MethodParameter parameter) { } @Override - public MemberDto resolveArgument( - MethodParameter parameter, + public Member resolveArgument( + @NonNull MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory ) { HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); String credential = credentialManager.getCredential(request); - return MemberDto.from(credentialProvider.extractMember(credential)); + return credentialProvider.extractMember(credential); } } diff --git a/backend/src/main/java/codezap/auth/configuration/AuthorizationInterceptor.java b/backend/src/main/java/codezap/auth/configuration/AuthorizationInterceptor.java deleted file mode 100644 index 8105075bb..000000000 --- a/backend/src/main/java/codezap/auth/configuration/AuthorizationInterceptor.java +++ /dev/null @@ -1,43 +0,0 @@ -package codezap.auth.configuration; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import org.springframework.http.HttpStatus; -import org.springframework.web.servlet.HandlerInterceptor; - -import codezap.auth.manager.CredentialManager; -import codezap.auth.provider.CredentialProvider; -import codezap.global.exception.CodeZapException; - -public class AuthorizationInterceptor implements HandlerInterceptor { - private final CredentialManager credentialManager; - private final CredentialProvider credentialProvider; - - public AuthorizationInterceptor(CredentialManager credentialManager, CredentialProvider credentialProvider) { - this.credentialManager = credentialManager; - this.credentialProvider = credentialProvider; - } - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { - String token = credentialManager.getCredential(request); - validatedTokeIsBlank(token); - validatedExistMember(token); - return true; - } - - private void validatedTokeIsBlank(final String token) { - if (token == null || token.isBlank()) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "회원의 인증 정보를 찾을 수 없습니다. 다시 로그인해주세요."); - } - } - - private void validatedExistMember(final String token) { - try { - credentialProvider.extractMember(token); - } catch (CodeZapException e) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다. 다시 로그인해주세요."); - } - } -} diff --git a/backend/src/main/java/codezap/auth/dto/response/LoginResponse.java b/backend/src/main/java/codezap/auth/dto/response/LoginResponse.java index dc70647d2..70b659992 100644 --- a/backend/src/main/java/codezap/auth/dto/response/LoginResponse.java +++ b/backend/src/main/java/codezap/auth/dto/response/LoginResponse.java @@ -3,7 +3,7 @@ import codezap.member.domain.Member; public record LoginResponse( - long memberId, + Long memberId, String name ) { public static LoginResponse from(Member member) { diff --git a/backend/src/main/java/codezap/auth/encryption/PasswordEncryptor.java b/backend/src/main/java/codezap/auth/encryption/PasswordEncryptor.java new file mode 100644 index 000000000..34b64d11a --- /dev/null +++ b/backend/src/main/java/codezap/auth/encryption/PasswordEncryptor.java @@ -0,0 +1,5 @@ +package codezap.auth.encryption; + +public interface PasswordEncryptor { + String encrypt(String plainPassword, String salt); +} diff --git a/backend/src/main/java/codezap/auth/encryption/RandomSaltGenerator.java b/backend/src/main/java/codezap/auth/encryption/RandomSaltGenerator.java new file mode 100644 index 000000000..a81884d30 --- /dev/null +++ b/backend/src/main/java/codezap/auth/encryption/RandomSaltGenerator.java @@ -0,0 +1,18 @@ +package codezap.auth.encryption; + +import java.security.SecureRandom; +import java.util.Base64; + +import org.springframework.stereotype.Component; + +@Component +public class RandomSaltGenerator implements SaltGenerator { + + @Override + public String generate() { + SecureRandom byteGenerator = new SecureRandom(); + byte[] saltByte = new byte[32]; + byteGenerator.nextBytes(saltByte); + return Base64.getEncoder().encodeToString(saltByte); + } +} diff --git a/backend/src/main/java/codezap/auth/encryption/SHA2PasswordEncryptor.java b/backend/src/main/java/codezap/auth/encryption/SHA2PasswordEncryptor.java new file mode 100644 index 000000000..bd93d2069 --- /dev/null +++ b/backend/src/main/java/codezap/auth/encryption/SHA2PasswordEncryptor.java @@ -0,0 +1,30 @@ +package codezap.auth.encryption; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import codezap.global.exception.CodeZapException; + +@Component +public class SHA2PasswordEncryptor implements PasswordEncryptor { + private final MessageDigest digest; + + public SHA2PasswordEncryptor() { + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new CodeZapException(HttpStatus.INTERNAL_SERVER_ERROR, "암호화 알고리즘이 잘못 명시되었습니다."); + } + } + + @Override + public String encrypt(String plainPassword, String salt) { + String passwordWithSalt = plainPassword + salt; + byte[] encryptByte = digest.digest(passwordWithSalt.getBytes()); + return Base64.getEncoder().encodeToString(encryptByte); + } +} diff --git a/backend/src/main/java/codezap/auth/encryption/SaltGenerator.java b/backend/src/main/java/codezap/auth/encryption/SaltGenerator.java new file mode 100644 index 000000000..f6d5eabd8 --- /dev/null +++ b/backend/src/main/java/codezap/auth/encryption/SaltGenerator.java @@ -0,0 +1,6 @@ +package codezap.auth.encryption; + +@FunctionalInterface +public interface SaltGenerator { + String generate(); +} diff --git a/backend/src/main/java/codezap/auth/provider/basic/BasicAuthCredentialProvider.java b/backend/src/main/java/codezap/auth/provider/basic/BasicAuthCredentialProvider.java index 9159dccfc..440128d26 100644 --- a/backend/src/main/java/codezap/auth/provider/basic/BasicAuthCredentialProvider.java +++ b/backend/src/main/java/codezap/auth/provider/basic/BasicAuthCredentialProvider.java @@ -26,7 +26,7 @@ public String createCredential(Member member) { @Override public Member extractMember(String credential) { String[] nameAndPassword = BasicAuthDecoder.decodeBasicAuth(credential); - Member member = memberRepository.fetchByname(nameAndPassword[0]); + Member member = memberRepository.fetchByName(nameAndPassword[0]); checkMatchPassword(member, nameAndPassword[1]); return member; } diff --git a/backend/src/main/java/codezap/auth/service/AuthService.java b/backend/src/main/java/codezap/auth/service/AuthService.java index d253674ae..5af9e21ef 100644 --- a/backend/src/main/java/codezap/auth/service/AuthService.java +++ b/backend/src/main/java/codezap/auth/service/AuthService.java @@ -6,6 +6,7 @@ import codezap.auth.dto.LoginAndCredentialDto; import codezap.auth.dto.request.LoginRequest; import codezap.auth.dto.response.LoginResponse; +import codezap.auth.encryption.PasswordEncryptor; import codezap.auth.provider.CredentialProvider; import codezap.global.exception.CodeZapException; import codezap.member.domain.Member; @@ -18,6 +19,7 @@ public class AuthService { private final CredentialProvider credentialProvider; private final MemberRepository memberRepository; + private final PasswordEncryptor passwordEncryptor; public LoginAndCredentialDto login(LoginRequest loginRequest) { Member member = getVerifiedMember(loginRequest.name(), loginRequest.password()); @@ -26,13 +28,15 @@ public LoginAndCredentialDto login(LoginRequest loginRequest) { } private Member getVerifiedMember(String name, String password) { - Member member = memberRepository.fetchByname(name); + Member member = memberRepository.fetchByName(name); validateCorrectPassword(member, password); return member; } private void validateCorrectPassword(Member member, String password) { - if (!member.matchPassword(password)) { + String salt = member.getSalt(); + String encryptedPassword = passwordEncryptor.encrypt(password, salt); + if (!member.matchPassword(encryptedPassword)) { throw new CodeZapException(HttpStatus.UNAUTHORIZED, "로그인에 실패하였습니다. 아이디 또는 비밀번호를 확인해주세요."); } } diff --git a/backend/src/main/java/codezap/category/controller/CategoryController.java b/backend/src/main/java/codezap/category/controller/CategoryController.java index 2d3a15687..26dc139e5 100644 --- a/backend/src/main/java/codezap/category/controller/CategoryController.java +++ b/backend/src/main/java/codezap/category/controller/CategoryController.java @@ -21,7 +21,7 @@ import codezap.category.dto.response.FindAllCategoriesResponse; import codezap.category.service.CategoryService; import codezap.global.validation.ValidationSequence; -import codezap.member.dto.MemberDto; +import codezap.member.domain.Member; import lombok.RequiredArgsConstructor; @RestController @@ -33,36 +33,31 @@ public class CategoryController implements SpringDocCategoryController { @PostMapping public ResponseEntity createCategory( - @AuthenticationPrinciple MemberDto memberDto, + @AuthenticationPrinciple Member member, @Validated(ValidationSequence.class) @RequestBody CreateCategoryRequest createCategoryRequest ) { - CreateCategoryResponse response = categoryService.create(memberDto, createCategoryRequest); - return ResponseEntity.created(URI.create("/categories/" + response.id())) - .body(response); + CreateCategoryResponse createdCategory = categoryService.create(member, createCategoryRequest); + return ResponseEntity.created(URI.create("/categories/" + createdCategory.id())).body(createdCategory); } @GetMapping - public ResponseEntity getCategories( - @AuthenticationPrinciple MemberDto memberDto, - @RequestParam Long memberId - ) { - return ResponseEntity.ok(categoryService.findAllByMember(memberId)); + public ResponseEntity getCategories(@RequestParam Long memberId) { + return ResponseEntity.ok(categoryService.findAllByMemberId(memberId)); } @PutMapping("/{id}") public ResponseEntity updateCategory( - @AuthenticationPrinciple MemberDto memberDto, + @AuthenticationPrinciple Member member, @PathVariable Long id, @Validated(ValidationSequence.class) @RequestBody UpdateCategoryRequest updateCategoryRequest ) { - categoryService.update(memberDto, id, updateCategoryRequest); + categoryService.update(member, id, updateCategoryRequest); return ResponseEntity.ok().build(); } @DeleteMapping("/{id}") - public ResponseEntity deleteCategory(@AuthenticationPrinciple MemberDto memberDto, @PathVariable Long id) { - categoryService.deleteById(memberDto, id); - return ResponseEntity.noContent() - .build(); + public ResponseEntity deleteCategory(@AuthenticationPrinciple Member member, @PathVariable Long id) { + categoryService.deleteById(member, id); + return ResponseEntity.noContent().build(); } } diff --git a/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java b/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java index 3608e4af5..ec8ef9312 100644 --- a/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java +++ b/backend/src/main/java/codezap/category/controller/SpringDocCategoryController.java @@ -9,7 +9,7 @@ import codezap.category.dto.response.FindAllCategoriesResponse; import codezap.global.swagger.error.ApiErrorResponse; import codezap.global.swagger.error.ErrorCase; -import codezap.member.dto.MemberDto; +import codezap.member.domain.Member; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; @@ -30,11 +30,11 @@ public interface SpringDocCategoryController { @Header(name = "생성된 카테고리의 API 경로", example = "/categories/1")}) @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories", errorCases = { @ErrorCase(description = "모든 필드 중 null인 값이 있는 경우", exampleMessage = "카테고리 이름이 null 입니다."), - @ErrorCase(description = "카테고리 이름이 255자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 255자까지 입력 가능합니다."), + @ErrorCase(description = "카테고리 이름이 15자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 15자까지 입력 가능합니다."), @ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다."), }) ResponseEntity createCategory( - MemberDto memberDto, + Member member, CreateCategoryRequest createCategoryRequest ); @@ -42,22 +42,20 @@ ResponseEntity createCategory( @Operation(summary = "카테고리 목록 조회", description = "생성된 모든 카테고리를 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = {@Content(schema = @Schema(implementation = FindAllCategoriesResponse.class))}) - ResponseEntity getCategories(MemberDto memberDto, Long memberId); + ResponseEntity getCategories(Long memberId); @SecurityRequirement(name = "쿠키 인증 토큰") @Operation(summary = "카테고리 수정", description = "해당하는 식별자의 카테고리를 수정합니다.") @ApiResponse(responseCode = "200", description = "카테고리 수정 성공") @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/categories/1", errorCases = { - @ErrorCase(description = "해당하는 id 값인 카테고리가 없는 경우", - exampleMessage = "식별자 1에 해당하는 카테고리가 존재하지 않습니다."), - @ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", - exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다."), + @ErrorCase(description = "카테고리 이름이 15자를 초과한 경우", exampleMessage = "카테고리 이름은 최대 15자까지 입력 가능합니다."), + @ErrorCase(description = "해당하는 id 값인 카테고리가 없는 경우", exampleMessage = "식별자 1에 해당하는 카테고리가 존재하지 않습니다."), + @ErrorCase(description = "동일한 이름의 카테고리가 존재하는 경우", exampleMessage = "이름이 Spring 인 카테고리가 이미 존재합니다."), }) @ApiErrorResponse(status = HttpStatus.FORBIDDEN, instance = "/categories/1", errorCases = { - @ErrorCase(description = "카테고리를 수정할 권한이 없는 경우", - exampleMessage = "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다.") + @ErrorCase(description = "카테고리를 수정할 권한이 없는 경우", exampleMessage = "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다.") }) - ResponseEntity updateCategory(MemberDto memberDto, Long id, UpdateCategoryRequest updateCategoryRequest); + ResponseEntity updateCategory(Member member, Long id, UpdateCategoryRequest updateCategoryRequest); @SecurityRequirement(name = "쿠키 인증 토큰") @Operation(summary = "카테고리 삭제", description = "해당하는 식별자의 카테고리를 삭제합니다.") @@ -74,5 +72,5 @@ ResponseEntity createCategory( @ErrorCase(description = "카테고리를 수정할 권한이 없는 경우", exampleMessage = "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다.") }) - ResponseEntity deleteCategory(MemberDto memberDto, Long id); + ResponseEntity deleteCategory(Member member, Long id); } diff --git a/backend/src/main/java/codezap/category/domain/Category.java b/backend/src/main/java/codezap/category/domain/Category.java index ee0cb60f6..e1048ea8e 100644 --- a/backend/src/main/java/codezap/category/domain/Category.java +++ b/backend/src/main/java/codezap/category/domain/Category.java @@ -1,36 +1,39 @@ package codezap.category.domain; -import java.util.Objects; - 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.Index; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import org.springframework.http.HttpStatus; + import codezap.global.auditing.BaseTimeEntity; +import codezap.global.exception.CodeZapException; import codezap.member.domain.Member; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor -@Getter @Table( - uniqueConstraints = { - @UniqueConstraint( - name = "name_with_member", - columnNames = {"member_id", "name"} - ) - } + uniqueConstraints = @UniqueConstraint( + name = "name_with_member", + columnNames = {"member_id", "name"} + ), + indexes = @Index(name = "idx_member_id", columnList = "member_id") ) +@Getter +@EqualsAndHashCode(of = "id", callSuper = false) public class Category extends BaseTimeEntity { private static final String DEFAULT_CATEGORY_NAME = "카테고리 없음"; @@ -62,20 +65,13 @@ public void updateName(String name) { this.name = name; } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; + public void validateAuthorization(Member member) { + if (!getMember().equals(member)) { + throw new CodeZapException(HttpStatus.UNAUTHORIZED, "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다."); } - Category category = (Category) o; - return Objects.equals(getId(), category.getId()); } - @Override - public int hashCode() { - return Objects.hash(getId()); + public boolean isDefault() { + return isDefault; } } diff --git a/backend/src/main/java/codezap/category/dto/request/CreateCategoryRequest.java b/backend/src/main/java/codezap/category/dto/request/CreateCategoryRequest.java index 9587c9d04..3dacbb10d 100644 --- a/backend/src/main/java/codezap/category/dto/request/CreateCategoryRequest.java +++ b/backend/src/main/java/codezap/category/dto/request/CreateCategoryRequest.java @@ -10,7 +10,7 @@ public record CreateCategoryRequest( @Schema(description = "카테고리 이름", example = "Spring") @NotBlank(message = "카테고리 이름이 null 입니다.", groups = NotNullGroup.class) - @Size(max = 255, message = "카테고리 이름은 최대 255자까지 입력 가능합니다.", groups = SizeCheckGroup.class) + @Size(max = 15, message = "카테고리 이름은 최대 15자까지 입력 가능합니다.", groups = SizeCheckGroup.class) String name ) { } diff --git a/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java b/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java index 33c524f77..cf5c96aa0 100644 --- a/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java +++ b/backend/src/main/java/codezap/category/repository/CategoryJpaRepository.java @@ -17,7 +17,7 @@ default Category fetchById(Long id) { () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 카테고리가 존재하지 않습니다.")); } - List findAllByMemberOrderById(Member member); + List findAllByMemberIdOrderById(Long memberId); boolean existsByNameAndMember(String categoryName, Member member); } diff --git a/backend/src/main/java/codezap/category/repository/CategoryRepository.java b/backend/src/main/java/codezap/category/repository/CategoryRepository.java index b940ce1dd..debb78f08 100644 --- a/backend/src/main/java/codezap/category/repository/CategoryRepository.java +++ b/backend/src/main/java/codezap/category/repository/CategoryRepository.java @@ -9,12 +9,10 @@ public interface CategoryRepository { Category fetchById(Long id); - List findAllByMemberOrderById(Member member); + List findAllByMemberIdOrderById(Long memberId); List findAll(); - boolean existsById(Long categoryId); - boolean existsByNameAndMember(String categoryName, Member member); Category save(Category category); diff --git a/backend/src/main/java/codezap/category/service/CategoryService.java b/backend/src/main/java/codezap/category/service/CategoryService.java index 400bb18e2..9ea73a9b6 100644 --- a/backend/src/main/java/codezap/category/service/CategoryService.java +++ b/backend/src/main/java/codezap/category/service/CategoryService.java @@ -12,8 +12,6 @@ import codezap.category.repository.CategoryRepository; import codezap.global.exception.CodeZapException; import codezap.member.domain.Member; -import codezap.member.dto.MemberDto; -import codezap.member.repository.MemberRepository; import codezap.template.repository.TemplateRepository; import lombok.RequiredArgsConstructor; @@ -23,32 +21,32 @@ public class CategoryService { private final CategoryRepository categoryRepository; private final TemplateRepository templateRepository; - private final MemberRepository memberRepository; @Transactional - public CreateCategoryResponse create(MemberDto memberDto, CreateCategoryRequest createCategoryRequest) { + public CreateCategoryResponse create(Member member, CreateCategoryRequest createCategoryRequest) { String categoryName = createCategoryRequest.name(); - Member member = memberRepository.fetchById(memberDto.id()); validateDuplicatedCategory(categoryName, member); Category category = categoryRepository.save(new Category(categoryName, member)); return CreateCategoryResponse.from(category); } - public FindAllCategoriesResponse findAllByMember(Long memberId) { - Member member = memberRepository.fetchById(memberId); - return FindAllCategoriesResponse.from(categoryRepository.findAllByMemberOrderById(member)); + public FindAllCategoriesResponse findAllByMemberId(Long memberId) { + return FindAllCategoriesResponse.from(categoryRepository.findAllByMemberIdOrderById(memberId)); } public FindAllCategoriesResponse findAll() { return FindAllCategoriesResponse.from(categoryRepository.findAll()); } + public Category fetchById(Long id) { + return categoryRepository.fetchById(id); + } + @Transactional - public void update(MemberDto memberDto, Long id, UpdateCategoryRequest updateCategoryRequest) { - Member member = memberRepository.fetchById(memberDto.id()); + public void update(Member member, Long id, UpdateCategoryRequest updateCategoryRequest) { validateDuplicatedCategory(updateCategoryRequest.name(), member); Category category = categoryRepository.fetchById(id); - validateAuthorizeMember(category, member); + category.validateAuthorization(member); category.updateName(updateCategoryRequest.name()); } @@ -58,23 +56,17 @@ private void validateDuplicatedCategory(String categoryName, Member member) { } } - public void deleteById(MemberDto memberDto, Long id) { + @Transactional + public void deleteById(Member member, Long id) { Category category = categoryRepository.fetchById(id); - Member member = memberRepository.fetchById(memberDto.id()); - validateAuthorizeMember(category, member); + category.validateAuthorization(member); if (templateRepository.existsByCategoryId(id)) { throw new CodeZapException(HttpStatus.BAD_REQUEST, "템플릿이 존재하는 카테고리는 삭제할 수 없습니다."); } - if (category.getIsDefault()) { + if (category.isDefault()) { throw new CodeZapException(HttpStatus.BAD_REQUEST, "기본 카테고리는 삭제할 수 없습니다."); } categoryRepository.deleteById(id); } - - private void validateAuthorizeMember(Category category, Member member) { - if (!category.getMember().equals(member)) { - throw new CodeZapException(HttpStatus.FORBIDDEN, "해당 카테고리를 수정 또는 삭제할 권한이 없는 유저입니다."); - } - } } diff --git a/backend/src/main/java/codezap/global/cors/CorsProperties.java b/backend/src/main/java/codezap/global/cors/CorsProperties.java new file mode 100644 index 000000000..339428dc4 --- /dev/null +++ b/backend/src/main/java/codezap/global/cors/CorsProperties.java @@ -0,0 +1,23 @@ +package codezap.global.cors; + + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +import lombok.Getter; + +@Getter +@ConfigurationProperties(prefix = "cors") +public class CorsProperties { + + private final String[] allowedOrigins; + private final String[] allowedOriginsPatterns; + + public CorsProperties( + @DefaultValue(value = "") String[] allowedOrigins, + @DefaultValue(value = "") String[] allowedOriginsPatterns + ) { + this.allowedOrigins = allowedOrigins; + this.allowedOriginsPatterns = allowedOriginsPatterns; + } +} diff --git a/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java b/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java index 3de4b7d9e..9c1b1bfcc 100644 --- a/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java +++ b/backend/src/main/java/codezap/global/cors/WebCorsConfiguration.java @@ -1,16 +1,26 @@ package codezap.global.cors; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration +@ConfigurationPropertiesScan public class WebCorsConfiguration implements WebMvcConfigurer { + + private final CorsProperties corsProperties; + + public WebCorsConfiguration(CorsProperties corsProperties) { + this.corsProperties = corsProperties; + } + @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowCredentials(true) - .allowedOriginPatterns("*") + .allowedOriginPatterns(corsProperties.getAllowedOriginsPatterns()) + .allowedOrigins(corsProperties.getAllowedOrigins()) .allowedMethods("*") .exposedHeaders("*"); } diff --git a/backend/src/main/java/codezap/global/exception/CodeZapException.java b/backend/src/main/java/codezap/global/exception/CodeZapException.java index 71af97c34..bfbf3ed1a 100644 --- a/backend/src/main/java/codezap/global/exception/CodeZapException.java +++ b/backend/src/main/java/codezap/global/exception/CodeZapException.java @@ -6,6 +6,7 @@ @Getter public class CodeZapException extends RuntimeException { + private final HttpStatusCode httpStatusCode; public CodeZapException(HttpStatusCode httpStatusCode, String message) { diff --git a/backend/src/main/java/codezap/global/logger/MDCFilter.java b/backend/src/main/java/codezap/global/logger/MDCFilter.java index 71cfb7d91..5ce41f3b1 100644 --- a/backend/src/main/java/codezap/global/logger/MDCFilter.java +++ b/backend/src/main/java/codezap/global/logger/MDCFilter.java @@ -10,13 +10,16 @@ import jakarta.servlet.ServletResponse; import org.slf4j.MDC; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import lombok.extern.slf4j.Slf4j; @Slf4j @Component +@Order(1) public class MDCFilter implements Filter { + private final String CORRELATION_ID = "correlationId"; @Override diff --git a/backend/src/main/java/codezap/global/logger/RequestResponseLogger.java b/backend/src/main/java/codezap/global/logger/RequestResponseLogger.java index 839163dd5..09507e816 100644 --- a/backend/src/main/java/codezap/global/logger/RequestResponseLogger.java +++ b/backend/src/main/java/codezap/global/logger/RequestResponseLogger.java @@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; @@ -18,6 +19,7 @@ @Slf4j @Component +@Order(2) public class RequestResponseLogger extends OncePerRequestFilter { @Override diff --git a/backend/src/main/java/codezap/global/rds/DataSourceConfig.java b/backend/src/main/java/codezap/global/rds/DataSourceConfig.java new file mode 100644 index 000000000..989cbfd99 --- /dev/null +++ b/backend/src/main/java/codezap/global/rds/DataSourceConfig.java @@ -0,0 +1,63 @@ +package codezap.global.rds; + +import java.util.HashMap; +import java.util.Map; + +import javax.sql.DataSource; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; + +import com.zaxxer.hikari.HikariDataSource; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +@Profile("prod") +@EnableJpaRepositories(basePackages = "codezap") +public class DataSourceConfig { + + @Bean + @ConfigurationProperties("spring.datasource.write") + public DataSource writeDataSource() { + return DataSourceBuilder.create().type(HikariDataSource.class).build(); + } + + @Bean + @ConfigurationProperties("spring.datasource.read") + public DataSource readeDataSource() { + return DataSourceBuilder.create().type(HikariDataSource.class).build(); + } + + @Bean + @DependsOn({"writeDataSource", "readeDataSource"}) + public DataSource routeDataSource() { + DataSourceRouter dataSourceRouter = new DataSourceRouter(); + DataSource writerDataSource = writeDataSource(); + DataSource readerDataSource = readeDataSource(); + + Map dataSourceMap = new HashMap<>(); + dataSourceMap.put(DataSourceRouter.WRITER_KEY, writerDataSource); + dataSourceMap.put(DataSourceRouter.READER_KEY, readerDataSource); + + dataSourceRouter.setTargetDataSources(dataSourceMap); + dataSourceRouter.setDefaultTargetDataSource(writerDataSource); + + return dataSourceRouter; + } + + @Bean + @Primary + @DependsOn({"routeDataSource"}) + public DataSource dataSource() { + return new LazyConnectionDataSourceProxy(routeDataSource()); + } +} diff --git a/backend/src/main/java/codezap/global/rds/DataSourceRouter.java b/backend/src/main/java/codezap/global/rds/DataSourceRouter.java new file mode 100644 index 000000000..9ffbe3ef9 --- /dev/null +++ b/backend/src/main/java/codezap/global/rds/DataSourceRouter.java @@ -0,0 +1,20 @@ +package codezap.global.rds; + +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +public class DataSourceRouter extends AbstractRoutingDataSource { + + public static final String READER_KEY = "read"; + public static final String WRITER_KEY = "write"; + + @Override + protected Object determineCurrentLookupKey() { + boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); + + if (readOnly) { + return READER_KEY; + } + return WRITER_KEY; + } +} diff --git a/backend/src/main/java/codezap/global/serialization/DateTimeFormatConfiguration.java b/backend/src/main/java/codezap/global/serialization/DateTimeFormatConfiguration.java index 959886ebe..85f47477e 100644 --- a/backend/src/main/java/codezap/global/serialization/DateTimeFormatConfiguration.java +++ b/backend/src/main/java/codezap/global/serialization/DateTimeFormatConfiguration.java @@ -10,6 +10,7 @@ @Configuration public class DateTimeFormatConfiguration { + private static final String DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; @Bean diff --git a/backend/src/main/java/codezap/global/swagger/AuthOperationCustomizer.java b/backend/src/main/java/codezap/global/swagger/AuthOperationCustomizer.java index ca2e048dc..e2622bb6c 100644 --- a/backend/src/main/java/codezap/global/swagger/AuthOperationCustomizer.java +++ b/backend/src/main/java/codezap/global/swagger/AuthOperationCustomizer.java @@ -27,6 +27,7 @@ */ @Component public class AuthOperationCustomizer implements OperationCustomizer { + private static final ObjectMapper objectMapper = new ObjectMapper(); @Override @@ -90,7 +91,7 @@ private String getExampleJsonString(ProblemDetail problemDetail) { */ private void hideAuthInfoParameter(Operation operation) { if (operation.getParameters() != null) { - operation.getParameters().removeIf(parameter -> parameter.getName().equals("memberDto")); + operation.getParameters().removeIf(parameter -> parameter.getName().equals("member")); } } } diff --git a/backend/src/main/java/codezap/global/swagger/SpringDocConfiguration.java b/backend/src/main/java/codezap/global/swagger/SpringDocConfiguration.java index 91254cc41..b3d3ee379 100644 --- a/backend/src/main/java/codezap/global/swagger/SpringDocConfiguration.java +++ b/backend/src/main/java/codezap/global/swagger/SpringDocConfiguration.java @@ -41,10 +41,13 @@ public OpenAPI openAPI() { List.of( new Server() .url("https://api.code-zap.com") - .description("클라우드에 배포되어 있는 테스트 서버입니다."), + .description("운영 서버"), + new Server() + .url("https://dev.code-zap.com") + .description("개발 서버"), new Server() .url("http://localhost:8080") - .description("BE 팀에서 사용할 로컬 환경 테스트를 위한 서버입니다.") + .description("로컬 서버") ) ) .addSecurityItem(new SecurityRequirement().addList("쿠키 인증 토큰")); diff --git a/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponses.java b/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponses.java index 2589cf96d..8d3ad4ff6 100644 --- a/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponses.java +++ b/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponses.java @@ -8,5 +8,6 @@ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ApiErrorResponses { + ApiErrorResponse[] value(); } diff --git a/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponsesCustomizer.java b/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponsesCustomizer.java index a2bcc78aa..766444dc5 100644 --- a/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponsesCustomizer.java +++ b/backend/src/main/java/codezap/global/swagger/error/ApiErrorResponsesCustomizer.java @@ -20,6 +20,7 @@ @Component public class ApiErrorResponsesCustomizer implements OperationCustomizer { + @Override public Operation customize(Operation operation, HandlerMethod handlerMethod) { List apiErrorResponses = getApiErrorResponses(handlerMethod); diff --git a/backend/src/main/java/codezap/global/validation/ByteLength.java b/backend/src/main/java/codezap/global/validation/ByteLength.java index 5b15f20b1..bbc3a1801 100644 --- a/backend/src/main/java/codezap/global/validation/ByteLength.java +++ b/backend/src/main/java/codezap/global/validation/ByteLength.java @@ -10,7 +10,7 @@ @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) -@Constraint(validatedBy = ByteLengthValidator.class) +@Constraint(validatedBy = {ByteLengthValidator.class, GroupedByteLengthValidator.class}) public @interface ByteLength { String message(); diff --git a/backend/src/main/java/codezap/global/validation/GroupedByteLengthValidator.java b/backend/src/main/java/codezap/global/validation/GroupedByteLengthValidator.java new file mode 100644 index 000000000..92e9e31cb --- /dev/null +++ b/backend/src/main/java/codezap/global/validation/GroupedByteLengthValidator.java @@ -0,0 +1,30 @@ +package codezap.global.validation; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class GroupedByteLengthValidator implements ConstraintValidator> { + + private int max; + private int min; + + @Override + public void initialize(ByteLength constraintAnnotation) { + max = constraintAnnotation.max(); + min = constraintAnnotation.min(); + } + + @Override + public boolean isValid(List strings, ConstraintValidatorContext constraintValidatorContext) { + return strings.stream() + .allMatch(this::isValid); + } + + private boolean isValid(String target) { + int byteLength = target.getBytes(StandardCharsets.UTF_8).length; + return min <= byteLength && byteLength <= max; + } +} diff --git a/backend/src/main/java/codezap/global/validation/ValidationGroups.java b/backend/src/main/java/codezap/global/validation/ValidationGroups.java index df4729a5c..8f36c360e 100644 --- a/backend/src/main/java/codezap/global/validation/ValidationGroups.java +++ b/backend/src/main/java/codezap/global/validation/ValidationGroups.java @@ -1,6 +1,7 @@ package codezap.global.validation; public class ValidationGroups { + public interface NotNullGroup {} public interface SourceCodeOrdinalGroup {} diff --git a/backend/src/main/java/codezap/likes/controller/LikesController.java b/backend/src/main/java/codezap/likes/controller/LikesController.java new file mode 100644 index 000000000..3699be8f6 --- /dev/null +++ b/backend/src/main/java/codezap/likes/controller/LikesController.java @@ -0,0 +1,31 @@ +package codezap.likes.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; + +import codezap.auth.configuration.AuthenticationPrinciple; +import codezap.likes.service.LikesService; +import codezap.member.domain.Member; +import lombok.RequiredArgsConstructor; + +@Controller +@RequiredArgsConstructor +public class LikesController implements SpringDocsLikesController { + + private final LikesService likesService; + + @PostMapping("like/{templateId}") + public ResponseEntity like(@AuthenticationPrinciple Member member, @PathVariable long templateId) { + likesService.like(member, templateId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("like/{templateId}") + public ResponseEntity cancelLike(@AuthenticationPrinciple Member member, @PathVariable long templateId) { + likesService.cancelLike(member, templateId); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/codezap/likes/controller/SpringDocsLikesController.java b/backend/src/main/java/codezap/likes/controller/SpringDocsLikesController.java new file mode 100644 index 000000000..43cb5040a --- /dev/null +++ b/backend/src/main/java/codezap/likes/controller/SpringDocsLikesController.java @@ -0,0 +1,38 @@ +package codezap.likes.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import codezap.global.swagger.error.ApiErrorResponse; +import codezap.global.swagger.error.ErrorCase; +import codezap.member.domain.Member; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "좋아요 API", description = "좋아요 기능 관련 API") +public interface SpringDocsLikesController { + + @SecurityRequirement(name = "쿠키 인증 토큰") + @Operation(summary = "좋아요", description = "템플릿을 좋아요 합니다.") + @ApiResponse(responseCode = "200", description = "좋아요 성공") + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/like/1", errorCases = { + @ErrorCase(description = "템플릿 Id 에 해당하는 템플릿이 존재하지 않는 경우", exampleMessage = "식별자 1에 해당하는 템플릿이 존재하지 않습니다.") + }) + @ApiErrorResponse(status = HttpStatus.UNAUTHORIZED, instance = "/like/1", errorCases = { + @ErrorCase(description = "인증 정보에 해당하는 멤버가 없는 경우", exampleMessage = "인증 정보가 정확하지 않습니다.") + }) + ResponseEntity like(Member member, long templateId); + + @SecurityRequirement(name = "쿠키 인증 토큰") + @Operation(summary = "좋아요 취소", description = "템플릿 좋아요를 취소합니다.") + @ApiResponse(responseCode = "200", description = "좋아요 취소 성공") + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, instance = "/dislike/1", errorCases = { + @ErrorCase(description = "템플릿 Id 에 해당하는 템플릿이 존재하지 않는 경우", exampleMessage = "식별자 1에 해당하는 템플릿이 존재하지 않습니다.") + }) + @ApiErrorResponse(status = HttpStatus.UNAUTHORIZED, instance = "/dislike/1", errorCases = { + @ErrorCase(description = "인증 정보에 해당하는 멤버가 없는 경우", exampleMessage = "인증 정보가 정확하지 않습니다.") + }) + ResponseEntity cancelLike(Member member, long templateId); +} diff --git a/backend/src/main/java/codezap/likes/domain/Likes.java b/backend/src/main/java/codezap/likes/domain/Likes.java new file mode 100644 index 000000000..cb7f2badf --- /dev/null +++ b/backend/src/main/java/codezap/likes/domain/Likes.java @@ -0,0 +1,39 @@ +package codezap.likes.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; + +import codezap.global.auditing.BaseTimeEntity; +import codezap.member.domain.Member; +import codezap.template.domain.Template; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@EqualsAndHashCode(of = "id", callSuper = false) +public class Likes extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + private Template template; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + private Member member; + + public Likes(Template template, Member member) { + this(null, template, member); + } +} diff --git a/backend/src/main/java/codezap/likes/repository/LikesJpaRepository.java b/backend/src/main/java/codezap/likes/repository/LikesJpaRepository.java new file mode 100644 index 000000000..e78afc450 --- /dev/null +++ b/backend/src/main/java/codezap/likes/repository/LikesJpaRepository.java @@ -0,0 +1,16 @@ +package codezap.likes.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import codezap.likes.domain.Likes; + +public interface LikesJpaRepository extends LikesRepository, JpaRepository { + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Likes l WHERE l.template.id in :templateIds") + void deleteByTemplateIds(List templateIds); +} diff --git a/backend/src/main/java/codezap/likes/repository/LikesRepository.java b/backend/src/main/java/codezap/likes/repository/LikesRepository.java new file mode 100644 index 000000000..26df06b0a --- /dev/null +++ b/backend/src/main/java/codezap/likes/repository/LikesRepository.java @@ -0,0 +1,20 @@ +package codezap.likes.repository; + +import java.util.List; + +import codezap.likes.domain.Likes; +import codezap.member.domain.Member; +import codezap.template.domain.Template; + +public interface LikesRepository { + + Likes save(Likes likes); + + boolean existsByMemberAndTemplate(Member member, Template template); + + long countByTemplate(Template template); + + void deleteByMemberAndTemplate(Member member, Template template); + + void deleteByTemplateIds(List templateIds); +} diff --git a/backend/src/main/java/codezap/likes/service/LikedChecker.java b/backend/src/main/java/codezap/likes/service/LikedChecker.java new file mode 100644 index 000000000..ec26f7ad4 --- /dev/null +++ b/backend/src/main/java/codezap/likes/service/LikedChecker.java @@ -0,0 +1,9 @@ +package codezap.likes.service; + +import codezap.template.domain.Template; + +@FunctionalInterface +public interface LikedChecker { + + boolean isLiked(Template template); +} diff --git a/backend/src/main/java/codezap/likes/service/LikesService.java b/backend/src/main/java/codezap/likes/service/LikesService.java new file mode 100644 index 000000000..79dfce166 --- /dev/null +++ b/backend/src/main/java/codezap/likes/service/LikesService.java @@ -0,0 +1,47 @@ +package codezap.likes.service; + +import java.util.List; + +import jakarta.transaction.Transactional; + +import org.springframework.stereotype.Service; + +import codezap.likes.domain.Likes; +import codezap.likes.repository.LikesRepository; +import codezap.member.domain.Member; +import codezap.template.domain.Template; +import codezap.template.repository.TemplateRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class LikesService { + + private final TemplateRepository templateRepository; + private final LikesRepository likesRepository; + + @Transactional + public void like(Member member, long templateId) { + Template template = templateRepository.fetchById(templateId); + if (isLiked(member, template)) { + return; + } + Likes likes = new Likes(null, template, member); + likesRepository.save(likes); + } + + public Boolean isLiked(Member member, Template template) { + return likesRepository.existsByMemberAndTemplate(member, template); + } + + @Transactional + public void cancelLike(Member member, long templateId) { + Template template = templateRepository.fetchById(templateId); + likesRepository.deleteByMemberAndTemplate(member, template); + } + + @Transactional + public void deleteAllByTemplateIds(List templateIds) { + likesRepository.deleteByTemplateIds(templateIds); + } +} diff --git a/backend/src/main/java/codezap/member/controller/MemberController.java b/backend/src/main/java/codezap/member/controller/MemberController.java index 063fc43d6..d858e0e21 100644 --- a/backend/src/main/java/codezap/member/controller/MemberController.java +++ b/backend/src/main/java/codezap/member/controller/MemberController.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController; import codezap.auth.configuration.AuthenticationPrinciple; -import codezap.member.dto.MemberDto; +import codezap.member.domain.Member; import codezap.member.dto.request.SignupRequest; import codezap.member.dto.response.FindMemberResponse; import codezap.member.service.MemberService; @@ -35,13 +35,13 @@ public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { @GetMapping("/check-name") @ResponseStatus(HttpStatus.OK) public void checkUniquename(@RequestParam String name) { - memberService.assertUniquename(name); + memberService.assertUniqueName(name); } @GetMapping("/members/{id}") public ResponseEntity findMember( - @AuthenticationPrinciple MemberDto memberDto, @PathVariable Long id + @AuthenticationPrinciple Member member, @PathVariable Long id ) { - return ResponseEntity.ok(memberService.findMember(memberDto, id)); + return ResponseEntity.ok(memberService.findMember(member, id)); } } diff --git a/backend/src/main/java/codezap/member/controller/SpringDocMemberController.java b/backend/src/main/java/codezap/member/controller/SpringDocMemberController.java index d7864e3ac..fc274ca25 100644 --- a/backend/src/main/java/codezap/member/controller/SpringDocMemberController.java +++ b/backend/src/main/java/codezap/member/controller/SpringDocMemberController.java @@ -7,7 +7,7 @@ import codezap.global.swagger.error.ApiErrorResponse; import codezap.global.swagger.error.ErrorCase; -import codezap.member.dto.MemberDto; +import codezap.member.domain.Member; import codezap.member.dto.request.SignupRequest; import codezap.member.dto.response.FindMemberResponse; import io.swagger.v3.oas.annotations.Operation; @@ -48,5 +48,5 @@ public interface SpringDocMemberController { @ApiErrorResponse(status = HttpStatus.NOT_FOUND, instance = "/members/1", errorCases = { @ErrorCase(description = "조회하려는 id 값인 회원이 없는 경우", exampleMessage = "식별자 1에 해당하는 회원이 존재하지 않습니다.") }) - ResponseEntity findMember(MemberDto memberDto, Long id); + ResponseEntity findMember(Member member, Long id); } diff --git a/backend/src/main/java/codezap/member/domain/Member.java b/backend/src/main/java/codezap/member/domain/Member.java index 331bdd10b..f6a908a87 100644 --- a/backend/src/main/java/codezap/member/domain/Member.java +++ b/backend/src/main/java/codezap/member/domain/Member.java @@ -11,6 +11,7 @@ import codezap.global.auditing.BaseTimeEntity; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @@ -18,6 +19,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Getter +@EqualsAndHashCode(of = "id", callSuper = false) public class Member extends BaseTimeEntity { @Id @@ -30,28 +32,14 @@ public class Member extends BaseTimeEntity { @Column(nullable = false) private String password; - public Member(String name, String password) { - this(null, name, password); + @Column(nullable = false) + private String salt; + + public Member(String name, String password, String salt) { + this(null, name, password, salt); } public boolean matchPassword(String password) { return Objects.equals(this.password, password); } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - Member member = (Member) o; - return Objects.equals(getId(), member.getId()); - } - - @Override - public int hashCode() { - return Objects.hash(getId()); - } } diff --git a/backend/src/main/java/codezap/member/dto/MemberDto.java b/backend/src/main/java/codezap/member/dto/MemberDto.java deleted file mode 100644 index f79016dec..000000000 --- a/backend/src/main/java/codezap/member/dto/MemberDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package codezap.member.dto; - -import codezap.member.domain.Member; - -public record MemberDto( - Long id, - String name, - String password -) { - public static MemberDto from(Member member) { - return new MemberDto(member.getId(), member.getName(), member.getPassword()); - } -} diff --git a/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java b/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java index f25823f8a..427cdf52f 100644 --- a/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java +++ b/backend/src/main/java/codezap/member/repository/MemberJpaRepository.java @@ -3,6 +3,8 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.http.HttpStatus; import codezap.global.exception.CodeZapException; @@ -16,12 +18,20 @@ default Member fetchById(Long id) { () -> new CodeZapException(HttpStatus.NOT_FOUND, "식별자 " + id + "에 해당하는 멤버가 존재하지 않습니다.")); } - default Member fetchByname(String name) { - return findByname(name) + default Member fetchByName(String name) { + return findByName(name) .orElseThrow(() -> new CodeZapException(HttpStatus.UNAUTHORIZED, "존재하지 않는 아이디 " + name + " 입니다.")); } - Optional findByname(String name); + default Member fetchByTemplateId(Long templateId) { + return findByTemplateId(templateId) + .orElseThrow(() -> new CodeZapException(HttpStatus.NOT_FOUND, "템플릿에 대한 멤버가 존재하지 않습니다.")); + } + + @Query("SELECT t.member FROM Template t WHERE t.id = :templateId") + Optional findByTemplateId(@Param("templateId") Long templateId); + + Optional findByName(String name); - boolean existsByname(String name); + boolean existsByName(String name); } diff --git a/backend/src/main/java/codezap/member/repository/MemberRepository.java b/backend/src/main/java/codezap/member/repository/MemberRepository.java index 07d669f76..49f2960d3 100644 --- a/backend/src/main/java/codezap/member/repository/MemberRepository.java +++ b/backend/src/main/java/codezap/member/repository/MemberRepository.java @@ -6,11 +6,11 @@ public interface MemberRepository { Member fetchById(Long id); - Member fetchByname(String name); + Member fetchByName(String name); - boolean existsByname(String name); + Member fetchByTemplateId(Long templateId); - boolean existsById(Long id); + boolean existsByName(String name); Member save(Member member); } diff --git a/backend/src/main/java/codezap/member/service/MemberService.java b/backend/src/main/java/codezap/member/service/MemberService.java index a0a832f52..5d3d474ec 100644 --- a/backend/src/main/java/codezap/member/service/MemberService.java +++ b/backend/src/main/java/codezap/member/service/MemberService.java @@ -2,14 +2,17 @@ import java.util.Objects; +import jakarta.transaction.Transactional; + import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import codezap.auth.encryption.PasswordEncryptor; +import codezap.auth.encryption.SaltGenerator; import codezap.category.domain.Category; import codezap.category.repository.CategoryRepository; import codezap.global.exception.CodeZapException; import codezap.member.domain.Member; -import codezap.member.dto.MemberDto; import codezap.member.dto.request.SignupRequest; import codezap.member.dto.response.FindMemberResponse; import codezap.member.repository.MemberRepository; @@ -21,38 +24,37 @@ public class MemberService { private final MemberRepository memberRepository; private final CategoryRepository categoryRepository; + private final SaltGenerator saltGenerator; + private final PasswordEncryptor passwordEncryptor; + @Transactional public Long signup(SignupRequest request) { - assertUniquename(request.name()); - Member member = memberRepository.save(new Member(request.name(), request.password())); + assertUniqueName(request.name()); + String salt = saltGenerator.generate(); + String encryptedPassword = passwordEncryptor.encrypt(request.password(), salt); + Member member = memberRepository.save(new Member(request.name(), encryptedPassword, salt)); categoryRepository.save(Category.createDefaultCategory(member)); return member.getId(); } - public void assertUniquename(String name) { - if (memberRepository.existsByname(name)) { + public void assertUniqueName(String name) { + if (memberRepository.existsByName(name)) { throw new CodeZapException(HttpStatus.CONFLICT, "아이디가 이미 존재합니다."); } } - public FindMemberResponse findMember(MemberDto memberDto, Long id) { - checkSameMember(memberDto, id); - return FindMemberResponse.from(memberRepository.fetchById(id)); + public FindMemberResponse findMember(Member member, Long id) { + checkSameMember(member, id); + return FindMemberResponse.from(member); } - private void checkSameMember(MemberDto memberDto, Long id) { - if (!Objects.equals(memberDto.id(), id)) { + private void checkSameMember(Member member, Long id) { + if (!Objects.equals(member.getId(), id)) { throw new CodeZapException(HttpStatus.FORBIDDEN, "본인의 정보만 조회할 수 있습니다."); } } - public void validateMemberIdentity(MemberDto memberDto, Long id) { - if (!id.equals(memberDto.id())) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "인증 정보에 포함된 멤버 ID와 파라미터로 받은 멤버 ID가 다릅니다."); - } - - if (!memberRepository.existsById(id)) { - throw new CodeZapException(HttpStatus.UNAUTHORIZED, "로그인 정보가 잘못되었습니다."); - } + public Member getByTemplateId(Long templateId) { + return memberRepository.fetchByTemplateId(templateId); } } diff --git a/backend/src/main/java/codezap/tag/controller/SpringDocTagController.java b/backend/src/main/java/codezap/tag/controller/SpringDocTagController.java new file mode 100644 index 000000000..af3006ae6 --- /dev/null +++ b/backend/src/main/java/codezap/tag/controller/SpringDocTagController.java @@ -0,0 +1,18 @@ +package codezap.tag.controller; + +import org.springframework.http.ResponseEntity; + +import codezap.tag.dto.response.FindAllTagsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "태그 API", description = "태그 조회 API") +public interface SpringDocTagController { + + @SecurityRequirement(name = "쿠키 인증 토큰") + @Operation(summary = "태그 조회", description = "해당 멤버의 템플릿들에 포함된 태그를 조회합니다.") + @ApiResponse(responseCode = "200", description = "태그 조회 성공") + ResponseEntity getTags(Long memberId); +} diff --git a/backend/src/main/java/codezap/tag/controller/TagController.java b/backend/src/main/java/codezap/tag/controller/TagController.java new file mode 100644 index 000000000..e6e0a4d78 --- /dev/null +++ b/backend/src/main/java/codezap/tag/controller/TagController.java @@ -0,0 +1,27 @@ +package codezap.tag.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import codezap.tag.dto.response.FindAllTagsResponse; +import codezap.tag.service.TagService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/tags") +public class TagController implements SpringDocTagController { + + private final TagService tagService; + + @GetMapping + public ResponseEntity getTags( + @RequestParam Long memberId + ) { + FindAllTagsResponse response = tagService.findAllByMemberId(memberId); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/codezap/template/domain/Tag.java b/backend/src/main/java/codezap/tag/domain/Tag.java similarity index 61% rename from backend/src/main/java/codezap/template/domain/Tag.java rename to backend/src/main/java/codezap/tag/domain/Tag.java index e76d7fe31..f8088a608 100644 --- a/backend/src/main/java/codezap/template/domain/Tag.java +++ b/backend/src/main/java/codezap/tag/domain/Tag.java @@ -1,16 +1,17 @@ -package codezap.template.domain; - -import java.util.Objects; +package codezap.tag.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; import codezap.global.auditing.BaseTimeEntity; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @@ -18,6 +19,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Getter +@EqualsAndHashCode(of = "id", callSuper = false) +@Table(indexes = @Index(name = "idx_tag_name", columnList = "name")) public class Tag extends BaseTimeEntity { @Id @@ -30,21 +33,4 @@ public class Tag extends BaseTimeEntity { public Tag(String name) { this.name = name; } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - Tag tag = (Tag) o; - return Objects.equals(getId(), tag.getId()); - } - - @Override - public int hashCode() { - return Objects.hash(getId()); - } } diff --git a/backend/src/main/java/codezap/template/dto/response/FindAllTagsResponse.java b/backend/src/main/java/codezap/tag/dto/response/FindAllTagsResponse.java similarity index 83% rename from backend/src/main/java/codezap/template/dto/response/FindAllTagsResponse.java rename to backend/src/main/java/codezap/tag/dto/response/FindAllTagsResponse.java index 8eaf4acee..1a32b8080 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindAllTagsResponse.java +++ b/backend/src/main/java/codezap/tag/dto/response/FindAllTagsResponse.java @@ -1,4 +1,4 @@ -package codezap.template.dto.response; +package codezap.tag.dto.response; import java.util.List; diff --git a/backend/src/main/java/codezap/template/dto/response/FindTagResponse.java b/backend/src/main/java/codezap/tag/dto/response/FindTagResponse.java similarity index 83% rename from backend/src/main/java/codezap/template/dto/response/FindTagResponse.java rename to backend/src/main/java/codezap/tag/dto/response/FindTagResponse.java index 08a167a37..4e0fa96b4 100644 --- a/backend/src/main/java/codezap/template/dto/response/FindTagResponse.java +++ b/backend/src/main/java/codezap/tag/dto/response/FindTagResponse.java @@ -1,6 +1,6 @@ -package codezap.template.dto.response; +package codezap.tag.dto.response; -import codezap.template.domain.Tag; +import codezap.tag.domain.Tag; import io.swagger.v3.oas.annotations.media.Schema; public record FindTagResponse( diff --git a/backend/src/main/java/codezap/template/repository/TagJpaRepository.java b/backend/src/main/java/codezap/tag/repository/TagJpaRepository.java similarity index 59% rename from backend/src/main/java/codezap/template/repository/TagJpaRepository.java rename to backend/src/main/java/codezap/tag/repository/TagJpaRepository.java index ce6a8d90f..06a00d318 100644 --- a/backend/src/main/java/codezap/template/repository/TagJpaRepository.java +++ b/backend/src/main/java/codezap/tag/repository/TagJpaRepository.java @@ -1,12 +1,15 @@ -package codezap.template.repository; +package codezap.tag.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.http.HttpStatus; import codezap.global.exception.CodeZapException; -import codezap.template.domain.Tag; +import codezap.tag.domain.Tag; @SuppressWarnings("unused") public interface TagJpaRepository extends TagRepository, JpaRepository { @@ -21,7 +24,9 @@ default Tag fetchByName(String name) { () -> new CodeZapException(HttpStatus.NOT_FOUND, "이름이 " + name + "인 태그는 존재하지 않습니다.")); } - Optional findByName(String name); + @Query(value = "SELECT * FROM tag WHERE tag.name = BINARY :name", nativeQuery = true) + Optional findByName(@Param("name") String name); - boolean existsByName(String name); + @Query(value = "SELECT * FROM tag WHERE tag.name COLLATE utf8mb4_bin IN :names", nativeQuery = true) + List findAllByNames(@Param("names") List names); } diff --git a/backend/src/main/java/codezap/template/repository/TagRepository.java b/backend/src/main/java/codezap/tag/repository/TagRepository.java similarity index 65% rename from backend/src/main/java/codezap/template/repository/TagRepository.java rename to backend/src/main/java/codezap/tag/repository/TagRepository.java index c0d1521e2..bc547bda9 100644 --- a/backend/src/main/java/codezap/template/repository/TagRepository.java +++ b/backend/src/main/java/codezap/tag/repository/TagRepository.java @@ -1,9 +1,9 @@ -package codezap.template.repository; +package codezap.tag.repository; import java.util.List; import java.util.Optional; -import codezap.template.domain.Tag; +import codezap.tag.domain.Tag; public interface TagRepository { @@ -13,12 +13,9 @@ public interface TagRepository { Optional findByName(String name); - boolean existsById(Long id); - - boolean existsByName(String name); + List findAllByNames(List names); Tag save(Tag tag); List saveAll(Iterable entities); - } diff --git a/backend/src/main/java/codezap/tag/repository/TemplateTagJpaRepository.java b/backend/src/main/java/codezap/tag/repository/TemplateTagJpaRepository.java new file mode 100644 index 000000000..2e81c2722 --- /dev/null +++ b/backend/src/main/java/codezap/tag/repository/TemplateTagJpaRepository.java @@ -0,0 +1,57 @@ +package codezap.tag.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import codezap.tag.domain.Tag; +import codezap.template.domain.Template; +import codezap.template.domain.TemplateTag; + +@SuppressWarnings("unused") +public interface TemplateTagJpaRepository extends TemplateTagRepository, JpaRepository { + + @Query(""" + SELECT t + FROM Tag t + JOIN TemplateTag tt ON t.id = tt.id.tagId + WHERE tt.template = :template + """) + List findAllTagsByTemplate(Template template); + + @Query(""" + SELECT tt, t + FROM TemplateTag tt + JOIN FETCH tt.tag t + WHERE tt.id.templateId = :templateId + """) + List findAllByTemplateId(Long templateId); + + @Query(""" + SELECT tt, t + FROM TemplateTag tt + JOIN FETCH tt.tag t + WHERE tt.id.templateId in :templateIds + """) + List findAllByTemplateIdsIn(List templateIds); + + @Query(""" + SELECT DISTINCT t + FROM Tag t + WHERE t.id IN ( + SELECT DISTINCT tt.id.tagId + FROM TemplateTag tt + WHERE tt.id.templateId IN ( + SELECT te.id FROM Template te WHERE te.member.id = :memberId + ) + ) + """) + List findAllTagDistinctByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM TemplateTag t WHERE t.template.id in :templateIds") + void deleteByTemplateIds(@Param("templateIds") List templateIds); +} diff --git a/backend/src/main/java/codezap/tag/repository/TemplateTagRepository.java b/backend/src/main/java/codezap/tag/repository/TemplateTagRepository.java new file mode 100644 index 000000000..e8dfc92f1 --- /dev/null +++ b/backend/src/main/java/codezap/tag/repository/TemplateTagRepository.java @@ -0,0 +1,28 @@ +package codezap.tag.repository; + +import java.util.List; + +import codezap.tag.domain.Tag; +import codezap.template.domain.Template; +import codezap.template.domain.TemplateTag; + +public interface TemplateTagRepository { + + List findAllByTemplate(Template template); + + List findAllTagsByTemplate(Template template); + + List findAllTagDistinctByMemberId(Long memberId); + + List findAllByTemplateId(Long templateId); + + List findAllByTemplateIdsIn(List templateIds); + + TemplateTag save(TemplateTag templateTag); + + List saveAll(Iterable entities); + + void deleteAllByTemplateId(Long templateId); + + void deleteByTemplateIds(List templateIds); +} diff --git a/backend/src/main/java/codezap/tag/service/TagService.java b/backend/src/main/java/codezap/tag/service/TagService.java new file mode 100644 index 000000000..44d279b34 --- /dev/null +++ b/backend/src/main/java/codezap/tag/service/TagService.java @@ -0,0 +1,77 @@ +package codezap.tag.service; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import codezap.tag.domain.Tag; +import codezap.tag.dto.response.FindAllTagsResponse; +import codezap.tag.dto.response.FindTagResponse; +import codezap.tag.repository.TagRepository; +import codezap.tag.repository.TemplateTagRepository; +import codezap.template.domain.Template; +import codezap.template.domain.TemplateTag; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class TagService { + + private final TagRepository tagRepository; + private final TemplateTagRepository templateTagRepository; + + @Transactional + public void createTags(Template template, List tagNames) { + List existingTags = new ArrayList<>(tagRepository.findAllByNames(tagNames)); + List existNames = existingTags.stream() + .map(Tag::getName) + .toList(); + + List newTags = tagRepository.saveAll( + tagNames.stream() + .filter(name -> !existNames.contains(name)) + .map(Tag::new) + .toList() + ); + existingTags.addAll(newTags); + + for (Tag existingTag : existingTags) { + templateTagRepository.save(new TemplateTag(template, existingTag)); + } + } + + public List findAllByTemplate(Template template) { + return templateTagRepository.findAllTagsByTemplate(template); + } + + public List findAllByTemplateId(Long templateId) { + return templateTagRepository.findAllByTemplateId(templateId).stream() + .map(TemplateTag::getTag) + .toList(); + } + + public List getAllTemplateTagsByTemplates(List