diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58d3b0a..8c8428b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,89 @@ on: - main jobs: + test: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + ports: + - 3306:3306 + env: + MYSQL_ROOT_PASSWORD: ${{ secrets.TEST_DB_PASSWORD}} + MYSQL_DATABASE: test + MYSQL_USER: tester + MYSQL_PASSWORD: ${{ secrets.TEST_DB_PASSWORD}} + options: >- + --health-cmd "mysqladmin ping -h localhost" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7.0 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + submodules: 'recursive' + token: ${{ secrets.ACTION_TOKEN }} + + - name: MySQL 연결 확인 + run: | + sudo apt-get update && sudo apt-get install -y mysql-client + mysql -h127.0.0.1 -P3306 -utester -p${{ secrets.TEST_DB_PASSWORD }} -e "SHOW DATABASES;" + + - name: Redis 연결 확인 + run: | + sudo apt-get update && sudo apt-get install -y redis-tools + redis-cli -h localhost ping + + - name: Copy private ci resources + run: ./gradlew copyCiYml --no-daemon + + - name: Create Firebase JSON file + run: | + echo '{ + "type": "${{ secrets.FIREBASE_TYPE }}", + "project_id": "${{ secrets.FIREBASE_PROJECT_ID }}", + "private_key_id": "${{ secrets.FIREBASE_PRIVATE_KEY_ID }}", + "private_key": "${{ secrets.FIREBASE_PRIVATE_KEY }}", + "client_email": "${{ secrets.FIREBASE_CLIENT_EMAIL }}", + "client_id": "${{ secrets.FIREBASE_CLIENT_ID }}", + "auth_uri": "${{ secrets.FIREBASE_AUTH_URI }}", + "token_uri": "${{ secrets.FIREBASE_TOKEN_URI }}", + "auth_provider_x509_cert_url": "${{ secrets.FIREBASE_AUTH_PROVIDER_CERT_URL }}", + "client_x509_cert_url": "${{ secrets.FIREBASE_CLIENT_CERT_URL }}", + "universe_domain": "${{ secrets.FIREBASE_UNIVERSE_DOMAIN }}" + }' > src/main/resources/ori-push-notification-firebase-adminsdk-k9qqe-d0581b0c52.json + + - name: 테스트 실행 + run: ./gradlew test + env: + SPRING_PROFILES_ACTIVE: ci + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_NAME: test + DB_USER: tester + DB_PASSWORD: ${{ secrets.TEST_DB_PASSWORD }} + REDIS_HOST: 127.0.0.1 + REDIS_PORT: 6379 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: 어플리케이션 실행 테스트(테스트 코드 제외하고 실행) + run: ./gradlew clean build --exclude-task test + build: name: Run on Ubuntu runs-on: ubuntu-latest @@ -28,8 +111,10 @@ jobs: run: chmod +x ./gradlew - name: Copy private resources - run: | - ./gradlew copyYml --no-daemon + run: ./gradlew copyYml --no-daemon + + - name: Copy private logback config + run: ./gradlew copyLog --no-daemon - name: Create Firebase JSON file run: | @@ -58,7 +143,6 @@ jobs: if: github.ref == 'refs/heads/main' run: docker build . --tag ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY_NAME }}:prod -f Dockerfile.prod - - name: Push Dev Docker image if: github.ref == 'refs/heads/dev' run: | @@ -70,7 +154,6 @@ jobs: run: | echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin docker push ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY_NAME }}:prod - deploy: runs-on: ubuntu-latest @@ -95,11 +178,10 @@ jobs: username: ${{ secrets.DEV_USERNAME }} key: ${{ secrets.DEV_PRIVATE_KEY }} script: | - # 도커 컨테이너 재배포 sudo docker stop ${{ secrets.DOCKER_CONTAINER_NAME }} sudo docker rm ${{ secrets.DOCKER_CONTAINER_NAME }} sudo docker rmi ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY_NAME }}:dev - sudo docker run -d --name ${{ secrets.DOCKER_CONTAINER_NAME }} --network ${{ secrets.DOCKER_NETWORK_NAME }} -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY_NAME }}:dev + sudo docker run -d --name ${{ secrets.DOCKER_CONTAINER_NAME }} --volume /ori/log:/ori/log --network ${{ secrets.DOCKER_NETWORK_NAME }} -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY_NAME }}:dev - name: Deploy to Main Server if: github.ref == 'refs/heads/main' @@ -109,8 +191,7 @@ jobs: username: ${{ secrets.PROD_USERNAME }} key: ${{ secrets.PROD_PRIVATE_KEY }} script: | - # 도커 컨테이너 재배포 sudo docker stop ${{ secrets.DOCKER_CONTAINER_NAME }} sudo docker rm ${{ secrets.DOCKER_CONTAINER_NAME }} sudo docker rmi ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY_NAME }}:prod - sudo docker run -d --name ${{ secrets.DOCKER_CONTAINER_NAME }} --network ${{ secrets.DOCKER_NETWORK_NAME }} -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY_NAME }}:prod \ No newline at end of file + sudo docker run -d --name ${{ secrets.DOCKER_CONTAINER_NAME }} --network ${{ secrets.DOCKER_NETWORK_NAME }} -p 8080:8080 ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY_NAME }}:prod diff --git a/.gitignore b/.gitignore index 785de1e..8a87770 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ out/ ### YAML Settings ### /src/main/resources/*.yml /src/test/resources/application-test.yml +/src/main/resources/logback-spring.xml ### VS Code ### .vscode/ diff --git a/build.gradle b/build.gradle index f9edae1..382543e 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.3.4' id 'io.spring.dependency-management' version '1.1.6' - id 'org.flywaydb.flyway' version '8.5.13' + id 'org.flywaydb.flyway' version '11.10.0' } group = 'org.example' @@ -26,9 +26,12 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-web' + implementation ('org.springframework.boot:spring-boot-starter-web') { + exclude module: 'commons-logging' + } implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-data-redis' @@ -44,11 +47,14 @@ dependencies { implementation 'io.micrometer:micrometer-registry-prometheus' // Flyway 라이브러리 사용을 위한 기본적인 의존성 추가 - implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-core:11.10.0' // MySQL 을 사용해야한다면 추가 implementation 'org.flywaydb:flyway-mysql' + // Github API 사용을 위해 추가 + implementation 'org.kohsuke:github-api:1.327' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' @@ -68,3 +74,17 @@ tasks.register('copyYml', Copy) { into 'src/main/resources' } } + +tasks.register('copyCiYml', Copy) { + from 'security_setting' + include 'application-ci.yml' + into 'src/test/resources' +} + +tasks.register('copyLog', Copy) { + copy { + from 'security_setting' + include "logback-spring.xml" + into 'src/main/resources' + } +} diff --git a/security_setting b/security_setting index 9785410..43cafd8 160000 --- a/security_setting +++ b/security_setting @@ -1 +1 @@ -Subproject commit 978541071dd3e00273a3bb03a06feeccb4b8b060 +Subproject commit 43cafd8b9e032982c5c9895e57e101bad76e4951 diff --git a/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/controller/CodePostBookmarkController.java b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/controller/CodePostBookmarkController.java new file mode 100644 index 0000000..71d5b81 --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/controller/CodePostBookmarkController.java @@ -0,0 +1,50 @@ +package org.example.autoreview.domain.bookmark.CodePostBookmark.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.request.CodePostBookmarkSaveRequestDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response.CodePostBookmarkListResponseDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.service.CodePostBookmarkService; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Code Post Bookmark API", description = "Code Post Bookmark API") +@RequiredArgsConstructor +@RestController +@RequestMapping("/v1/api/post/code/bookmark") +public class CodePostBookmarkController { + + private final CodePostBookmarkService codePostBookmarkService; + + @Operation(summary = "북마크 저장 또는 수정", description = "soft delete 되었다면 수정, 존재하지 않는다면 저장") + @PutMapping + public ResponseEntity saveOrUpdate(@RequestBody CodePostBookmarkSaveRequestDto requestDto, + @AuthenticationPrincipal UserDetails userDetails) { + return ResponseEntity.ok().body(codePostBookmarkService.saveOrUpdate(requestDto, userDetails.getUsername())); + } + + @Operation(summary = "북마크 전체 조회") + @GetMapping("/list") + public ResponseEntity findAll(@AuthenticationPrincipal UserDetails userDetails, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + return ResponseEntity.ok().body(codePostBookmarkService.findAllByEmail(userDetails.getUsername(), pageable)); + } + + @Operation(summary = "북마크 삭제") + @DeleteMapping + public ResponseEntity deleteExpiredSoftDeletedBookmarks() { + codePostBookmarkService.deleteExpiredSoftDeletedBookmarks(); + return ResponseEntity.ok().body("Success Hard Delete"); + } + +} diff --git a/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/dto/request/CodePostBookmarkSaveRequestDto.java b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/dto/request/CodePostBookmarkSaveRequestDto.java new file mode 100644 index 0000000..3d0042c --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/dto/request/CodePostBookmarkSaveRequestDto.java @@ -0,0 +1,10 @@ +package org.example.autoreview.domain.bookmark.CodePostBookmark.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CodePostBookmarkSaveRequestDto( + + @Schema(description = "코드 포스트 아이디", example = "1") + Long codePostId +) { +} diff --git a/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/dto/response/CodePostBookmarkListResponseDto.java b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/dto/response/CodePostBookmarkListResponseDto.java new file mode 100644 index 0000000..cc3f32a --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/dto/response/CodePostBookmarkListResponseDto.java @@ -0,0 +1,9 @@ +package org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response; + +import java.util.List; + +public record CodePostBookmarkListResponseDto( + List dtoList, + int totalPage +) { +} diff --git a/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/dto/response/CodePostBookmarkResponseDto.java b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/dto/response/CodePostBookmarkResponseDto.java new file mode 100644 index 0000000..3f1fb52 --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/dto/response/CodePostBookmarkResponseDto.java @@ -0,0 +1,27 @@ +package org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response; + +import java.time.LocalDateTime; +import lombok.Getter; +import org.example.autoreview.domain.bookmark.CodePostBookmark.entity.CodePostBookmark; +import org.example.autoreview.domain.codepost.entity.CodePost; +import org.example.autoreview.domain.member.entity.Member; + +@Getter +public class CodePostBookmarkResponseDto { + + private final Long id; + private final Long codePostId; + private final String codePostTitle; + private final int commentCount; + private final String writer; + private final LocalDateTime updateAt; + + public CodePostBookmarkResponseDto(CodePostBookmark bookmark, CodePost codePost, Member member) { + this.id = bookmark.getId(); + this.codePostId = codePost.getId(); + this.codePostTitle = codePost.getTitle(); + this.commentCount = codePost.getCodePostCommentList().size(); + this.writer = member.getNickname(); + this.updateAt = bookmark.getUpdateDate(); + } +} diff --git a/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/entity/CodePostBookmark.java b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/entity/CodePostBookmark.java new file mode 100644 index 0000000..a706e3d --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/entity/CodePostBookmark.java @@ -0,0 +1,49 @@ +package org.example.autoreview.domain.bookmark.CodePostBookmark.entity; + +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 jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.autoreview.global.common.basetime.BaseEntity; +import org.hibernate.annotations.ColumnDefault; + +@Getter +@NoArgsConstructor +@Table(uniqueConstraints = {@UniqueConstraint(name = "uq_email_codepost", + columnNames = {"email", "code_post_id"})} +) +@Entity +public class CodePostBookmark extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String email; + + @Column(nullable = false) + private Long codePostId; + + @ColumnDefault("false") + @Column(nullable = false) + private boolean isDeleted; + + @Builder + public CodePostBookmark(Long codePostId, String email, boolean isDeleted) { + this.codePostId = codePostId; + this.email = email; + this.isDeleted = isDeleted; + } + + public Long update() { + this.isDeleted = !this.isDeleted; + return this.id; + } + +} diff --git a/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/entity/CodePostBookmarkRepository.java b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/entity/CodePostBookmarkRepository.java new file mode 100644 index 0000000..8dbd273 --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/entity/CodePostBookmarkRepository.java @@ -0,0 +1,34 @@ +package org.example.autoreview.domain.bookmark.CodePostBookmark.entity; + +import io.lettuce.core.dynamic.annotation.Param; +import java.util.Optional; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response.CodePostBookmarkResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface CodePostBookmarkRepository extends JpaRepository { + + @Modifying + @Query( + value = " INSERT INTO code_post_bookmark (email, code_post_id, is_deleted, create_date, update_date) " + + " VALUES (:email, :codePostId, false, NOW(), NOW()) " + + " ON DUPLICATE KEY UPDATE is_deleted = NOT is_deleted, update_date = NOW() ", + nativeQuery = true + ) + void upsert(@Param("email") String email, @Param("codePostId") Long codePostId); + + + @Query("SELECT b FROM CodePostBookmark b WHERE b.email = :email AND b.codePostId = :codePostId") + Optional findById(@Param("email") String email, @Param("codePostId") Long codePostId); + + @Query("SELECT new org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response.CodePostBookmarkResponseDto(b,c,m) from CodePostBookmark b " + + "JOIN CodePost c ON b.codePostId = c.id JOIN Member m ON m.id = c.writerId WHERE b.isDeleted = FALSE AND b.email = :email") + Page findAllByEmail(@Param("email") String email, Pageable pageable); + + @Modifying + @Query("DELETE FROM CodePostBookmark b WHERE b.isDeleted = TRUE") + void deleteExpiredSoftDeletedBookmarks(); +} diff --git a/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkCommand.java b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkCommand.java new file mode 100644 index 0000000..986147a --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkCommand.java @@ -0,0 +1,40 @@ +package org.example.autoreview.domain.bookmark.CodePostBookmark.service; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.request.CodePostBookmarkSaveRequestDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response.CodePostBookmarkResponseDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.entity.CodePostBookmark; +import org.example.autoreview.domain.bookmark.CodePostBookmark.entity.CodePostBookmarkRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class CodePostBookmarkCommand { + + private final CodePostBookmarkRepository codePostBookmarkRepository; + + @Transactional + public Optional saveOrUpdate(CodePostBookmarkSaveRequestDto requestDto, String email) { + codePostBookmarkRepository.upsert(email, requestDto.codePostId()); + return codePostBookmarkRepository.findById(email, requestDto.codePostId()); + } + + @Transactional + public void deleteExpiredSoftDeletedBookmarks() { + codePostBookmarkRepository.deleteExpiredSoftDeletedBookmarks(); + } + + @Transactional(readOnly = true) + public Optional findByCodePostBookmark(String email, Long codePostId) { + return codePostBookmarkRepository.findById(email,codePostId); + } + + @Transactional(readOnly = true) + public Page findAllByEmail(String email, Pageable pageable) { + return codePostBookmarkRepository.findAllByEmail(email, pageable); + } +} diff --git a/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkService.java b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkService.java new file mode 100644 index 0000000..b74cc18 --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkService.java @@ -0,0 +1,49 @@ +package org.example.autoreview.domain.bookmark.CodePostBookmark.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.request.CodePostBookmarkSaveRequestDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response.CodePostBookmarkListResponseDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response.CodePostBookmarkResponseDto; +import org.example.autoreview.domain.member.service.MemberCommand; +import org.example.autoreview.global.exception.base_exceptions.CustomRuntimeException; +import org.example.autoreview.global.exception.errorcode.ErrorCode; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Slf4j +@RequiredArgsConstructor +@Service +public class CodePostBookmarkService { + + private final CodePostBookmarkCommand codePostBookmarkCommand; + private final MemberCommand memberCommand; + + /** + * 북마크가 없을 시 생성, 있으면 상태 변경하는 메서드이다. + * MySQL 에서 지원하는 Upsert 기능을 통해 유니크 키가 중복될 경우 update 실행 + */ + public Long saveOrUpdate(CodePostBookmarkSaveRequestDto requestDto, String email) { + return codePostBookmarkCommand.saveOrUpdate(requestDto, email).orElseThrow( + () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_BOOKMARK) + ).getId(); + } + + /** + * 회원 이메일을 통해 북마크한 포스트를 조회하는 메서드이다. + */ + public CodePostBookmarkListResponseDto findAllByEmail(String email, Pageable pageable) { + Page pageDto = codePostBookmarkCommand.findAllByEmail(email,pageable); + + return new CodePostBookmarkListResponseDto(pageDto.getContent(),pageDto.getTotalPages()); + } + + /** + * 북마크 Table에서 soft delete 되어있는 컬럼을 전부 삭제하는 메서드이다. + * Scheduler에서 쓰일 예정 + */ + public void deleteExpiredSoftDeletedBookmarks() { + codePostBookmarkCommand.deleteExpiredSoftDeletedBookmarks(); + } +} diff --git a/src/main/java/org/example/autoreview/domain/codepost/controller/CodePostController.java b/src/main/java/org/example/autoreview/domain/codepost/controller/CodePostController.java index 157d6a2..77cce3f 100644 --- a/src/main/java/org/example/autoreview/domain/codepost/controller/CodePostController.java +++ b/src/main/java/org/example/autoreview/domain/codepost/controller/CodePostController.java @@ -7,21 +7,11 @@ import org.example.autoreview.domain.codepost.dto.request.CodePostUpdateRequestDto; import org.example.autoreview.domain.codepost.dto.response.CodePostListResponseDto; import org.example.autoreview.domain.codepost.dto.response.CodePostResponseDto; -import org.example.autoreview.domain.codepost.service.CodePostDtoService; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; +import org.example.autoreview.domain.codepost.service.CodePostService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Tag(name = "코드 포스트 API", description = "코드 포스트 API") @RequiredArgsConstructor @@ -29,61 +19,78 @@ @RequestMapping("/v1/api/post/code") public class CodePostController { - private final CodePostDtoService codePostMemberService; + private final CodePostService codePostService; @Operation(summary = "코드 포스트 생성", description = "코드 포스트 생성") @PostMapping public ResponseEntity save(@RequestBody CodePostSaveRequestDto requestDto, @AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok().body(codePostMemberService.postSave(requestDto, userDetails.getUsername())); + return ResponseEntity.ok().body(codePostService.save(requestDto, userDetails.getUsername())); } @Operation(summary = "제목으로 코드 포스트 검색", description = "공백 또는 null 입력 시 에러 반환") @GetMapping("/search") public ResponseEntity search(@RequestParam String keyword, - @PageableDefault(page = 0, size = 9) Pageable pageable) { - return ResponseEntity.ok().body(codePostMemberService.postSearch(keyword, pageable)); + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "9") int size, + @RequestParam(defaultValue = "desc") String direction, + @RequestParam(defaultValue = "id") String sortBy, + @RequestParam(defaultValue = "all") String language) { + return ResponseEntity.ok().body(codePostService.search(keyword, page,size,direction,sortBy,language)); } - @Operation(summary = "코드 포스트 단일 조회", description = "코드 포스트 단일 조회") + @Operation(summary = "코드 포스트 단일 조회", description = "공개된 포스트 or 작성자일 경우만 조회됨 + 북마크 여부 포함") @GetMapping("/detail/{id}") - public ResponseEntity view(@PathVariable("id") Long codePostId) { - return ResponseEntity.ok().body(codePostMemberService.findPostById(codePostId)); + public ResponseEntity view(@PathVariable("id") Long codePostId, + @AuthenticationPrincipal UserDetails userDetails) { + return ResponseEntity.ok().body(codePostService.findById(codePostId,userDetails.getUsername())); } - @Operation(summary = "코드 포스트 전체 조회", description = "코드 포스트 전체 조회") + @Operation(summary = "코드 포스트 전체 조회", description = "코드 포스트 전체 조회(비공개 포스트는 제외)") @GetMapping("/list") - public ResponseEntity viewAll(@PageableDefault(page = 0, size = 9) Pageable pageable) { - return ResponseEntity.ok().body(codePostMemberService.findPostByPage(pageable)); + public ResponseEntity viewAll(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "9") int size, + @RequestParam(defaultValue = "desc") String direction, + @RequestParam(defaultValue = "id") String sortBy, + @RequestParam(defaultValue = "all") String language) { + return ResponseEntity.ok().body(codePostService.findPostByPage(page,size,direction,sortBy,language)); } @Operation(summary = "내가 쓴 코드 포스트 조회", description = "내가 쓴 코드 포스트 조회") @GetMapping("/own") - public ResponseEntity myCodePostPage(@PageableDefault(page = 0, size = 9) Pageable pageable, - @AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok().body(codePostMemberService.findPostByMemberId(pageable, userDetails.getUsername())); + public ResponseEntity myCodePostPage(@AuthenticationPrincipal UserDetails userDetails, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "9") int size, + @RequestParam(defaultValue = "desc") String direction, + @RequestParam(defaultValue = "id") String sortBy, + @RequestParam(defaultValue = "all") String language) { + return ResponseEntity.ok().body(codePostService.findByMemberId(userDetails.getUsername(),page,size,direction,sortBy,language)); } @Operation(summary = "내 코드 포스트 검색", description = "내 코드 포스트 검색") @GetMapping("/own/search") public ResponseEntity mySearch(@RequestParam String keyword, - @PageableDefault(page = 0, size = 9) Pageable pageable, - @AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok().body(codePostMemberService.postMySearch(keyword, pageable, userDetails.getUsername())); + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "9") int size, + @RequestParam(defaultValue = "desc") String direction, + @RequestParam(defaultValue = "id") String sortBy, + @RequestParam(defaultValue = "all") String language) { + return ResponseEntity.ok().body(codePostService.mySearch(keyword, userDetails.getUsername(), page,size,direction,sortBy,language)); } @Operation(summary = "코드 포스트 수정", description = "코드 포스트 수정") @PutMapping public ResponseEntity update(@RequestBody CodePostUpdateRequestDto requestDto, @AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok().body(codePostMemberService.postUpdate(requestDto, userDetails.getUsername())); + return ResponseEntity.ok().body(codePostService.update(requestDto, userDetails.getUsername())); } @Operation(summary = "코드 포스트 삭제", description = "코드 포스트 삭제") @DeleteMapping("/{id}") public ResponseEntity delete(@PathVariable("id") Long codePostId, @AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok().body(codePostMemberService.postDelete(codePostId, userDetails.getUsername())); + return ResponseEntity.ok().body(codePostService.delete(codePostId, userDetails.getUsername())); } } diff --git a/src/main/java/org/example/autoreview/domain/codepost/dto/request/CodePostSaveRequestDto.java b/src/main/java/org/example/autoreview/domain/codepost/dto/request/CodePostSaveRequestDto.java index b31578b..e0a71d4 100644 --- a/src/main/java/org/example/autoreview/domain/codepost/dto/request/CodePostSaveRequestDto.java +++ b/src/main/java/org/example/autoreview/domain/codepost/dto/request/CodePostSaveRequestDto.java @@ -2,11 +2,12 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; import org.example.autoreview.domain.codepost.entity.CodePost; import org.example.autoreview.domain.codepost.entity.Language; import org.example.autoreview.domain.member.entity.Member; +import java.time.LocalDate; + public record CodePostSaveRequestDto( @Schema(description = "코드 포스트 제목", example = "[BOJ] 0000: test 해보기") @@ -15,6 +16,9 @@ public record CodePostSaveRequestDto( @Schema(description = "난이도", example = "4") int level, + @Schema(description = "공개여부", example = "true") + boolean isPublic, + @Schema(description = "복습일 설정", example = "2024-10-11") LocalDate reviewDay, @@ -33,6 +37,7 @@ public CodePost toEntity(Member member){ .writerId(member.getId()) .title(title) .level(level) + .isPublic(isPublic) .reviewDay(reviewDay) .description(description) .language(Language.of(language)) diff --git a/src/main/java/org/example/autoreview/domain/codepost/dto/request/CodePostUpdateRequestDto.java b/src/main/java/org/example/autoreview/domain/codepost/dto/request/CodePostUpdateRequestDto.java index 9eaa89a..d79a126 100644 --- a/src/main/java/org/example/autoreview/domain/codepost/dto/request/CodePostUpdateRequestDto.java +++ b/src/main/java/org/example/autoreview/domain/codepost/dto/request/CodePostUpdateRequestDto.java @@ -21,10 +21,13 @@ public class CodePostUpdateRequestDto { @Schema(description = "난이도", example = "4") private final int level; + @Schema(description = "공개여부", example = "true") + private final boolean isPublic; + @Schema(description = "복습일 설정", example = "2024-10-11") private final LocalDate reviewDay; - @Schema(description = "사용 언어", example = "c++") + @Schema(description = "사용 언어", example = "cpp") private final String language; @Schema(description = "코드", example = "import test") diff --git a/src/main/java/org/example/autoreview/domain/codepost/dto/response/CodePostResponseDto.java b/src/main/java/org/example/autoreview/domain/codepost/dto/response/CodePostResponseDto.java index 5b0cb7d..374df89 100644 --- a/src/main/java/org/example/autoreview/domain/codepost/dto/response/CodePostResponseDto.java +++ b/src/main/java/org/example/autoreview/domain/codepost/dto/response/CodePostResponseDto.java @@ -17,24 +17,28 @@ public class CodePostResponseDto { private final String writerNickName; private final String title; private final int level; + private final boolean isPublic; private final LocalDate reviewDay; private final String description; private final String language; private final String code; + private final boolean isBookmarked; private final List dtoList; private final LocalDateTime createDate; - public CodePostResponseDto(CodePost entity, List dtoList, Member member) { + public CodePostResponseDto(CodePost entity, List dtoList, Member writer, boolean isBookmarked) { this.id = entity.getId(); this.writerId = entity.getWriterId(); - this.writerEmail = member.getEmail(); - this.writerNickName = member.getNickname(); + this.writerEmail = writer.getEmail(); + this.writerNickName = writer.getNickname(); this.title = entity.getTitle(); this.level = entity.getLevel(); + this.isPublic = entity.isPublic(); this.reviewDay = entity.getReviewDay(); this.description = entity.getDescription(); this.language = entity.getLanguage().getType(); this.code = entity.getCode(); + this.isBookmarked = isBookmarked; this.dtoList = dtoList; this.createDate = entity.getCreateDate(); } diff --git a/src/main/java/org/example/autoreview/domain/codepost/dto/response/CodePostThumbnailResponseDto.java b/src/main/java/org/example/autoreview/domain/codepost/dto/response/CodePostThumbnailResponseDto.java index d6229c4..ef5e4f1 100644 --- a/src/main/java/org/example/autoreview/domain/codepost/dto/response/CodePostThumbnailResponseDto.java +++ b/src/main/java/org/example/autoreview/domain/codepost/dto/response/CodePostThumbnailResponseDto.java @@ -14,6 +14,9 @@ public class CodePostThumbnailResponseDto { private final String writerNickName; private final String title; private final int level; + private final int commentCount; + private final int reviewCount; + private final boolean isPublic; private final String description; private final LocalDateTime createdDate; @@ -24,7 +27,17 @@ public CodePostThumbnailResponseDto(CodePost entity, Member writer) { this.writerNickName = writer.getNickname(); this.title = entity.getTitle(); this.level = entity.getLevel(); - this.description = entity.getDescription(); + this.commentCount = entity.getCodePostCommentList().size(); + this.reviewCount = entity.getReviewList().size(); + this.isPublic = entity.isPublic(); + this.description = summarize(entity.getDescription(),100); this.createdDate = entity.getCreateDate(); } + + private String summarize(String text, int maxLength) { + if (text == null) { + return ""; + } + return text.length() <= maxLength ? text : text.substring(0, maxLength) + "..."; + } } diff --git a/src/main/java/org/example/autoreview/domain/codepost/entity/CodePost.java b/src/main/java/org/example/autoreview/domain/codepost/entity/CodePost.java index 7ee168f..56954bf 100644 --- a/src/main/java/org/example/autoreview/domain/codepost/entity/CodePost.java +++ b/src/main/java/org/example/autoreview/domain/codepost/entity/CodePost.java @@ -1,17 +1,6 @@ package org.example.autoreview.domain.codepost.entity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -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.OneToMany; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; +import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -19,6 +8,11 @@ import org.example.autoreview.domain.comment.codepost.entity.CodePostComment; import org.example.autoreview.domain.review.entity.Review; import org.example.autoreview.global.common.basetime.BaseEntity; +import org.hibernate.annotations.ColumnDefault; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; @Getter @NoArgsConstructor @@ -42,6 +36,10 @@ public class CodePost extends BaseEntity { private int level; + @Column(nullable = false) + @ColumnDefault("true") + private boolean isPublic; + private LocalDate reviewDay; @Column(length = 4000) @@ -54,7 +52,8 @@ public class CodePost extends BaseEntity { private Language language; @Builder - public CodePost(String title, int level, LocalDate reviewDay, String description, String code, Language language, Long writerId) { + public CodePost(String title, int level, LocalDate reviewDay, String description, + String code, Language language, Long writerId, boolean isPublic) { this.writerId = writerId; this.title = title; this.level = level; @@ -62,11 +61,13 @@ public CodePost(String title, int level, LocalDate reviewDay, String description this.description = description; this.code = code; this.language = language; + this.isPublic = isPublic; } public void update(CodePostUpdateRequestDto requestDto){ this.title = requestDto.getTitle(); this.level = requestDto.getLevel(); + this.isPublic = requestDto.isPublic(); this.reviewDay = requestDto.getReviewDay(); this.description = requestDto.getDescription(); this.language = Language.of(requestDto.getLanguage()); diff --git a/src/main/java/org/example/autoreview/domain/codepost/entity/CodePostRepository.java b/src/main/java/org/example/autoreview/domain/codepost/entity/CodePostRepository.java index 89b4867..b74667c 100644 --- a/src/main/java/org/example/autoreview/domain/codepost/entity/CodePostRepository.java +++ b/src/main/java/org/example/autoreview/domain/codepost/entity/CodePostRepository.java @@ -1,6 +1,7 @@ package org.example.autoreview.domain.codepost.entity; import io.lettuce.core.dynamic.annotation.Param; +import java.util.Optional; import org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -9,11 +10,19 @@ public interface CodePostRepository extends JpaRepository { - @Query("SELECT new org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto(c,m) " + - "FROM CodePost c INNER JOIN Member m ON c.writerId = m.id ORDER BY c.id DESC") - Page findByPage(Pageable pageable); + @Query("SELECT c FROM CodePost c WHERE c.id = :id AND (c.writerId = :memberId OR c.isPublic = TRUE)") + Optional findByIdIsPublic(@Param("id") Long id, @Param("memberId") Long memberId); - @Query(value = "SELECT * FROM code_post c WHERE MATCH(c.title) AGAINST(:keyword IN BOOLEAN MODE)", + @Query(""" + SELECT new org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto(c,m) + FROM CodePost c + INNER JOIN Member m ON c.writerId = m.id + AND c.isPublic = TRUE + AND (:language = 'all' OR c.language = :language) + """) + Page findByPage(Pageable pageable, @Param("language") Language language); + + @Query(value = "SELECT * FROM code_post c WHERE c.is_public = TRUE AND MATCH(c.title) AGAINST(:keyword IN BOOLEAN MODE)", countQuery = "SELECT COUNT(*) FROM code_post c WHERE MATCH(c.title) AGAINST(:keyword IN BOOLEAN MODE)", nativeQuery = true) Page search(@Param("keyword") String keyword, Pageable pageable); @@ -22,7 +31,122 @@ public interface CodePostRepository extends JpaRepository { @Query("SELECT c FROM CodePost c WHERE c.writerId =:id AND c.title LIKE %:keyword% ORDER BY c.id DESC") Page mySearch(@Param("keyword") String keyword, Pageable pageable, @Param("id") Long id); - @Query("SELECT c FROM CodePost c WHERE c.writerId =:id ORDER BY c.id DESC") + @Query(""" + SELECT new org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto(c, m) + FROM CodePost c + LEFT JOIN c.codePostCommentList cm + INNER JOIN Member m ON c.writerId = m.id + WHERE c.writerId = :writerId + AND LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) + AND (:language = 'all' OR c.language = :language) + GROUP BY c, m + ORDER BY COUNT(cm) DESC + """) + Page mySearchSortByCommentCountDesc( + @Param("keyword") String keyword, + Pageable pageable, + @Param("writerId") Long writerId, + @Param("language") Language language + ); + + @Query(""" + SELECT new org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto(c, m) + FROM CodePost c + LEFT JOIN c.codePostCommentList cm + INNER JOIN Member m ON c.writerId = m.id + WHERE c.writerId = :writerId + AND LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) + AND (:language = 'all' OR c.language = :language) + GROUP BY c, m + ORDER BY COUNT(cm) ASC + """) + Page mySearchSortByCommentCountAsc( + @Param("keyword") String keyword, + Pageable pageable, + @Param("writerId") Long writerId, + @Param("language") Language language + ); + + + @Query("SELECT c FROM CodePost c WHERE c.writerId =:id") Page findByMemberId(@Param("id") Long id, Pageable pageable); + @Query(""" + SELECT new org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto(c, m) + FROM CodePost c + LEFT JOIN c.codePostCommentList cm + INNER JOIN Member m ON c.writerId = m.id + WHERE c.writerId = :memberId + AND c.isPublic = TRUE + AND (:language = 'all' OR c.language = :language) + GROUP BY c, m + ORDER BY COUNT(cm) DESC + """) + Page findByMemberIdSortByCommentCountDesc(Pageable pageable, @Param("memberId") Long memberId, @Param("language") Language language); + + @Query(""" + SELECT new org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto(c, m) + FROM CodePost c + LEFT JOIN c.codePostCommentList cm + INNER JOIN Member m ON c.writerId = m.id + WHERE c.writerId = :memberId + AND c.isPublic = TRUE + AND (:language = 'all' OR c.language = :language) + GROUP BY c, m + ORDER BY COUNT(cm) ASC + """) + Page findByMemberIdSortByCommentCountAsc(Pageable pageable, @Param("memberId") Long memberId, @Param("language") Language language); + + @Query(""" + SELECT new org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto(c, m) + FROM CodePost c + LEFT JOIN c.codePostCommentList cm + INNER JOIN Member m ON c.writerId = m.id + AND c.isPublic = TRUE + AND LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) + AND (:language = 'all' OR c.language = :language) + GROUP BY c, m + ORDER BY COUNT(cm) DESC + """) + Page findByPageSortByCommentCountDesc(Pageable pageable, @Param("language") Language language); + + @Query(""" + SELECT new org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto(c, m) + FROM CodePost c + LEFT JOIN c.codePostCommentList cm + INNER JOIN Member m ON c.writerId = m.id + AND c.isPublic = TRUE + AND (:language = 'all' OR c.language = :language) + GROUP BY c, m + ORDER BY COUNT(cm) ASC + """) + Page findByPageSortByCommentCountAsc(Pageable pageable, @Param("language") Language language); + + @Query(""" + SELECT new org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto(c, m) + FROM CodePost c + LEFT JOIN c.codePostCommentList cm + INNER JOIN Member m ON c.writerId = m.id + AND c.isPublic = TRUE + AND LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) + AND (:language = 'all' OR c.language = :language) + GROUP BY c, m + ORDER BY COUNT(cm) DESC + """) + Page searchSortByCommentCountDesc(@Param("keyword") String keyword, Pageable pageable, @Param("language") Language language); + + @Query(""" + SELECT new org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto(c, m) + FROM CodePost c + LEFT JOIN c.codePostCommentList cm + INNER JOIN Member m ON c.writerId = m.id + AND c.isPublic = TRUE + AND LOWER(c.title) LIKE LOWER(CONCAT('%', :keyword, '%')) + AND (:language = 'all' OR c.language = :language) + GROUP BY c, m + ORDER BY COUNT(cm) ASC + """) + Page searchSortByCommentCountAsc(@Param("keyword") String keyword, Pageable pageable, @Param("language") Language language); + + } diff --git a/src/main/java/org/example/autoreview/domain/codepost/entity/Language.java b/src/main/java/org/example/autoreview/domain/codepost/entity/Language.java index 485f868..237812f 100644 --- a/src/main/java/org/example/autoreview/domain/codepost/entity/Language.java +++ b/src/main/java/org/example/autoreview/domain/codepost/entity/Language.java @@ -2,30 +2,34 @@ import lombok.Getter; +import java.util.Arrays; + @Getter public enum Language { - JAVASCRIPT("javascript"), - PYTHON("python"), - JAVA("java"), - CSHARP("csharp"), - CPP("cpp"), - C("c"), - RUBY("ruby"), - GO("go"); + ALL("all","txt"), + KOTLIN("kotlin","kt"), + JAVASCRIPT("javascript", "js"), + PYTHON("python", "py"), + JAVA("java", "java"), + CSHARP("csharp", "cs"), + CPP("cpp", "cpp"), + C("c", "c"), + RUBY("ruby", "rb"), + GO("go", "go"); private final String type; + private final String fileExtension; - Language(String type) { + Language(String type, String fileExtension) { this.type = type; + this.fileExtension = fileExtension; } - public static Language of(String type) { - for (Language lt : Language.values()) { - if (lt.type.equals(type)) { - return lt; - } - } - return null; + public static Language of(String name) { + return Arrays.stream(values()) + .filter(lang -> lang.type.equalsIgnoreCase(name)) + .findFirst() + .get(); } } diff --git a/src/main/java/org/example/autoreview/domain/codepost/service/CodePostCommand.java b/src/main/java/org/example/autoreview/domain/codepost/service/CodePostCommand.java index 6f37934..dce0e3b 100644 --- a/src/main/java/org/example/autoreview/domain/codepost/service/CodePostCommand.java +++ b/src/main/java/org/example/autoreview/domain/codepost/service/CodePostCommand.java @@ -1,11 +1,17 @@ package org.example.autoreview.domain.codepost.service; import lombok.RequiredArgsConstructor; +import org.example.autoreview.domain.codepost.dto.request.CodePostUpdateRequestDto; +import org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto; import org.example.autoreview.domain.codepost.entity.CodePost; import org.example.autoreview.domain.codepost.entity.CodePostRepository; +import org.example.autoreview.domain.codepost.entity.Language; import org.example.autoreview.global.exception.base_exceptions.CustomRuntimeException; import org.example.autoreview.global.exception.errorcode.ErrorCode; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -13,9 +19,95 @@ public class CodePostCommand { private final CodePostRepository codePostRepository; + @Transactional + public CodePost save(CodePost codePost) { + return codePostRepository.save(codePost); + } + + @Transactional(readOnly = true) public CodePost findById(Long id) { return codePostRepository.findById(id).orElseThrow( () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_POST) ); } + + @Transactional(readOnly = true) + public CodePost findByIdIsPublic(Long codePostId, Long memberId) { + return codePostRepository.findByIdIsPublic(codePostId, memberId).orElseThrow( + () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_POST) + ); + } + + @Transactional(readOnly = true) + public Page search(String wildcardKeyword, Pageable pageable) { + return codePostRepository.search(wildcardKeyword, pageable); + } + + @Transactional(readOnly = true) + public Page findByMemberId(Long memberId, Pageable pageable) { + return codePostRepository.findByMemberId(memberId, pageable); + } + + @Transactional(readOnly = true) + public Page findByMemberIdSortByCommentCountDesc(Pageable pageable, Long memberId, Language language) { + return codePostRepository.findByMemberIdSortByCommentCountDesc(pageable, memberId, language); + } + + @Transactional(readOnly = true) + public Page findByMemberIdSortByCommentCountAsc(Pageable pageable, Long memberId, Language language) { + return codePostRepository.findByMemberIdSortByCommentCountAsc(pageable, memberId, language); + } + + @Transactional(readOnly = true) + public Page mySearch(String keyword, Pageable pageable, Long memberId) { + return codePostRepository.mySearch(keyword, pageable, memberId); + } + + @Transactional(readOnly = true) + public Page mySearchSortByCommentCountDesc(String keyword, Pageable pageable, Long memberId, Language language) { + return codePostRepository.mySearchSortByCommentCountDesc(keyword, pageable, memberId, language); + } + + @Transactional(readOnly = true) + public Page mySearchSortByCommentCountAsc(String keyword, Pageable pageable, Long memberId, Language language) { + return codePostRepository.mySearchSortByCommentCountAsc(keyword, pageable, memberId, language); + } + + @Transactional(readOnly = true) + public Page findByPage(Pageable pageable, Language language) { + return codePostRepository.findByPage(pageable, language); + } + + @Transactional(readOnly = true) + public Page findByPageSortByCommentCountDesc(Pageable pageable, Language language) { + return codePostRepository.findByPageSortByCommentCountDesc(pageable, language); + } + + @Transactional(readOnly = true) + public Page findByPageSortByCommentCountAsc(Pageable pageable, Language language) { + return codePostRepository.findByPageSortByCommentCountAsc(pageable, language); + } + + @Transactional(readOnly = true) + public Page searchSortByCommentCountDesc(String keyword, Pageable pageable, Language language) { + return codePostRepository.searchSortByCommentCountDesc(keyword, pageable, language); + } + + @Transactional(readOnly = true) + public Page searchSortByCommentCountAsc(String keyword, Pageable pageable, Language language) { + return codePostRepository.searchSortByCommentCountAsc(keyword, pageable, language); + } + + @Transactional + public void update(Long codePostId, CodePostUpdateRequestDto requestDto) { + CodePost codePost = codePostRepository.findById(codePostId).orElseThrow( + () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_POST) + ); + codePost.update(requestDto); + } + + @Transactional + public void delete(CodePost codePost) { + codePostRepository.delete(codePost); + } } diff --git a/src/main/java/org/example/autoreview/domain/codepost/service/CodePostDtoService.java b/src/main/java/org/example/autoreview/domain/codepost/service/CodePostDtoService.java deleted file mode 100644 index f6d0fba..0000000 --- a/src/main/java/org/example/autoreview/domain/codepost/service/CodePostDtoService.java +++ /dev/null @@ -1,83 +0,0 @@ -package org.example.autoreview.domain.codepost.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.example.autoreview.domain.codepost.dto.request.CodePostSaveRequestDto; -import org.example.autoreview.domain.codepost.dto.request.CodePostUpdateRequestDto; -import org.example.autoreview.domain.codepost.dto.response.CodePostListResponseDto; -import org.example.autoreview.domain.codepost.dto.response.CodePostResponseDto; -import org.example.autoreview.domain.codepost.entity.CodePost; -import org.example.autoreview.domain.member.entity.Member; -import org.example.autoreview.domain.member.service.MemberService; -import org.example.autoreview.domain.notification.enums.NotificationStatus; -import org.example.autoreview.domain.notification.service.NotificationService; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - -@Slf4j -@RequiredArgsConstructor -@Service -public class CodePostDtoService { - - private final CodePostService codePostService; - private final MemberService memberService; - private final NotificationService notificationService; - - public Long postSave(CodePostSaveRequestDto requestDto, String email){ - Member member = memberService.findByEmail(email); - CodePost codePost = codePostService.save(requestDto, member); - - if (requestDto.reviewDay() != null) { - notificationService.save(member, codePost); - } - - return codePost.getId(); - } - - public CodePostResponseDto findPostById(Long id){ - return codePostService.findById(id); - } - - public CodePostListResponseDto postSearch(String keyword, Pageable pageable){ - return codePostService.search(keyword, pageable); - } - - public CodePostListResponseDto postMySearch(String keyword, Pageable pageable, String email){ - Member member = memberService.findByEmail(email); - return codePostService.mySearch(keyword, pageable, member); - } - - public CodePostListResponseDto findPostByMemberId(Pageable pageable, String email){ - Member member = memberService.findByEmail(email); - return codePostService.findByMemberId(pageable, member); - } - - public CodePostListResponseDto findPostByPage(Pageable pageable){ - return codePostService.findByPage(pageable); - } - - public Long postUpdate(CodePostUpdateRequestDto requestDto, String email) { - CodePost codePost = codePostService.update(requestDto, email); - boolean notificationExists = notificationService.existsByCodePostId(requestDto.getId()); - - if (notificationExists) { - if (requestDto.getReviewDay() == null) { - notificationService.delete(email, codePost.getId()); - } else { - notificationService.update(email, codePost, NotificationStatus.PENDING); - } - } else { - Member member = memberService.findByEmail(email); - notificationService.save(member, codePost); - } - - return codePost.getId(); - } - - public Long postDelete(Long id, String email){ - if (notificationService.existsByCodePostId(id)) { - notificationService.delete(email,id); - } - return codePostService.delete(id, email); - } -} diff --git a/src/main/java/org/example/autoreview/domain/codepost/service/CodePostService.java b/src/main/java/org/example/autoreview/domain/codepost/service/CodePostService.java index 4ca0ed3..f7044f2 100644 --- a/src/main/java/org/example/autoreview/domain/codepost/service/CodePostService.java +++ b/src/main/java/org/example/autoreview/domain/codepost/service/CodePostService.java @@ -1,124 +1,302 @@ package org.example.autoreview.domain.codepost.service; import java.util.List; -import java.util.stream.Collectors; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.example.autoreview.domain.bookmark.CodePostBookmark.entity.CodePostBookmark; +import org.example.autoreview.domain.bookmark.CodePostBookmark.service.CodePostBookmarkCommand; import org.example.autoreview.domain.codepost.dto.request.CodePostSaveRequestDto; import org.example.autoreview.domain.codepost.dto.request.CodePostUpdateRequestDto; import org.example.autoreview.domain.codepost.dto.response.CodePostListResponseDto; import org.example.autoreview.domain.codepost.dto.response.CodePostResponseDto; import org.example.autoreview.domain.codepost.dto.response.CodePostThumbnailResponseDto; import org.example.autoreview.domain.codepost.entity.CodePost; -import org.example.autoreview.domain.codepost.entity.CodePostRepository; +import org.example.autoreview.domain.codepost.entity.Language; import org.example.autoreview.domain.member.entity.Member; import org.example.autoreview.domain.member.service.MemberCommand; +import org.example.autoreview.domain.notification.entity.Notification; +import org.example.autoreview.domain.notification.enums.NotificationStatus; +import org.example.autoreview.domain.notification.service.NotificationCommand; import org.example.autoreview.domain.review.dto.response.ReviewResponseDto; import org.example.autoreview.domain.review.entity.Review; import org.example.autoreview.global.exception.errorcode.ErrorCode; import org.example.autoreview.global.exception.sub_exceptions.BadRequestException; -import org.example.autoreview.global.exception.sub_exceptions.NotFoundException; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Slf4j -@Transactional(readOnly = true) @RequiredArgsConstructor @Service public class CodePostService { - private final CodePostRepository codePostRepository; + private final CodePostCommand codePostCommand; private final MemberCommand memberCommand; + private final CodePostBookmarkCommand codePostBookmarkCommand; + private final NotificationCommand notificationCommand; - @Transactional - public CodePost save(CodePostSaveRequestDto requestDto, Member member) { - CodePost codePost = requestDto.toEntity(member); - return codePostRepository.save(codePost); + /** + * 코드 포스트 저장 메서드이다. + * 복습일을 설정했을 경우에 notification Entity 를 저장한다. + */ + public Long save(CodePostSaveRequestDto requestDto, String email) { + Member member = memberCommand.findByEmail(email); + CodePost codePost = codePostCommand.save(requestDto.toEntity(member)); + + if (requestDto.reviewDay() != null) { + Notification notification = Notification.builder() + .title("ORI 복습 알림") + .content(codePost.getTitle()) + .status(NotificationStatus.PENDING) + .executeTime(codePost.getReviewDay()) + .member(member) + .codePostId(codePost.getId()) + .build(); + notificationCommand.save(notification); + } + return codePost.getId(); + } + + /** + * 키워드에 맞는 제목을 가진 코드 포스트를 조회하는 검색 메서드이다. + * 키워드 뒤에 와일드 카드를 붙혀서 연관된 결과도 조회한다. + */ + public CodePostListResponseDto search(String keyword, int page, int size, String direction, String sortBy, String language) { + keywordValidator(keyword); + String wildcardKeyword = keyword + "*"; + Language lang = Language.of(language); + + if (sortBy.equals("commentCount")) { + Pageable pageable = PageRequest.of(page, size); + return searchSortByCommentCount(wildcardKeyword, pageable, direction, lang); + } + + Sort.Direction sortDirection = Sort.Direction.fromString(direction); + Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sortBy)); + return searchSorted(wildcardKeyword, pageable, lang); } - public CodePost findEntityById(Long id) { - return codePostRepository.findById(id).orElseThrow( - () -> new NotFoundException(ErrorCode.NOT_FOUND_POST) - ); + private CodePostListResponseDto searchSortByCommentCount(String keyword, Pageable pageable, String direction, Language language) { + Page page; + if (direction.equals("desc")) { + page = codePostCommand.searchSortByCommentCountDesc(keyword, pageable, language); + } else { + page = codePostCommand.searchSortByCommentCountAsc(keyword, pageable, language); + } + return new CodePostListResponseDto(page.getContent(), page.getTotalPages()); } - public CodePostResponseDto findById(Long id) { - CodePost codePost = codePostRepository.findById(id).orElseThrow( - () -> new NotFoundException(ErrorCode.NOT_FOUND_POST) - ); - Member member = memberCommand.findById(codePost.getWriterId()); + private CodePostListResponseDto searchSorted(String keyword, Pageable pageable, Language language) { + Page codePostPage = codePostCommand.search(keyword, pageable); + + List content = codePostPage + .stream() + .filter(post -> post.isPublic() && (language == Language.ALL || post.getLanguage() == language)) + .map(post -> { + Member member = memberCommand.findById(post.getWriterId()); + return new CodePostThumbnailResponseDto(post, member); + }) + .toList(); + + return new CodePostListResponseDto(content, codePostPage.getTotalPages()); + } + + /** + * code_post_id를 통해 단일 조회하는 메서드이다. + * 1. member_id를 통해 해당 글이 공개인지 비공개인지 결정한다. + * 2. 반환값에서는 포스트, 작성자, 리뷰, 북마크 여부를 포함한다. + */ + @Transactional(readOnly = true) + public CodePostResponseDto findById(Long id, String email) { + Member member = memberCommand.findByEmail(email); + CodePost codePost = codePostCommand.findByIdIsPublic(id, member.getId()); + Member writer = memberCommand.findById(codePost.getWriterId()); + Optional codePostBookmark = codePostBookmarkCommand.findByCodePostBookmark(email, codePost.getId()); + + boolean isBookmarked = false; + if (codePostBookmark.isPresent()) { + isBookmarked = !codePostBookmark.get().isDeleted(); + } + List reviews = codePost.getReviewList(); List dtoList = reviews.stream() .map(ReviewResponseDto::new) .toList(); - return new CodePostResponseDto(codePost, dtoList, member); + return new CodePostResponseDto(codePost, dtoList, writer, isBookmarked); } - public CodePostListResponseDto search(String keyword, Pageable pageable) { - keywordValidator(keyword); - String wildcardKeyword = keyword + "*"; - Page codePostPage = codePostRepository.search(wildcardKeyword, pageable) - .map(post -> { - Member member = memberCommand.findById(post.getWriterId()); - return new CodePostThumbnailResponseDto(post, member); - }); + /** + * 페이지네이션 정보와 필터할 키워드를 통해 포스트를 페이징 조회하는 메서드이다. + * 1.[direction: 오(내)림차순] / [sortBy: 정렬 기준] / [language: 프로그래밍 언어] + * 2. 정렬 기준이 commentCount 일 때에는 CodePost Entity 에 존재하지 않는 컬럼이기 때문에 따로 페이징 처리한다. + */ + public CodePostListResponseDto findPostByPage(int page, int size, String direction, String sortBy, String language){ + Language lang = Language.of(language); + if(sortBy.equals("commentCount")) { + Pageable pageable = PageRequest.of(page, size); + return findByPageSortByCommentCount(pageable,direction,lang); + } + Sort.Direction sortDirection = Sort.Direction.fromString(direction); + Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection,sortBy)); + return findByPage(pageable,lang); + } - return new CodePostListResponseDto(codePostPage.getContent(), codePostPage.getTotalPages()); + private CodePostListResponseDto findByPageSortByCommentCount(Pageable pageable, String direction, Language language) { + if (direction.equals("desc")) { + Page page = codePostCommand.findByPageSortByCommentCountDesc(pageable, language); + return new CodePostListResponseDto(page.getContent(), page.getTotalPages()); + } + Page page = codePostCommand.findByPageSortByCommentCountAsc(pageable, language); + return new CodePostListResponseDto(page.getContent(), page.getTotalPages()); } - public CodePostListResponseDto findByMemberId(Pageable pageable, Member member) { - Page codePostPage = codePostRepository.findByMemberId(member.getId(), pageable); - return new CodePostListResponseDto(convertListDto(codePostPage,member), codePostPage.getTotalPages()); + private CodePostListResponseDto findByPage(Pageable pageable, Language language) { + Page page = codePostCommand.findByPage(pageable, language); + return new CodePostListResponseDto(page.getContent(), page.getTotalPages()); } - public CodePostListResponseDto mySearch(String keyword, Pageable pageable, Member member) { - keywordValidator(keyword); - Page codePostPage = codePostRepository.mySearch(keyword, pageable, member.getId()); - return new CodePostListResponseDto(convertListDto(codePostPage,member), codePostPage.getTotalPages()); + /** + * 사용자가 작성한 모든 코드 포스트를 조회하는 메서드이다. + */ + public CodePostListResponseDto findByMemberId(String email, int page, int size, String direction, String sortBy, String language) { + Member member = memberCommand.findByEmail(email); + Language lang = Language.of(language); + + Pageable pageable; + if (sortBy.equals("commentCount")) { + pageable = PageRequest.of(page, size); + return findByMemberIdSortByCommentCount(pageable, direction, member.getId(), lang); + } + + Sort.Direction sortDirection = Sort.Direction.fromString(direction); + pageable = PageRequest.of(page, size, Sort.by(sortDirection, sortBy)); + + Page codePostPage = codePostCommand.findByMemberId(member.getId(), pageable); + + List content = codePostPage + .stream() + .filter(post -> post.isPublic() && (lang == Language.ALL || post.getLanguage() == lang)) + .map(post -> new CodePostThumbnailResponseDto(post, member)) + .toList(); + + return new CodePostListResponseDto(content, codePostPage.getTotalPages()); } - private static void keywordValidator(String keyword) { - if (keyword == null || keyword.isBlank()) { - throw new IllegalArgumentException(ErrorCode.INVALID_PARAMETER.getMessage()); + private CodePostListResponseDto findByMemberIdSortByCommentCount(Pageable pageable, String direction, Long memberId, Language language) { + Page page; + + if (direction.equalsIgnoreCase("desc")) { + page = codePostCommand.findByMemberIdSortByCommentCountDesc(pageable, memberId, language); + } else { + page = codePostCommand.findByMemberIdSortByCommentCountAsc(pageable, memberId, language); } + + return new CodePostListResponseDto(page.getContent(), page.getTotalPages()); } - private List convertListDto(Page page, Member member) { - return page.stream() - .map(post -> getCodePostThumbnailResponseDto(post,member)) - .collect(Collectors.toList()); + + /** + * 사용자가 작성한 포스트들 중 키워드에 맞는 포스트들을 조회하는 메서드이다. + */ + public CodePostListResponseDto mySearch(String keyword, String email, int page, int size, String direction, String sortBy, String language) { + Member member = memberCommand.findByEmail(email); + keywordValidator(keyword); + Language lang = Language.of(language); + + if (sortBy.equals("commentCount")) { + Pageable pageable = PageRequest.of(page, size); + return mySearchSortByCommentCount(keyword, member, pageable, direction, lang); + } + + Sort.Direction sortDirection = Sort.Direction.fromString(direction); + Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sortBy)); + return mySearchSorted(keyword, member, pageable, lang); } - private CodePostThumbnailResponseDto getCodePostThumbnailResponseDto(CodePost codePost, Member member) { - return new CodePostThumbnailResponseDto(codePost, member); + private CodePostListResponseDto mySearchSortByCommentCount(String keyword, Member member, Pageable pageable, String direction, Language language) { + Page page; + if (direction.equals("desc")) { + page = codePostCommand.mySearchSortByCommentCountDesc(keyword, pageable, member.getId(), language); + } else { + page = codePostCommand.mySearchSortByCommentCountAsc(keyword, pageable, member.getId(), language); + } + return new CodePostListResponseDto(page.getContent(), page.getTotalPages()); } - public CodePostListResponseDto findByPage(Pageable pageable) { - Page page = codePostRepository.findByPage(pageable); + private CodePostListResponseDto mySearchSorted(String keyword, Member member, Pageable pageable, Language language) { + Page codePostPage = codePostCommand.mySearch(keyword, pageable, member.getId()); - return new CodePostListResponseDto(page.getContent(), page.getTotalPages()); + List content = codePostPage + .stream() + .filter(post -> post.isPublic() && (language == Language.ALL || post.getLanguage() == language)) + .map(post -> new CodePostThumbnailResponseDto(post, member)) + .toList(); + + return new CodePostListResponseDto(content, codePostPage.getTotalPages()); } - @Transactional - public CodePost update(CodePostUpdateRequestDto requestDto, String email) { - CodePost codePost = codePostRepository.findById(requestDto.getId()).orElseThrow( - () -> new NotFoundException(ErrorCode.NOT_FOUND_POST) - ); + private static void keywordValidator(String keyword) { + if (keyword == null || keyword.isBlank()) { + throw new IllegalArgumentException(ErrorCode.INVALID_PARAMETER.getMessage()); + } + } + + /** + * 사용자 유효성 검사 후 포스트를 업데이트하는 메서드이다. + * 1. 복습일 설정을 on 에서 off 로 변경할 경우 삭제 + * 2. 복습일 설정을 on 에서 on 으로 변경할 경우 업데이트 + * 3. 복습일 설정을 off 에서 on 으로 변경할 경우 저장 + */ + public Long update(CodePostUpdateRequestDto requestDto, String email) { + CodePost codePost = codePostCommand.findById(requestDto.getId()); memberValidator(email, codePost); - codePost.update(requestDto); - return codePost; + Member member = memberCommand.findByEmail(email); + codePostCommand.update(codePost.getId(), requestDto); + + boolean notificationExists = notificationCommand.existsByCodePostId(requestDto.getId()); + + if (notificationExists) { + Notification notification = notificationCommand.findByCodePostId(codePost.getId()); + if (requestDto.getReviewDay() == null) { + notificationCommand.delete(notification); + } else { + notificationCommand.update(notification, codePost, NotificationStatus.PENDING); + } + } else if (requestDto.getReviewDay() != null){ + notificationSave(member, requestDto); + } + return codePost.getId(); + } + + private void notificationSave(Member member, CodePostUpdateRequestDto requestDto) { + Notification notification = Notification.builder() + .title("ORI 복습 알림") + .content(requestDto.getTitle()) + .status(NotificationStatus.PENDING) + .executeTime(requestDto.getReviewDay()) + .member(member) + .codePostId(requestDto.getId()) + .build(); + notificationCommand.save(notification); } - @Transactional + /** + * 사용자 유효성 검사 후 포스트를 삭제하는 메서드이다. + * 1. 해당 포스트에 알림이 존재할 경우 삭제한다. + */ public Long delete(Long id, String email) { - CodePost codePost = codePostRepository.findById(id).orElseThrow( - () -> new NotFoundException(ErrorCode.NOT_FOUND_POST) - ); + if (notificationCommand.existsByCodePostId(id)) { + Notification notification = notificationCommand.findByCodePostId(id); + notificationCommand.delete(notification); + } + CodePost codePost = codePostCommand.findById(id); memberValidator(email, codePost); - codePostRepository.delete(codePost); + codePostCommand.delete(codePost); return id; } diff --git a/src/main/java/org/example/autoreview/domain/comment/base/CommentCommand.java b/src/main/java/org/example/autoreview/domain/comment/base/CommentCommand.java index da53cc6..96356f6 100644 --- a/src/main/java/org/example/autoreview/domain/comment/base/CommentCommand.java +++ b/src/main/java/org/example/autoreview/domain/comment/base/CommentCommand.java @@ -1,17 +1,52 @@ package org.example.autoreview.domain.comment.base; import lombok.RequiredArgsConstructor; +import org.example.autoreview.domain.comment.base.dto.request.CommentUpdateRequestDto; import org.example.autoreview.global.exception.base_exceptions.CustomRuntimeException; import org.example.autoreview.global.exception.errorcode.ErrorCode; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public abstract class CommentCommand> { protected final R repository; + @Transactional + public C save(C comment) { + return repository.save(comment); + } + + @Transactional(readOnly = true) public C findById(Long id) { return repository.findById(id).orElseThrow( () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_COMMENT) ); } + + @Transactional(readOnly = true) + public Page findByCommentPage(Long id, Pageable pageable) { + return repository.findByCommentPage(id, pageable); + } + + @Transactional(readOnly = true) + public Page findByReplyPage(Long id, Long parentId, Pageable pageable) { + return repository.findByReplyPage(id, parentId, pageable); + } + + @Transactional + public void update(Long commentId, CommentUpdateRequestDto requestDto) { + C comment = repository.findById(commentId).orElseThrow( + () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_COMMENT) + ); + comment.update(requestDto); + } + + @Transactional + public void delete(C comment) { + repository.delete(comment); + } + + } diff --git a/src/main/java/org/example/autoreview/domain/comment/base/CommentService.java b/src/main/java/org/example/autoreview/domain/comment/base/CommentService.java index 0f7d421..171d125 100644 --- a/src/main/java/org/example/autoreview/domain/comment/base/CommentService.java +++ b/src/main/java/org/example/autoreview/domain/comment/base/CommentService.java @@ -12,7 +12,6 @@ import org.example.autoreview.global.exception.errorcode.ErrorCode; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; @@ -21,37 +20,41 @@ public abstract class CommentService> { private final String SECRETE_COMMENT = "비밀 댓글 입니다."; - protected final R commentRepository; - protected final CommentCommand commentCommand; + protected final CommentCommand commentCommand; protected final MemberCommand memberCommand; - @Transactional + /** + * (대)댓글 저장하는 메서드이다. + * parent_id가 존재할 경우 대댓글로서 저장 + * 그렇지 않으면 댓글로 저장 + */ public Long save(CommentSaveRequestDto requestDto, String email) { Member writer = memberCommand.findByEmail(email); if (requestDto.parentId() != null) { C parent = commentCommand.findById(requestDto.parentId()); - return commentRepository.save(createReplyEntity(requestDto, parent, writer)).getId(); + return commentCommand.save(createReplyEntity(requestDto, parent, writer)).getId(); } - return commentRepository.save(createCommentEntity(requestDto, writer)).getId(); + return commentCommand.save(createCommentEntity(requestDto, writer)).getId(); } protected abstract C createReplyEntity(CommentSaveRequestDto requestDto, C parent, Member writer); + protected abstract C createCommentEntity(CommentSaveRequestDto requestDto, Member writer); // User 상위 댓글 조회 public CommentListResponseDto userFindCommentPage(Long postId, Pageable pageable, String email) { - Page commentPage = commentRepository.findByCommentPage(postId, pageable); + Page commentPage = commentCommand.findByCommentPage(postId, pageable); List dtoList = new ArrayList<>(); for (C c : commentPage.getContent()) { Member writer = memberCommand.findById(c.getWriterId()); // 공개 or 게시글 작성자 or 댓글 작성자 일 경우 - if (c.isPublic() || isPostWriter(postId,email) || writer.getEmail().equals(email)) { - dtoList.add(new CommentResponseDto(c,c.getBody(),writer.getEmail(),writer.getNickname())); + if (c.isPublic() || isPostWriter(postId, email) || writer.getEmail().equals(email)) { + dtoList.add(new CommentResponseDto(c, c.getBody(), writer.getEmail(), writer.getNickname())); continue; } - dtoList.add(new CommentResponseDto(c,SECRETE_COMMENT,writer.getEmail(),writer.getNickname())); + dtoList.add(new CommentResponseDto(c, SECRETE_COMMENT, writer.getEmail(), writer.getNickname())); } return new CommentListResponseDto(dtoList, commentPage.getTotalPages()); @@ -61,24 +64,24 @@ public CommentListResponseDto userFindCommentPage(Long postId, Pageable pageable // User 하위 댓글 조회 public CommentListResponseDto userFindReplyPage(Long postId, Long parentId, Pageable pageable, String email) { - Page replyPage = commentRepository.findByReplyPage(postId, parentId, pageable); + Page replyPage = commentCommand.findByReplyPage(postId, parentId, pageable); List dtoList = new ArrayList<>(); for (C c : replyPage.getContent()) { Member writer = memberCommand.findById(c.getWriterId()); // 공개 or 언급된 사용자 or 댓글 작성자 일 경우 if (c.isPublic() || c.getMentionEmail().equals(email) || writer.getEmail().equals(email)) { - dtoList.add(new CommentResponseDto(c,c.getBody(),writer.getEmail(),writer.getNickname())); + dtoList.add(new CommentResponseDto(c, c.getBody(), writer.getEmail(), writer.getNickname())); continue; } - dtoList.add(new CommentResponseDto(c,SECRETE_COMMENT,writer.getEmail(),writer.getNickname())); + dtoList.add(new CommentResponseDto(c, SECRETE_COMMENT, writer.getEmail(), writer.getNickname())); } return new CommentListResponseDto(dtoList, replyPage.getTotalPages()); } // Guest 상위 댓글 조회 public CommentListResponseDto guestFindCommentPage(Long postId, Pageable pageable) { - Page commentPage = commentRepository.findByCommentPage(postId, pageable); + Page commentPage = commentCommand.findByCommentPage(postId, pageable); List dtoList = new ArrayList<>(); return getCommentListResponseDto(commentPage, dtoList); @@ -86,7 +89,7 @@ public CommentListResponseDto guestFindCommentPage(Long postId, Pageable pageabl // Guest 하위 댓글 조회 public CommentListResponseDto guestFindReplyPage(Long postId, Long parentId, Pageable pageable) { - Page replyPage = commentRepository.findByReplyPage(postId, parentId, pageable); + Page replyPage = commentCommand.findByReplyPage(postId, parentId, pageable); List dtoList = new ArrayList<>(); return getCommentListResponseDto(replyPage, dtoList); @@ -96,30 +99,28 @@ private CommentListResponseDto getCommentListResponseDto(Page replyPage, List for (C c : replyPage.getContent()) { Member writer = memberCommand.findById(c.getWriterId()); if (c.isPublic()) { - dtoList.add(new CommentResponseDto(c,c.getBody(),writer.getEmail(),writer.getNickname())); + dtoList.add(new CommentResponseDto(c, c.getBody(), writer.getEmail(), writer.getNickname())); continue; } - dtoList.add(new CommentResponseDto(c,SECRETE_COMMENT,writer.getEmail(),writer.getNickname())); + dtoList.add(new CommentResponseDto(c, SECRETE_COMMENT, writer.getEmail(), writer.getNickname())); } return new CommentListResponseDto(dtoList, replyPage.getTotalPages()); } // 수정 - @Transactional public Long update(CommentUpdateRequestDto requestDto, String email) { C comment = commentCommand.findById(requestDto.commentId()); memberValidator(comment.getWriterId(), email); - comment.update(requestDto); + commentCommand.update(comment.getId(), requestDto); return comment.getId(); } // 삭제 - @Transactional public Long delete(CommentDeleteRequestDto requestDto, String email) { C comment = commentCommand.findById(requestDto.commentId()); memberValidator(comment.getWriterId(), email); - commentRepository.delete(comment); // deleteById 는 내부적으로 findById가 존재해서 조회가 한 번 더 일어남 + commentCommand.delete(comment); // deleteById 는 내부적으로 findById가 존재해서 조회가 한 번 더 일어남 return requestDto.commentId(); } diff --git a/src/main/java/org/example/autoreview/domain/comment/codepost/service/CodePostCommentCommand.java b/src/main/java/org/example/autoreview/domain/comment/codepost/service/CodePostCommentCommand.java index 475d7d9..ce86666 100644 --- a/src/main/java/org/example/autoreview/domain/comment/codepost/service/CodePostCommentCommand.java +++ b/src/main/java/org/example/autoreview/domain/comment/codepost/service/CodePostCommentCommand.java @@ -11,4 +11,5 @@ public class CodePostCommentCommand extends CommentCommand save(@RequestBody FcmTokenSaveRequestDto requestDto, - @AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok().body(fcmTokenMemberService.save(requestDto, userDetails.getUsername())); + public ResponseEntity save(@RequestBody FcmTokenRequestDto requestDto, + @AuthenticationPrincipal UserDetails userDetails) { + return ResponseEntity.ok().body(fcmTokenService.save(requestDto, userDetails.getUsername())); + } + + @Operation(summary = "fcm 삭제 API", description = "FCM 삭제") + @DeleteMapping + public ResponseEntity delete(@RequestBody FcmTokenRequestDto fcmTokenRequestDto, + @AuthenticationPrincipal UserDetails userDetails) { + return ResponseEntity.ok().body(fcmTokenService.delete(fcmTokenRequestDto, userDetails.getUsername())); } } diff --git a/src/main/java/org/example/autoreview/domain/fcm/dto/request/FcmTokenSaveRequestDto.java b/src/main/java/org/example/autoreview/domain/fcm/dto/request/FcmTokenRequestDto.java similarity index 92% rename from src/main/java/org/example/autoreview/domain/fcm/dto/request/FcmTokenSaveRequestDto.java rename to src/main/java/org/example/autoreview/domain/fcm/dto/request/FcmTokenRequestDto.java index 0d55d3e..135c5b4 100644 --- a/src/main/java/org/example/autoreview/domain/fcm/dto/request/FcmTokenSaveRequestDto.java +++ b/src/main/java/org/example/autoreview/domain/fcm/dto/request/FcmTokenRequestDto.java @@ -7,7 +7,7 @@ @Getter @NoArgsConstructor -public class FcmTokenSaveRequestDto { +public class FcmTokenRequestDto { private String fcmToken; diff --git a/src/main/java/org/example/autoreview/domain/fcm/entity/FcmToken.java b/src/main/java/org/example/autoreview/domain/fcm/entity/FcmToken.java index a8e9ab1..d100582 100644 --- a/src/main/java/org/example/autoreview/domain/fcm/entity/FcmToken.java +++ b/src/main/java/org/example/autoreview/domain/fcm/entity/FcmToken.java @@ -1,12 +1,7 @@ package org.example.autoreview.domain.fcm.entity; -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.*; + import java.time.LocalDate; import lombok.Builder; import lombok.Getter; @@ -25,6 +20,7 @@ public class FcmToken { @JoinColumn private Member member; + @Column(nullable = false, unique = true) private String token; private LocalDate lastUsedDate; @@ -36,7 +32,7 @@ public FcmToken(Member member, String token) { this.lastUsedDate = LocalDate.now(); } - public void updateDate() { - this.lastUsedDate = LocalDate.now(); + public void updateDate(LocalDate newDate) { + this.lastUsedDate = newDate; } } diff --git a/src/main/java/org/example/autoreview/domain/fcm/entity/FcmTokenRepository.java b/src/main/java/org/example/autoreview/domain/fcm/entity/FcmTokenRepository.java index 56c699a..fc1d0a0 100644 --- a/src/main/java/org/example/autoreview/domain/fcm/entity/FcmTokenRepository.java +++ b/src/main/java/org/example/autoreview/domain/fcm/entity/FcmTokenRepository.java @@ -1,7 +1,12 @@ package org.example.autoreview.domain.fcm.entity; +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface FcmTokenRepository extends JpaRepository { + @Query("SELECT f FROM FcmToken f WHERE f.member.id = :memberId AND f.token = :token") + FcmToken findByTokenAndMember(@Param("token") String token, @Param("memberId") Long memberId); + } diff --git a/src/main/java/org/example/autoreview/domain/fcm/service/FcmTokenCommand.java b/src/main/java/org/example/autoreview/domain/fcm/service/FcmTokenCommand.java new file mode 100644 index 0000000..57203ad --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/fcm/service/FcmTokenCommand.java @@ -0,0 +1,36 @@ +package org.example.autoreview.domain.fcm.service; + +import lombok.RequiredArgsConstructor; +import org.example.autoreview.domain.fcm.entity.FcmToken; +import org.example.autoreview.domain.fcm.entity.FcmTokenRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Map; + +@RequiredArgsConstructor +@Component +public class FcmTokenCommand { + + private final FcmTokenRepository fcmTokenRepository; + + @Transactional + public Long save(FcmToken fcmToken) { + return fcmTokenRepository.save(fcmToken).getId(); + } + + @Transactional + public void fcmTokensUpdate(Map fcmTokenMap) { + for (Map.Entry entry : fcmTokenMap.entrySet()) { + entry.getKey().updateDate(entry.getValue()); + } + } + + @Transactional + public Long delete(String token, Long memberId) { + FcmToken fcmToken = fcmTokenRepository.findByTokenAndMember(token, memberId); + fcmTokenRepository.delete(fcmToken); + return fcmToken.getId(); + } +} diff --git a/src/main/java/org/example/autoreview/domain/fcm/service/FcmTokenMemberService.java b/src/main/java/org/example/autoreview/domain/fcm/service/FcmTokenMemberService.java deleted file mode 100644 index 0e36416..0000000 --- a/src/main/java/org/example/autoreview/domain/fcm/service/FcmTokenMemberService.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.example.autoreview.domain.fcm.service; - -import lombok.RequiredArgsConstructor; -import org.example.autoreview.domain.fcm.dto.request.FcmTokenSaveRequestDto; -import org.example.autoreview.domain.member.entity.Member; -import org.example.autoreview.domain.member.service.MemberService; -import org.springframework.stereotype.Service; - -@RequiredArgsConstructor -@Service -public class FcmTokenMemberService { - - private final MemberService memberService; - private final FcmTokenService fcmTokenService; - - public Long save(FcmTokenSaveRequestDto requestDto, String email) { - Member member = memberService.findByEmail(email); - return fcmTokenService.save(requestDto, member); - } -} diff --git a/src/main/java/org/example/autoreview/domain/fcm/service/FcmTokenService.java b/src/main/java/org/example/autoreview/domain/fcm/service/FcmTokenService.java index 87f4b1c..5b1ad03 100644 --- a/src/main/java/org/example/autoreview/domain/fcm/service/FcmTokenService.java +++ b/src/main/java/org/example/autoreview/domain/fcm/service/FcmTokenService.java @@ -1,55 +1,54 @@ package org.example.autoreview.domain.fcm.service; -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.Message; -import java.util.List; -import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.example.autoreview.domain.fcm.dto.request.FcmTokenSaveRequestDto; +import org.example.autoreview.domain.fcm.dto.request.FcmTokenRequestDto; import org.example.autoreview.domain.fcm.entity.FcmToken; -import org.example.autoreview.domain.fcm.entity.FcmTokenRepository; import org.example.autoreview.domain.member.entity.Member; +import org.example.autoreview.domain.member.service.MemberCommand; +import org.example.autoreview.global.exception.base_exceptions.CustomRuntimeException; +import org.example.autoreview.global.exception.errorcode.ErrorCode; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; @Slf4j @RequiredArgsConstructor @Service public class FcmTokenService { - private final FcmTokenRepository fcmTokenRepository; + private final MemberCommand memberCommand; + private final FcmTokenCommand fcmTokenCommand; - @Transactional(readOnly = false) - public Long save(FcmTokenSaveRequestDto requestDto, Member member) { - FcmToken fcmToken = requestDto.toEntity(member); - return fcmTokenRepository.save(fcmToken).getId(); - } + // 토큰 단위 락 (메모리 기준) + private final Map lockMap = new ConcurrentHashMap<>(); - public void pushNotification(List fcmTokens, String title, String content) { - log.info("pushNotification 트랜잭션 존재 여부: {}", TransactionSynchronizationManager.isActualTransactionActive()); - - for (FcmToken fcmToken : fcmTokens) { - CompletableFuture.runAsync(() -> { - try { - Message message = Message.builder() - .putData("title", title) - .putData("body", content) - .setToken(fcmToken.getToken()) - .build(); - - FirebaseMessaging.getInstance().send(message); - fcmToken.updateDate(); - - } catch (FirebaseMessagingException e) { - log.error("Failed to send message to device {}: {}", fcmToken.getId(), e.getMessage()); - } catch (Exception e) { - log.error("An unexpected error occurred: {}", e.getMessage()); - } - }); + public Long save(FcmTokenRequestDto requestDto, String email) { + String tokenKey = requestDto.getFcmToken(); + ReentrantLock lock = lockMap.computeIfAbsent(tokenKey, k -> new ReentrantLock()); + + boolean locked = lock.tryLock(); + if (!locked) { + throw new CustomRuntimeException(ErrorCode.DUPLICATE_ERROR); + } + + try { + Member member = memberCommand.findByEmail(email); + FcmToken fcmToken = requestDto.toEntity(member); + return fcmTokenCommand.save(fcmToken); + } catch (DataIntegrityViolationException e) { + throw new CustomRuntimeException(ErrorCode.DUPLICATE_ERROR); + }finally { + lock.unlock(); } } + + public Long delete(FcmTokenRequestDto requestDto, String email) { + Member member = memberCommand.findByEmail(email); + return fcmTokenCommand.delete(requestDto.getFcmToken(), member.getId()); + } } diff --git a/src/main/java/org/example/autoreview/domain/github/controller/GithubController.java b/src/main/java/org/example/autoreview/domain/github/controller/GithubController.java new file mode 100644 index 0000000..8d7f584 --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/github/controller/GithubController.java @@ -0,0 +1,47 @@ +package org.example.autoreview.domain.github.controller; + +import io.swagger.v3.oas.annotations.Operation; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.example.autoreview.domain.github.dto.request.GithubCodePushRequestDto; +import org.example.autoreview.domain.github.dto.request.GithubCodeRequestDto; +import org.example.autoreview.domain.github.service.GithubService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/v1/api/github") +public class GithubController { + + private final GithubService githubService; + + @Operation(description = "깃헙 인증 토큰이 있는지 확인") + @GetMapping("/token/check") + public ResponseEntity checkToken(@AuthenticationPrincipal UserDetails userDetails) { + return ResponseEntity.ok().body(githubService.tokenCheck(userDetails.getUsername())); + } + + @Operation(description = "깃헙 리다이렉트 된 일회성 인증 코드로 인증 토큰 발급 후 저장") + @PostMapping("/callback") + public ResponseEntity callbackAndSave(@RequestBody GithubCodeRequestDto requestDto, + @AuthenticationPrincipal UserDetails userDetails) { + String accessToken = githubService.getAccessToken(requestDto); + return ResponseEntity.ok().body(githubService.save(accessToken, userDetails.getUsername())); + } + + @Operation + @PostMapping("/push/post/code") + public ResponseEntity push(@AuthenticationPrincipal UserDetails userDetails, + @RequestBody GithubCodePushRequestDto requestDto) throws IOException { + githubService.pushToGithub(userDetails.getUsername(), requestDto); + return ResponseEntity.ok().body("Push to GitHub completed successfully."); + } + +} diff --git a/src/main/java/org/example/autoreview/domain/github/dto/request/GithubCodePushRequestDto.java b/src/main/java/org/example/autoreview/domain/github/dto/request/GithubCodePushRequestDto.java new file mode 100644 index 0000000..8ff846d --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/github/dto/request/GithubCodePushRequestDto.java @@ -0,0 +1,24 @@ +package org.example.autoreview.domain.github.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record GithubCodePushRequestDto( + + @Schema(description = "코드 포스트 제목", example = "[BOJ] 0000: test 해보기") + String title, + + @Schema(description = "난이도", example = "4") + int level, + + @Schema(description = "해설", example = "test 기법을 사용해서 구현") + String description, + + @NotNull + @Schema(description = "사용 언어", example = "java") + String language, + + @Schema(description = "코드", example = "import test") + String code +) { +} diff --git a/src/main/java/org/example/autoreview/domain/github/dto/request/GithubCodeRequestDto.java b/src/main/java/org/example/autoreview/domain/github/dto/request/GithubCodeRequestDto.java new file mode 100644 index 0000000..e147aed --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/github/dto/request/GithubCodeRequestDto.java @@ -0,0 +1,10 @@ +package org.example.autoreview.domain.github.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record GithubCodeRequestDto( + + @Schema(description = "리다이렉트로 온 일회성 코드") + String code +) { +} diff --git a/src/main/java/org/example/autoreview/domain/github/dto/request/GithubTokenRequestDto.java b/src/main/java/org/example/autoreview/domain/github/dto/request/GithubTokenRequestDto.java new file mode 100644 index 0000000..4fe5236 --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/github/dto/request/GithubTokenRequestDto.java @@ -0,0 +1,20 @@ +package org.example.autoreview.domain.github.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.example.autoreview.domain.github.entity.GithubToken; + +public record GithubTokenRequestDto( + + @Schema(description = "인증 코드") + String githubToken, + + @Schema(description = "사용자 이메일", example = "abc@gmail.com") + String email +) { + public GithubToken toEntity(){ + return GithubToken.builder() + .githubToken(githubToken) + .email(email) + .build(); + } +} diff --git a/src/main/java/org/example/autoreview/domain/github/entity/GithubToken.java b/src/main/java/org/example/autoreview/domain/github/entity/GithubToken.java new file mode 100644 index 0000000..6b24e3e --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/github/entity/GithubToken.java @@ -0,0 +1,37 @@ +package org.example.autoreview.domain.github.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.autoreview.global.common.basetime.BaseEntity; + +@Getter +@NoArgsConstructor +@Entity +public class GithubToken extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + private String githubToken; + + @Builder + public GithubToken(String email, String githubToken) { + this.email = email; + this.githubToken = githubToken; + } + + public GithubToken update(String githubToken) { + this.githubToken = githubToken; + return this; + } + +} diff --git a/src/main/java/org/example/autoreview/domain/github/entity/GithubTokenRepository.java b/src/main/java/org/example/autoreview/domain/github/entity/GithubTokenRepository.java new file mode 100644 index 0000000..cfeb29d --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/github/entity/GithubTokenRepository.java @@ -0,0 +1,12 @@ +package org.example.autoreview.domain.github.entity; + +import io.lettuce.core.dynamic.annotation.Param; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GithubTokenRepository extends JpaRepository { + + Optional findByEmail(@Param("email") String email); + + boolean existsByEmail(@Param("email") String email); +} diff --git a/src/main/java/org/example/autoreview/domain/github/service/GithubCommand.java b/src/main/java/org/example/autoreview/domain/github/service/GithubCommand.java new file mode 100644 index 0000000..7ded920 --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/github/service/GithubCommand.java @@ -0,0 +1,43 @@ +package org.example.autoreview.domain.github.service; + +import lombok.RequiredArgsConstructor; +import org.example.autoreview.domain.github.entity.GithubToken; +import org.example.autoreview.domain.github.entity.GithubTokenRepository; +import org.example.autoreview.global.exception.base_exceptions.CustomRuntimeException; +import org.example.autoreview.global.exception.errorcode.ErrorCode; +import org.example.autoreview.global.exception.sub_exceptions.NotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class GithubCommand { + + private final GithubTokenRepository githubTokenRepository; + + @Transactional + public GithubToken save(GithubToken githubToken) { + return githubTokenRepository.save(githubToken); + } + + @Transactional + public GithubToken update(Long githubTokenId, String newToken) { + GithubToken githubToken = githubTokenRepository.findById(githubTokenId).orElseThrow( + () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_GITHUB_TOKEN) + ); + return githubToken.update(newToken); + } + + @Transactional(readOnly = true) + public GithubToken findByEmail(String email) { + return githubTokenRepository.findByEmail(email).orElseThrow( + () -> new NotFoundException(ErrorCode.NOT_FOUND_GITHUB_TOKEN) + ); + } + + @Transactional(readOnly = true) + public boolean existsByEmail(String email) { + return githubTokenRepository.existsByEmail(email); + } + +} diff --git a/src/main/java/org/example/autoreview/domain/github/service/GithubService.java b/src/main/java/org/example/autoreview/domain/github/service/GithubService.java new file mode 100644 index 0000000..5c8901e --- /dev/null +++ b/src/main/java/org/example/autoreview/domain/github/service/GithubService.java @@ -0,0 +1,99 @@ +package org.example.autoreview.domain.github.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.autoreview.domain.codepost.entity.Language; +import org.example.autoreview.domain.github.dto.request.GithubCodePushRequestDto; +import org.example.autoreview.domain.github.dto.request.GithubCodeRequestDto; +import org.example.autoreview.domain.github.dto.request.GithubTokenRequestDto; +import org.example.autoreview.domain.github.entity.GithubToken; +import org.kohsuke.github.GHFileNotFoundException; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.io.IOException; +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +@Service +public class GithubService { + private final String REPO_NAME = "Ori"; + + private final GithubCommand githubCommand; + private final WebClient webClient; + + @Value("${github.client-id}") + private String clientId; + + @Value("${github.client-secret}") + private String clientSecret; + + public Long save(String accessToken, String email) { + GithubToken githubToken = GithubToken.builder().githubToken(accessToken).email(email).build(); + + return githubCommand.save(githubToken).getId(); + } + + public Long update(GithubTokenRequestDto requestDto) { + GithubToken githubToken = githubCommand.findByEmail(requestDto.email()); + return githubCommand.update(githubToken.getId(), requestDto.githubToken()).getId(); + } + + public boolean tokenCheck(String email) { + return githubCommand.existsByEmail(email); + } + + public String getAccessToken(GithubCodeRequestDto requestDto) { + Map body = Map.of("client_id", clientId, "client_secret", clientSecret, "code", requestDto.code()); + + Map response = webClient.post().uri("https://github.com/login/oauth/access_token").header(HttpHeaders.ACCEPT, "application/json").bodyValue(body).retrieve().bodyToMono(Map.class).block(); + + return (String) response.get("access_token"); + } + + public void pushToGithub(String email, GithubCodePushRequestDto requestDto) throws IOException { + String fileExtension = Language.of(requestDto.language()).getFileExtension(); + + String codePath = requestDto.title() + "[" + requestDto.language() + "]" + "/code." + fileExtension; + String readmePath = requestDto.title() + "[" + requestDto.language() + "]" + "/description.md"; + + String githubToken = githubCommand.findByEmail(email).getGithubToken(); + + GitHub github = new GitHubBuilder().withOAuthToken(githubToken).build(); + GHRepository repo = getOrCreateRepository(github, getGithubName(github)); + + pushFile(repo, codePath, requestDto.code(), "Initial commit: code", "Updated code"); + + String newDescription = requestDto.description().replaceAll("\n", " \n"); + pushFile(repo, readmePath, newDescription, "Initial commit: description", "Updated README"); + + } + + private GHRepository getOrCreateRepository(GitHub github, String username) throws IOException { + try { + return github.getRepository(username + "/" + REPO_NAME); + } catch (GHFileNotFoundException e) { + return github.createRepository(REPO_NAME).description("Auto-created ori repo").private_(false).create(); + } + } + + private void pushFile(GHRepository repo, String path, String content, String initialMsg, String updateMsg) throws IOException { + try { + org.kohsuke.github.GHContent existing = repo.getFileContent(path, "main"); + existing.update(content, updateMsg, "main"); + } catch (GHFileNotFoundException e) { + repo.createContent().path(path).message(initialMsg).content(content).commit(); + } + } + + private String getGithubName(GitHub github) throws IOException { + return github.getMyself().getLogin(); + } + +} diff --git a/src/main/java/org/example/autoreview/domain/member/service/MemberCommand.java b/src/main/java/org/example/autoreview/domain/member/service/MemberCommand.java index 2094740..e3fe7d2 100644 --- a/src/main/java/org/example/autoreview/domain/member/service/MemberCommand.java +++ b/src/main/java/org/example/autoreview/domain/member/service/MemberCommand.java @@ -14,13 +14,14 @@ public class MemberCommand { private final MemberRepository memberRepository; - @Transactional + @Transactional(readOnly = true) public Member findById(Long id) { return memberRepository.findById(id).orElseThrow( () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_MEMBER) ); } + @Transactional(readOnly = true) public Member findByEmail(String email) { return memberRepository.findByEmail(email).orElseThrow( () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_MEMBER) diff --git a/src/main/java/org/example/autoreview/domain/notification/controller/NotificationController.java b/src/main/java/org/example/autoreview/domain/notification/controller/NotificationController.java index a2f8972..8da7d11 100644 --- a/src/main/java/org/example/autoreview/domain/notification/controller/NotificationController.java +++ b/src/main/java/org/example/autoreview/domain/notification/controller/NotificationController.java @@ -2,20 +2,16 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.List; import lombok.RequiredArgsConstructor; import org.example.autoreview.domain.notification.dto.response.NotificationResponseDto; -import org.example.autoreview.domain.notification.service.NotificationDtoService; +import org.example.autoreview.domain.notification.service.NotificationService; import org.example.autoreview.global.scheduler.NotificationScheduler; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.util.List; @Tag(name = "알림 API", description = "알림 API") @RequestMapping("/v1/api/notification") @@ -24,18 +20,18 @@ public class NotificationController { private final NotificationScheduler notificationScheduler; - private final NotificationDtoService notificationDtoService; + private final NotificationService notificationService; @Operation(summary = "알림 전체 조회", description = "회원 정보는 헤더에서") @GetMapping("/list") public ResponseEntity> findAll() { - return ResponseEntity.ok().body(notificationDtoService.findAll()); + return ResponseEntity.ok().body(notificationService.findAll()); } @Operation(summary = "회원 알림 전체 조회", description = "회원 정보는 헤더에서") @GetMapping("/own") public ResponseEntity> findAll(@AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok().body(notificationDtoService.findAllByMemberId(userDetails.getUsername())); + return ResponseEntity.ok().body(notificationService.findAllByMemberId(userDetails.getUsername())); } @Operation(summary = "회원 알림 날짜별 조회", description = "회원 정보는 헤더에서") @@ -43,22 +39,23 @@ public ResponseEntity> findAll(@AuthenticationPrin public ResponseEntity> findAllByDate(@AuthenticationPrincipal UserDetails userDetails, @RequestParam int year, @RequestParam int month) { - return ResponseEntity.ok().body(notificationDtoService.findAllByDate(userDetails.getUsername(),year,month)); + return ResponseEntity.ok().body(notificationService.findAllByDate(userDetails.getUsername(),year,month)); } @Operation(summary = "회원 안읽은 알림 전체 조회", description = "회원이 안읽은 알림을 조회한다.") @GetMapping("/own/unchecked") public ResponseEntity> findAllNotificationIsNotCheckedByMemberId(@AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok().body(notificationDtoService.findAllNotificationIsNotCheckedByMemberId(userDetails.getUsername())); + return ResponseEntity.ok().body(notificationService.findAllNotificationIsNotCheckedByMemberId(userDetails.getUsername())); } @Operation(summary = "알림 상태 변경", description = "알림을 읽음 상태로 변경한다.") @PutMapping - public ResponseEntity statUpdate(@RequestParam Long id) { - notificationDtoService.stateUpdate(id); + public ResponseEntity stateUpdate(@RequestParam Long id) { + notificationService.stateUpdate(id); return ResponseEntity.ok().body("update success"); } + // swagger 에서 test 하기 위해 scheduler 클래스 사용 @Operation(summary = "푸쉬 알림 강제 시작") @PutMapping("/push") public ResponseEntity push() { diff --git a/src/main/java/org/example/autoreview/domain/notification/service/NotificationCommand.java b/src/main/java/org/example/autoreview/domain/notification/service/NotificationCommand.java index 327dd33..acda5ca 100644 --- a/src/main/java/org/example/autoreview/domain/notification/service/NotificationCommand.java +++ b/src/main/java/org/example/autoreview/domain/notification/service/NotificationCommand.java @@ -2,11 +2,17 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.example.autoreview.domain.codepost.entity.CodePost; +import org.example.autoreview.domain.notification.dto.response.NotificationResponseDto; import org.example.autoreview.domain.notification.entity.Notification; import org.example.autoreview.domain.notification.entity.NotificationRepository; +import org.example.autoreview.domain.notification.enums.NotificationStatus; +import org.example.autoreview.global.exception.errorcode.ErrorCode; +import org.example.autoreview.global.exception.sub_exceptions.NotFoundException; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.List; @Slf4j @@ -16,6 +22,70 @@ public class NotificationCommand { private final NotificationRepository notificationRepository; + @Transactional + public void save(Notification notification) { + notificationRepository.save(notification); + } + + @Transactional(readOnly = true) + public boolean existsByCodePostId(Long id) { + return notificationRepository.existsByCodePostId(id); + } + + @Transactional(readOnly = true) + public Notification findById(Long id) { + return notificationRepository.findById(id).orElseThrow( + () -> new NotFoundException(ErrorCode.NOT_FOUND_NOTIFICATION) + ); + } + + @Transactional(readOnly = true) + public Notification findByCodePostId(Long codePostId) { + return notificationRepository.findByCodePostId(codePostId).orElseThrow( + () -> new NotFoundException(ErrorCode.NOT_FOUND_NOTIFICATION) + ); + } + + @Transactional(readOnly = true) + public List findAll() { + return notificationRepository.findAll(); + } + + @Transactional(readOnly = true) + public List findAllByMemberId(Long memberId) { + return notificationRepository.findAllByMemberId(memberId); + } + + @Transactional(readOnly = true) + public List findAllByDate(Long memberId, int year, int month) { + return notificationRepository.findAllByDate(memberId,year,month); + } + + @Transactional(readOnly = true) + public List findAllNotificationIsNotCheckedByMemberId(Long memberId, LocalDate now) { + return notificationRepository.findAllNotificationIsNotCheckedByMemberId(memberId, now); + } + + @Transactional + public void update(Notification notification, CodePost codePost, NotificationStatus status) { + notification.update(codePost, status); + } + + @Transactional + public void readNotification(Notification notification) { + notification.readNotification(); + } + + @Transactional + public void delete(Notification notification) { + notificationRepository.delete(notification); + } + + @Transactional + public void deleteAll(List completedNotifications) { + notificationRepository.deleteAll(completedNotifications); + } + @Transactional(readOnly = true) public List getNotifications() { return notificationRepository.findAll(); diff --git a/src/main/java/org/example/autoreview/domain/notification/service/NotificationDtoService.java b/src/main/java/org/example/autoreview/domain/notification/service/NotificationDtoService.java deleted file mode 100644 index 28ed73f..0000000 --- a/src/main/java/org/example/autoreview/domain/notification/service/NotificationDtoService.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.example.autoreview.domain.notification.service; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.example.autoreview.domain.fcm.entity.FcmToken; -import org.example.autoreview.domain.fcm.service.FcmTokenService; -import org.example.autoreview.domain.member.service.MemberService; -import org.example.autoreview.domain.notification.dto.response.NotificationResponseDto; -import org.example.autoreview.domain.notification.entity.Notification; -import org.example.autoreview.domain.notification.enums.NotificationStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@RequiredArgsConstructor -@Service -public class NotificationDtoService { - - private final NotificationService notificationService; - private final MemberService memberService; - private final FcmTokenService fcmTokenService; - private final NotificationCommand notificationCommand; - - public void delete(String email, Long id) { - notificationService.delete(email, id); - } - - public List findAll() { - return notificationService.findAll(); - } - - public List findAllByMemberId(String email) { - Long memberId = memberService.findByEmail(email).getId(); - return notificationService.findAllByMemberId(memberId); - } - - public List findAllByDate(String email, int year, int month) { - Long memberId = memberService.findByEmail(email).getId(); - return notificationService.findAllByDate(memberId, year, month); - } - - public List findAllNotificationIsNotCheckedByMemberId(String email) { - Long memberId = memberService.findByEmail(email).getId(); - return notificationService.findAllNotificationIsNotCheckedByMemberId(memberId); - } - - @Transactional - public void stateUpdate(Long id) { - Notification notification = notificationService.findEntityById(id); - notification.readNotification(); - } - - public void sendNotification() { - LocalDate today = LocalDate.now(); - List notificationList = notificationCommand.getNotifications(); - - for (Notification notification : notificationList) { - if (notification.getStatus().equals(NotificationStatus.PENDING) && notification.getExecuteTime().isEqual(today)) { - notificationCommand.updateStatus(notification); - List fcmTokens = notification.getMember().getFcmTokens(); - fcmTokenService.pushNotification(fcmTokens, notification.getTitle(), notification.getContent()); - } - } - } - - @Transactional - public void deleteCompleteNotification() { - List completedNotifications = new ArrayList<>(); - List notificationList = notificationService.findEntityAll(); - - for (Notification notification : notificationList) { - if(notification.getStatus().equals(NotificationStatus.COMPLETE)) { - completedNotifications.add(notification); - } - } - notificationService.deleteAll(completedNotifications); - } -} diff --git a/src/main/java/org/example/autoreview/domain/notification/service/NotificationService.java b/src/main/java/org/example/autoreview/domain/notification/service/NotificationService.java index 048fe19..7322588 100644 --- a/src/main/java/org/example/autoreview/domain/notification/service/NotificationService.java +++ b/src/main/java/org/example/autoreview/domain/notification/service/NotificationService.java @@ -1,114 +1,137 @@ package org.example.autoreview.domain.notification.service; -import java.time.LocalDate; -import java.util.List; -import java.util.stream.Collectors; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.example.autoreview.domain.codepost.entity.CodePost; -import org.example.autoreview.domain.member.entity.Member; +import org.example.autoreview.domain.fcm.entity.FcmToken; +import org.example.autoreview.domain.fcm.service.FcmTokenCommand; +import org.example.autoreview.domain.member.service.MemberCommand; import org.example.autoreview.domain.notification.dto.response.NotificationResponseDto; import org.example.autoreview.domain.notification.entity.Notification; -import org.example.autoreview.domain.notification.entity.NotificationRepository; import org.example.autoreview.domain.notification.enums.NotificationStatus; -import org.example.autoreview.global.exception.errorcode.ErrorCode; -import org.example.autoreview.global.exception.sub_exceptions.BadRequestException; -import org.example.autoreview.global.exception.sub_exceptions.NotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; @Slf4j @RequiredArgsConstructor -@Transactional(readOnly = true) @Service public class NotificationService { - private final NotificationRepository notificationRepository; - - @Transactional - public void save(Member member, CodePost codePost) { - Notification notification = Notification.builder() - .title("ORI 복습 알림") - .content(codePost.getTitle()) - .status(NotificationStatus.PENDING) - .executeTime(codePost.getReviewDay()) - .member(member) - .codePostId(codePost.getId()) - .build(); - - notificationRepository.save(notification); - } - - public boolean existsById(Long id) { - return notificationRepository.existsById(id); - } - - public boolean existsByCodePostId(Long id) { - return notificationRepository.existsByCodePostId(id); - } - - public Notification findEntityById(Long id) { - return notificationRepository.findById(id).orElseThrow( - () -> new NotFoundException(ErrorCode.NOT_FOUND_NOTIFICATION) - ); - } - - public List findEntityAll() { - return notificationRepository.findAll(); - } + private final NotificationCommand notificationCommand; + private final MemberCommand memberCommand; + private final FcmTokenCommand fcmTokenCommand; + /** + * 알림 전체 조회하는 메서드이다. + */ public List findAll() { - return notificationRepository.findAll().stream() + return notificationCommand.findAll().stream() .map(NotificationResponseDto::new) .collect(Collectors.toList()); } - public List findAllByMemberId(Long memberId) { - return notificationRepository.findAllByMemberId(memberId).stream() + /** + * 사용자가 설정한 모든 알림을 조회하는 메서드이다. + */ + public List findAllByMemberId(String email) { + Long memberId = memberCommand.findByEmail(email).getId(); + return notificationCommand.findAllByMemberId(memberId).stream() .map(NotificationResponseDto::new) .collect(Collectors.toList()); } - public List findAllByDate(Long memberId, int year, int month) { - return notificationRepository.findAllByDate(memberId,year,month); + /** + * 특정 날짜에 사용자가 설정해놓은 알림 전체 조회하는 메서드이다. + */ + public List findAllByDate(String email, int year, int month) { + Long memberId = memberCommand.findByEmail(email).getId(); + return notificationCommand.findAllByDate(memberId,year,month); } - public List findAllNotificationIsNotCheckedByMemberId(Long memberId) { + /** + * 사용자가 읽지 않은 알림을 전체 조회하는 메서드이다. + */ + public List findAllNotificationIsNotCheckedByMemberId(String email) { LocalDate now = LocalDate.now(); - List notifications = notificationRepository.findAllNotificationIsNotCheckedByMemberId(memberId, now); - - return notifications.stream() + Long memberId = memberCommand.findByEmail(email).getId(); + return notificationCommand.findAllNotificationIsNotCheckedByMemberId(memberId, now).stream() .map(NotificationResponseDto::new) .collect(Collectors.toList()); } - @Transactional - public void update(String email, CodePost codePost, NotificationStatus status) { - Notification notification = notificationRepository.findByCodePostId(codePost.getId()).orElseThrow( - () -> new NotFoundException(ErrorCode.NOT_FOUND_NOTIFICATION) - ); - userValidator(email, notification); - notification.update(codePost, status); + /** + * 알림을 읽음 상태로 변경해주는 메서드이다. + */ + public void stateUpdate(Long id) { + Notification notification = notificationCommand.findById(id); + notificationCommand.readNotification(notification); } - @Transactional - public void delete(String email, Long id) { - Notification notification = notificationRepository.findByCodePostId(id).orElseThrow( - () -> new NotFoundException(ErrorCode.NOT_FOUND_NOTIFICATION) - ); - userValidator(email,notification); - notificationRepository.delete(notification); + /** + * FCM으로 전송할 알림을 요청하는 외부 API이다. + */ + public void sendNotification() { + LocalDate today = LocalDate.now(); + List notificationList = notificationCommand.getNotifications(); + + // + for (Notification notification : notificationList) { + if (notification.getStatus().equals(NotificationStatus.PENDING) && notification.getExecuteTime().isEqual(today)) { + notificationCommand.updateStatus(notification); + List fcmTokens = notification.getMember().getFcmTokens(); + pushNotification(fcmTokens, notification.getTitle(), notification.getContent()); + } + } } - @Transactional - public void deleteAll(List completedNotifications) { - notificationRepository.deleteAll(completedNotifications); + private void pushNotification(List fcmTokens, String title, String content) { + log.info("pushNotification 트랜잭션 존재 여부: {}", TransactionSynchronizationManager.isActualTransactionActive()); + + Map fcmTokenMap = new ConcurrentHashMap<>(); + for (FcmToken fcmToken : fcmTokens) { + CompletableFuture.runAsync(() -> { + try { + Message message = Message.builder() + .putData("title", title) + .putData("body", content) + .setToken(fcmToken.getToken()) + .build(); + + FirebaseMessaging.getInstance().send(message); + fcmTokenMap.put(fcmToken, LocalDate.now()); + + } catch (FirebaseMessagingException e) { + log.error("Failed to send message to device {}: {}", fcmToken.getId(), e.getMessage()); + } catch (Exception e) { + log.error("An unexpected error occurred: {}", e.getMessage()); + } + }); + } + fcmTokenCommand.fcmTokensUpdate(fcmTokenMap); } - private static void userValidator(String email, Notification notification) { - if (!notification.getMember().getEmail().equals(email)) { - throw new BadRequestException(ErrorCode.UNMATCHED_EMAIL); + @Transactional + public void deleteCompleteNotification() { + List completedNotifications = new ArrayList<>(); + List notificationList = notificationCommand.findAll(); + + for (Notification notification : notificationList) { + if(notification.getStatus().equals(NotificationStatus.COMPLETE)) { + completedNotifications.add(notification); + } } + notificationCommand.deleteAll(completedNotifications); } } diff --git a/src/main/java/org/example/autoreview/domain/review/service/ReviewCodePostService.java b/src/main/java/org/example/autoreview/domain/review/service/ReviewCodePostService.java index d9df9d7..808c24e 100644 --- a/src/main/java/org/example/autoreview/domain/review/service/ReviewCodePostService.java +++ b/src/main/java/org/example/autoreview/domain/review/service/ReviewCodePostService.java @@ -1,26 +1,27 @@ package org.example.autoreview.domain.review.service; -import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.autoreview.domain.codepost.entity.CodePost; -import org.example.autoreview.domain.codepost.service.CodePostService; +import org.example.autoreview.domain.codepost.service.CodePostCommand; import org.example.autoreview.domain.review.dto.request.ReviewDeleteRequestDto; import org.example.autoreview.domain.review.dto.request.ReviewSaveRequestDto; import org.example.autoreview.domain.review.dto.request.ReviewUpdateRequestDto; import org.example.autoreview.domain.review.dto.response.ReviewResponseDto; import org.springframework.stereotype.Service; +import java.util.List; + @Slf4j @RequiredArgsConstructor @Service public class ReviewCodePostService { private final ReviewService reviewService; - private final CodePostService codePostService; + private final CodePostCommand codePostCommand; public Long save(ReviewSaveRequestDto requestDto) { - CodePost codePost = codePostService.findEntityById(requestDto.codePostId()); + CodePost codePost = codePostCommand.findById(requestDto.codePostId()); return reviewService.save(requestDto, codePost).getId(); } @@ -29,7 +30,7 @@ public ReviewResponseDto findOne(Long reviewId) { } public List findAllByCodePostId(Long codePostId) { - CodePost codePost = codePostService.findEntityById(codePostId); + CodePost codePost = codePostCommand.findById(codePostId); return reviewService.findAllByCodePost(codePost); } diff --git a/src/main/java/org/example/autoreview/global/config/WebClientConfig.java b/src/main/java/org/example/autoreview/global/config/WebClientConfig.java new file mode 100644 index 0000000..67bede0 --- /dev/null +++ b/src/main/java/org/example/autoreview/global/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package org.example.autoreview.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient(WebClient.Builder builder) { + return builder.build(); + } +} diff --git a/src/main/java/org/example/autoreview/global/exception/errorcode/ErrorCode.java b/src/main/java/org/example/autoreview/global/exception/errorcode/ErrorCode.java index faa6ed6..876c558 100644 --- a/src/main/java/org/example/autoreview/global/exception/errorcode/ErrorCode.java +++ b/src/main/java/org/example/autoreview/global/exception/errorcode/ErrorCode.java @@ -12,6 +12,7 @@ public enum ErrorCode { INVALID_PARAMETER(400, HttpStatus.BAD_REQUEST, "잘못된 매개변수가 포함되었습니다."), NOT_FOUND_RESOURCE(404, HttpStatus.NOT_FOUND, "자원을 찾을 수 없습니다."), INTERNAL_SERVER_ERROR(500, HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 오류가 발생했습니다."), + DUPLICATE_ERROR(409, HttpStatus.CONFLICT, "중복 요청 오류가 발생했습니다."), // SECURITY UNAUTHORIZED_SECURITY(401, HttpStatus.UNAUTHORIZED, "자격 증명에 실패했습니다."), @@ -34,6 +35,7 @@ public enum ErrorCode { // POST NOT_FOUND_POST(404, HttpStatus.NOT_FOUND, "해당 포스트를 찾을 수 없습니다."), + NOT_FOUND_LANGUAGE(404, HttpStatus.NOT_FOUND, "해당 언어를 찾을 수 없습니다."), // NOTIFICATION NOT_FOUND_NOTIFICATION(404, HttpStatus.NOT_FOUND, "해당 알림을 찾을 수 없습니다."), @@ -48,7 +50,10 @@ public enum ErrorCode { NOT_FOUND_BOOKMARK(404, HttpStatus.NOT_FOUND, "해당 북마크를 찾을 수 없습니다."), // Comment - NOT_FOUND_COMMENT(404, HttpStatus.NOT_FOUND, "해당 댓글을 찾을 수 없습니다.") + NOT_FOUND_COMMENT(404, HttpStatus.NOT_FOUND, "해당 댓글을 찾을 수 없습니다."), + + // GitHub Token + NOT_FOUND_GITHUB_TOKEN(404, HttpStatus.NOT_FOUND, "해당 토큰을 찾을 수 없습니다.") ; diff --git a/src/main/java/org/example/autoreview/global/scheduler/NotificationScheduler.java b/src/main/java/org/example/autoreview/global/scheduler/NotificationScheduler.java index d22442c..aa2d2c9 100644 --- a/src/main/java/org/example/autoreview/global/scheduler/NotificationScheduler.java +++ b/src/main/java/org/example/autoreview/global/scheduler/NotificationScheduler.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.example.autoreview.domain.notification.service.NotificationDtoService; +import org.example.autoreview.domain.notification.service.NotificationService; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -11,13 +11,13 @@ @Component public class NotificationScheduler { - private final NotificationDtoService notificationMemberService; + private final NotificationService notificationService; // 매일 오전 8시에 호출 @Scheduled(cron = "0 0 8 * * ?") public void pushNotification(){ log.info("start push notification"); - notificationMemberService.sendNotification(); + notificationService.sendNotification(); log.info("finish push notification"); } @@ -26,7 +26,7 @@ public void pushNotification(){ @Scheduled(cron = "0 10 7 * * ?") public void deleteCompleteNotification(){ log.info("start delete notification"); - notificationMemberService.deleteCompleteNotification(); + notificationService.deleteCompleteNotification(); log.info("end delete notification"); } } diff --git a/src/main/resources/db/migration/V10.1__alter_fcm_token_add_unique_token.sql b/src/main/resources/db/migration/V10.1__alter_fcm_token_add_unique_token.sql new file mode 100644 index 0000000..b2ecf92 --- /dev/null +++ b/src/main/resources/db/migration/V10.1__alter_fcm_token_add_unique_token.sql @@ -0,0 +1,7 @@ +-- 1. token 컬럼 NOT NULL 제약 추가 (있다면 무시) +ALTER TABLE fcm_token + MODIFY COLUMN token VARCHAR(255) NOT NULL; + +-- 2. token 컬럼에 UNIQUE 제약 추가 +ALTER TABLE fcm_token + ADD CONSTRAINT uq_fcm_token UNIQUE (token); diff --git a/src/main/resources/db/migration/V5.1__add_is_public_column_to_code_post.sql b/src/main/resources/db/migration/V5.1__add_is_public_column_to_code_post.sql new file mode 100644 index 0000000..47eb43d --- /dev/null +++ b/src/main/resources/db/migration/V5.1__add_is_public_column_to_code_post.sql @@ -0,0 +1,2 @@ +ALTER TABLE code_post +ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1; diff --git a/src/main/resources/db/migration/V5.2__if_ispublic_column_is_null_then_update.sql b/src/main/resources/db/migration/V5.2__if_ispublic_column_is_null_then_update.sql new file mode 100644 index 0000000..bbf854f --- /dev/null +++ b/src/main/resources/db/migration/V5.2__if_ispublic_column_is_null_then_update.sql @@ -0,0 +1 @@ +UPDATE code_post SET is_public = 1 WHERE is_public IS NULL; \ No newline at end of file diff --git a/src/main/resources/db/migration/V6.1__create_code_post_bookmark_table.sql b/src/main/resources/db/migration/V6.1__create_code_post_bookmark_table.sql new file mode 100644 index 0000000..f6b0d4c --- /dev/null +++ b/src/main/resources/db/migration/V6.1__create_code_post_bookmark_table.sql @@ -0,0 +1,11 @@ +create table code_post_bookmark ( + id bigint not null auto_increment, + code_post_id bigint not null, + is_deleted bit not null default false, + member_id bigint, + create_date datetime(6) not null, + update_date datetime(6) not null, + primary key (id), + constraint fk_code_post_bookmark_member + foreign key (member_id) references member (id) +); diff --git a/src/main/resources/db/migration/V7.1__create_github_token_table.sql b/src/main/resources/db/migration/V7.1__create_github_token_table.sql new file mode 100644 index 0000000..6d6996e --- /dev/null +++ b/src/main/resources/db/migration/V7.1__create_github_token_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE `github_token` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `email` varchar(255) NOT NULL, + `github_token` varchar(255) DEFAULT NULL, + `create_date` datetime(6) DEFAULT NULL, + `update_date` datetime(6) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UK4b3exbfoct7fo6vl8upp3qgo3` (`email`) +) \ No newline at end of file diff --git a/src/main/resources/db/migration/V8.1__add_unique_constraint_on_bookmark.sql b/src/main/resources/db/migration/V8.1__add_unique_constraint_on_bookmark.sql new file mode 100644 index 0000000..4891b2b --- /dev/null +++ b/src/main/resources/db/migration/V8.1__add_unique_constraint_on_bookmark.sql @@ -0,0 +1,7 @@ +ALTER TABLE code_post_bookmark + ADD CONSTRAINT uq_codepost_member + UNIQUE (code_post_id, member_id); + +ALTER TABLE tilbookmark + ADD CONSTRAINT uq_tilpost_email + UNIQUE (tilpost_id, member_email); diff --git a/src/main/resources/db/migration/V9.1__replace_member_id_with_email.sql b/src/main/resources/db/migration/V9.1__replace_member_id_with_email.sql new file mode 100644 index 0000000..8ff4c8b --- /dev/null +++ b/src/main/resources/db/migration/V9.1__replace_member_id_with_email.sql @@ -0,0 +1,21 @@ +-- -- 1. email 컬럼 추가 +-- ALTER TABLE code_post_bookmark +-- ADD COLUMN email VARCHAR(255); +-- +-- -- 2. member_id → member_email 데이터 복사 +-- UPDATE code_post_bookmark cb +-- JOIN member m ON cb.member_id = m.id +-- SET cb.email = m.email; +-- +-- -- 3. member_email NOT NULL 설정 +-- ALTER TABLE code_post_bookmark +-- MODIFY COLUMN email VARCHAR(255) NOT NULL; +-- +-- -- 4. 기존 Unique 제약조건 삭제 +-- ALTER TABLE code_post_bookmark +-- DROP INDEX uq_codepost_member; +-- +-- -- 5. 새로운 Unique 제약조건 추가 +-- ALTER TABLE code_post_bookmark +-- ADD CONSTRAINT uq_email_codepost UNIQUE (email, code_post_id); +-- diff --git a/src/main/resources/db/migration/V9.2__delete_member_foreign_key.sql b/src/main/resources/db/migration/V9.2__delete_member_foreign_key.sql new file mode 100644 index 0000000..f7bc2dc --- /dev/null +++ b/src/main/resources/db/migration/V9.2__delete_member_foreign_key.sql @@ -0,0 +1,10 @@ +-- 1. 외래키 제약 조건 제거 +ALTER TABLE code_post_bookmark +DROP FOREIGN KEY fk_code_post_bookmark_member; + +-- 2. 인덱스 제거 (외래키에 대한 인덱스였다면 함께 제거) +DROP INDEX fk_code_post_bookmark_member ON code_post_bookmark; + +-- 3. 컬럼 제거 +ALTER TABLE code_post_bookmark +DROP COLUMN member_id; diff --git a/src/test/java/org/example/autoreview/AutoreviewApplicationTests.java b/src/test/java/org/example/autoreview/AutoreviewApplicationTests.java index efe71bc..fbf4f90 100644 --- a/src/test/java/org/example/autoreview/AutoreviewApplicationTests.java +++ b/src/test/java/org/example/autoreview/AutoreviewApplicationTests.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("${spring.profiles.active:test}") @SpringBootTest class AutoreviewApplicationTests { diff --git a/src/test/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkServiceIntegrationTest.java b/src/test/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkServiceIntegrationTest.java new file mode 100644 index 0000000..35ca73a --- /dev/null +++ b/src/test/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkServiceIntegrationTest.java @@ -0,0 +1,111 @@ +package org.example.autoreview.domain.bookmark.CodePostBookmark.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.data.domain.PageRequest.of; + +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.request.CodePostBookmarkSaveRequestDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response.CodePostBookmarkListResponseDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.entity.CodePostBookmarkRepository; +import org.example.autoreview.domain.codepost.entity.CodePost; +import org.example.autoreview.domain.codepost.entity.CodePostRepository; +import org.example.autoreview.domain.codepost.service.CodePostCommand; +import org.example.autoreview.domain.member.entity.Member; +import org.example.autoreview.domain.member.entity.MemberRepository; +import org.example.autoreview.domain.member.entity.Role; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("${spring.profiles.active:test}") +@SpringBootTest +class CodePostBookmarkServiceIntegrationTest { + + @Autowired + private CodePostBookmarkService codePostBookmarkService; + + @Autowired + private CodePostCommand codePostCommand; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CodePostRepository codePostRepository; + + @Autowired + private CodePostBookmarkRepository codePostBookmarkRepository; + + private CodePostBookmarkSaveRequestDto saveRequestDto; + private Member testMember; + private CodePost testCodePost; + + + @BeforeEach + void setUp() { + testMember = memberRepository.save(Member.builder() + .email("test@example.com") + .nickname("tester") + .role(Role.USER) + .build()); + + testCodePost = codePostCommand.save(CodePost.builder() + .writerId(testMember.getId()) + .isPublic(true) + .build()); + + saveRequestDto = new CodePostBookmarkSaveRequestDto(testCodePost.getId()); + } + + @AfterEach + void cleanUp() { + memberRepository.deleteAll(); + codePostRepository.deleteAll(); + codePostBookmarkRepository.deleteAll(); + } + + @Test + void save() { + // when + Long bookmarkId = codePostBookmarkService.saveOrUpdate(saveRequestDto, testMember.getEmail()); + + // then + CodePostBookmarkListResponseDto bookmarks = codePostBookmarkService.findAllByEmail(testMember.getEmail(), of(0, 10)); + + assertThat(bookmarkId).isNotNull(); + assertThat(bookmarks.dtoList().size()).isEqualTo(1); + assertThat(bookmarks.dtoList().get(0).getCodePostId()).isEqualTo(testCodePost.getId()); + } + + @Test + void update() { + // when + // bookmark 여부 true -> false 로 변경 + Long bookmarkId = codePostBookmarkService.saveOrUpdate(saveRequestDto, testMember.getEmail()); + boolean beforeState = codePostBookmarkRepository.findById(bookmarkId).get().isDeleted(); + + codePostBookmarkService.saveOrUpdate(saveRequestDto, testMember.getEmail()); + + // then + assertThat(beforeState).isFalse(); + assertThat(codePostBookmarkRepository.findById(bookmarkId).get().isDeleted()).isTrue(); + } + + @Test + void saveOrUpdate_then_findAllByMemberId() { + // given + CodePostBookmarkSaveRequestDto requestDto = new CodePostBookmarkSaveRequestDto(testCodePost.getId()); + + // when + Long bookmarkId = codePostBookmarkService.saveOrUpdate(requestDto, testMember.getEmail()); + + // then + CodePostBookmarkListResponseDto bookmarks = codePostBookmarkService.findAllByEmail(testMember.getEmail(), of(0, 10)); + + assertThat(bookmarks.dtoList().size()).isEqualTo(1); + assertThat(bookmarks.dtoList().get(0).getCodePostId()).isEqualTo(testCodePost.getId()); + assertThat(bookmarkId).isNotNull(); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkServiceUnitTest.java b/src/test/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkServiceUnitTest.java new file mode 100644 index 0000000..8815232 --- /dev/null +++ b/src/test/java/org/example/autoreview/domain/bookmark/CodePostBookmark/service/CodePostBookmarkServiceUnitTest.java @@ -0,0 +1,149 @@ +package org.example.autoreview.domain.bookmark.CodePostBookmark.service; + +import static java.util.List.of; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.request.CodePostBookmarkSaveRequestDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response.CodePostBookmarkListResponseDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.dto.response.CodePostBookmarkResponseDto; +import org.example.autoreview.domain.bookmark.CodePostBookmark.entity.CodePostBookmark; +import org.example.autoreview.domain.codepost.entity.CodePost; +import org.example.autoreview.domain.member.entity.Member; +import org.example.autoreview.domain.member.entity.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class CodePostBookmarkServiceUnitTest { + + @Mock + private CodePostBookmarkCommand codePostBookmarkCommand; + + @InjectMocks + private CodePostBookmarkService codePostBookmarkService; + + private Member mockMember; + + private CodePostBookmark mockCodePostBookmark; + + private String email; + + private Long codePostId; + + @BeforeEach + void setUp() { + Long memberId = 1L; + Long bookmarkId = 1L; + codePostId = 123L; + email = "test@example.com"; + + mockMember = Member.builder() + .nickname("tester") + .role(Role.USER) + .email(email) + .build(); + + mockCodePostBookmark = CodePostBookmark.builder() + .email(mockMember.getEmail()) + .codePostId(codePostId) + .isDeleted(false) + .build(); + + ReflectionTestUtils.setField(mockMember,"id", memberId); + ReflectionTestUtils.setField(mockCodePostBookmark,"id", bookmarkId); + } + + @Test + void saveOrUpdate_성공적으로_북마크를_저장한다() { + // given + CodePostBookmarkSaveRequestDto requestDto = new CodePostBookmarkSaveRequestDto(codePostId); + when(codePostBookmarkCommand.saveOrUpdate(requestDto, mockMember.getEmail())) + .thenReturn(Optional.ofNullable(mockCodePostBookmark)); + + // when + Long result = codePostBookmarkService.saveOrUpdate(requestDto, email); + + // then + assertThat(result).isEqualTo(mockCodePostBookmark.getId()); + verify(codePostBookmarkCommand).saveOrUpdate(requestDto, mockMember.getEmail()); + } + + @Test + void saveOrUpdate_UNIQUE_제약조건_위반시_업데이트로_전환한다() { + // given + CodePostBookmark updateBookmark = CodePostBookmark.builder() + .email(mockMember.getEmail()) + .codePostId(codePostId) + .isDeleted(!mockCodePostBookmark.isDeleted()) + .build(); + ReflectionTestUtils.setField(updateBookmark,"id",2L); + + CodePostBookmarkSaveRequestDto requestDto = new CodePostBookmarkSaveRequestDto(codePostId); + + when(codePostBookmarkCommand.saveOrUpdate(requestDto, mockMember.getEmail())).thenReturn( + Optional.of(updateBookmark)); + + // when + Long result = codePostBookmarkService.saveOrUpdate(requestDto, email); + + // then + assertThat(result).isEqualTo(2L); + verify(codePostBookmarkCommand).saveOrUpdate(requestDto, mockMember.getEmail()); + } + + @Test + void findAllByEmail_정상조회() { + // given + CodePostBookmark bookmark1 = CodePostBookmark.builder() + .codePostId(1L) + .email(mockMember.getEmail()) + .isDeleted(false) + .build(); + + CodePostBookmark bookmark2 = CodePostBookmark.builder() + .codePostId(2L) + .email(mockMember.getEmail()) + .isDeleted(false) + .build(); + + CodePost codePost = CodePost.builder() + .writerId(1L) + .isPublic(true) + .build(); + + CodePostBookmarkResponseDto dto1 = new CodePostBookmarkResponseDto(bookmark1, codePost, mockMember); + CodePostBookmarkResponseDto dto2 = new CodePostBookmarkResponseDto(bookmark2, codePost, mockMember); + + var dtoList = of(dto1, dto2); + Page page = new PageImpl<>(dtoList, PageRequest.of(0, 10), 2); + + when(codePostBookmarkCommand.findAllByEmail(email, PageRequest.of(0, 10))).thenReturn(page); + + // when + CodePostBookmarkListResponseDto result = codePostBookmarkService.findAllByEmail(email, PageRequest.of(0, 10)); + + // then + assertThat(result.dtoList().size()).isEqualTo(2); + assertThat(result.totalPage()).isEqualTo(1); + } + + @Test + void deleteExpiredSoftDeletedBookmarks_정상작동() { + // when + codePostBookmarkService.deleteExpiredSoftDeletedBookmarks(); + + // then + verify(codePostBookmarkCommand).deleteExpiredSoftDeletedBookmarks(); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/autoreview/domain/codepost/service/CodePostServiceIntegrationTest.java b/src/test/java/org/example/autoreview/domain/codepost/service/CodePostServiceIntegrationTest.java new file mode 100644 index 0000000..9ca531e --- /dev/null +++ b/src/test/java/org/example/autoreview/domain/codepost/service/CodePostServiceIntegrationTest.java @@ -0,0 +1,190 @@ +package org.example.autoreview.domain.codepost.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDate; +import org.example.autoreview.domain.bookmark.CodePostBookmark.service.CodePostBookmarkCommand; +import org.example.autoreview.domain.codepost.dto.request.CodePostSaveRequestDto; +import org.example.autoreview.domain.codepost.dto.request.CodePostUpdateRequestDto; +import org.example.autoreview.domain.codepost.dto.response.CodePostResponseDto; +import org.example.autoreview.domain.codepost.entity.CodePost; +import org.example.autoreview.domain.codepost.entity.CodePostRepository; +import org.example.autoreview.domain.codepost.entity.Language; +import org.example.autoreview.domain.member.entity.Member; +import org.example.autoreview.domain.member.entity.MemberRepository; +import org.example.autoreview.domain.member.entity.Role; +import org.example.autoreview.domain.notification.service.NotificationCommand; +import org.example.autoreview.global.exception.base_exceptions.CustomRuntimeException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("${spring.profiles.active:test}") +@SpringBootTest +public class CodePostServiceIntegrationTest { + + @Autowired + private CodePostCommand codePostCommand; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CodePostRepository codePostRepository; + + @Autowired + CodePostBookmarkCommand codePostBookmarkCommand; + + @Autowired + private NotificationCommand notificationCommand; + + @Autowired + private CodePostService codePostService; + + private CodePostSaveRequestDto saveRequestDto; + private Member testMember; + private CodePost testCodePost; + + @BeforeEach + void setUp() { + testMember = memberRepository.save(Member.builder() + .email("test@example.com") + .nickname("tester") + .role(Role.USER) + .build()); + + testCodePost = codePostCommand.save(CodePost.builder() + .writerId(testMember.getId()) + .language(Language.JAVA) + .isPublic(false) + .build()); + } + + @AfterEach + void cleanUp() { + memberRepository.deleteAll(); + codePostRepository.deleteAll(); + } + + @Test + void save_post_review_day_is_null() { + // given + saveRequestDto = new CodePostSaveRequestDto( + "test", + 1, + true, + null, + "test", + "java", + "import test" + ); + + // when + Long codePostId = codePostService.save(saveRequestDto, testMember.getEmail()); + + // then + CodePostResponseDto responseDto = codePostService.findById(codePostId, testMember.getEmail()); + + assertThat(responseDto.getReviewDay()).isNull(); + assertThat(responseDto.getWriterId()).isEqualTo(testMember.getId()); + } + + @Test + void save_post_review_day_is_not_null() { + // given + saveRequestDto = new CodePostSaveRequestDto( + "test", + 1, + true, + LocalDate.now().plusDays(1), + "test", + "java", + "import test" + ); + + // when + Long codePostId = codePostService.save(saveRequestDto, testMember.getEmail()); + + // then + CodePostResponseDto responseDto = codePostService.findById(codePostId, testMember.getEmail()); + + assertThat(notificationCommand.existsByCodePostId(codePostId)).isTrue(); + assertThat(responseDto.getReviewDay()).isEqualTo(LocalDate.now().plusDays(1)); + assertThat(responseDto.getWriterId()).isEqualTo(testMember.getId()); + + } + + @Test + void 비공개_포스트를_다른_사용자가_조회() { + // given + Member user = memberRepository.save(Member.builder() + .email("user@example.com") + .nickname("user") + .role(Role.USER) + .build()); + + // when + then + assertThrows(CustomRuntimeException.class, + () -> codePostService.findById(testCodePost.getId(), user.getEmail()) + ); + } + + @Test + void 비공개_포스트를_작성자가_조회() { + // when + CodePostResponseDto responseDto = codePostService.findById(testCodePost.getId(), testMember.getEmail()); + + // then + assertThat(responseDto).isNotNull(); + assertThat(responseDto.getWriterId()).isEqualTo(testMember.getId()); + } + + @Test + void update() { + // given + CodePostUpdateRequestDto requestDto = CodePostUpdateRequestDto.builder() + .id(testCodePost.getId()) + .language("cpp") + .build(); + + String beforeLanguage = testCodePost.getLanguage().getType(); + + // when + Long updatePostId = codePostService.update(requestDto, testMember.getEmail()); + CodePost updatePost = codePostRepository.findById(updatePostId).get(); + + // then + assertThat(updatePost.getLanguage().getType()).isNotEqualTo(beforeLanguage); + assertThat(updatePost.getLanguage()).isEqualTo(Language.CPP); + } + + @Test + void delete() { + // given + saveRequestDto = new CodePostSaveRequestDto( + "test", + 1, + true, + LocalDate.now().plusDays(1), + "test", + "java", + "import test" + ); + + // when + Long codePostId = codePostService.save(saveRequestDto, testMember.getEmail()); + + // when + int notificationCount = notificationCommand.findAll().size(); + + //then + codePostService.delete(codePostId, testMember.getEmail()); + + assertThat(notificationCount).isOne(); + assertThat(notificationCommand.findAll().size()).isZero(); + } +} diff --git a/src/test/java/org/example/autoreview/domain/codepost/service/CodePostServiceUnitTest.java b/src/test/java/org/example/autoreview/domain/codepost/service/CodePostServiceUnitTest.java new file mode 100644 index 0000000..643d692 --- /dev/null +++ b/src/test/java/org/example/autoreview/domain/codepost/service/CodePostServiceUnitTest.java @@ -0,0 +1,170 @@ +package org.example.autoreview.domain.codepost.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.Optional; +import org.example.autoreview.domain.bookmark.CodePostBookmark.entity.CodePostBookmark; +import org.example.autoreview.domain.bookmark.CodePostBookmark.service.CodePostBookmarkCommand; +import org.example.autoreview.domain.codepost.dto.request.CodePostSaveRequestDto; +import org.example.autoreview.domain.codepost.dto.response.CodePostResponseDto; +import org.example.autoreview.domain.codepost.entity.CodePost; +import org.example.autoreview.domain.codepost.entity.Language; +import org.example.autoreview.domain.member.entity.Member; +import org.example.autoreview.domain.member.service.MemberCommand; +import org.example.autoreview.domain.notification.entity.Notification; +import org.example.autoreview.domain.notification.service.NotificationCommand; +import org.example.autoreview.global.exception.base_exceptions.CustomRuntimeException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class CodePostServiceUnitTest { + + @Mock + private CodePostCommand codePostCommand; + + @Mock + private MemberCommand memberCommand; + + @Mock + private NotificationCommand notificationCommand; + + @Mock + private CodePostBookmarkCommand codePostBookmarkCommand; + + @InjectMocks + private CodePostService codePostService; + + private Long codePostId; + private Long memberId; + private String email; + private Member mockMember; + private CodePost mockCodePost; + + @BeforeEach + void setUp() { + codePostId = 123L; + memberId = 1L; + email = "test@example.com"; + mockMember = Member.builder() + .email(email) + .nickname("tester") + .build(); + + mockCodePost = CodePost.builder() + .writerId(memberId) + .language(Language.JAVA) + .isPublic(false) + .build(); + + ReflectionTestUtils.setField(mockMember, "id", memberId); + ReflectionTestUtils.setField(mockCodePost, "id", codePostId); + } + + @Test + void save_post_review_day_is_null() { + // given + CodePostSaveRequestDto requestDto = new CodePostSaveRequestDto( + "test", + 1, + true, + null, + "test", + "java", + "import test" + ); + when(memberCommand.findByEmail(email)).thenReturn(mockMember); + when(codePostCommand.save(any(CodePost.class))).thenReturn(mockCodePost); + + + // when + Long result = codePostService.save(requestDto, email); + + // then + assertThat(result).isEqualTo(codePostId); + verify(notificationCommand, never()).save(any(Notification.class)); + } + + @Test + void save_post_review_day_is_not_null() { + // given + LocalDate reviewDay = LocalDate.now().plusDays(2); + CodePostSaveRequestDto requestDto = new CodePostSaveRequestDto( + "test", + 1, + true, + reviewDay, + "test", + "java", + "import test" + ); + when(memberCommand.findByEmail(email)).thenReturn(mockMember); + when(codePostCommand.save(any(CodePost.class))).thenReturn(mockCodePost); + doNothing().when(notificationCommand).save(any(Notification.class)); + + + // when + Long result = codePostService.save(requestDto, email); + + // then + assertThat(result).isEqualTo(codePostId); + verify(notificationCommand).save(any(Notification.class)); + } + + @Test + void 비공개_포스트를_다른_사용자가_조회() { + //given + Long userId = 10L; + Member mockUser = Member.builder() + .email("user@example.com") + .nickname("user") + .build(); + + ReflectionTestUtils.setField(mockUser, "id", userId); + + when(memberCommand.findByEmail(email)).thenReturn(mockUser); + when(codePostCommand.findByIdIsPublic(mockCodePost.getId(), mockUser.getId())) + .thenThrow(CustomRuntimeException.class); + + //when + then + assertThrows(CustomRuntimeException.class, + () -> codePostService.findById(mockCodePost.getId(), email) + ); + } + + @Test + void 비공개_포스트를_작성자가_조회() { + //given + CodePostBookmark mockBookmark = CodePostBookmark.builder() + .email(mockMember.getEmail()) + .codePostId(codePostId) + .isDeleted(false) + .build(); + + ReflectionTestUtils.setField(mockBookmark, "id", 1L); + + when(memberCommand.findByEmail(email)).thenReturn(mockMember); + when(codePostCommand.findByIdIsPublic(codePostId, memberId)).thenReturn(mockCodePost); + when(memberCommand.findById(memberId)).thenReturn(mockMember); + when(codePostBookmarkCommand.findByCodePostBookmark(email,codePostId)).thenReturn(Optional.empty()); + + //when + CodePostResponseDto responseDto = codePostService.findById(codePostId, email); + + //then + assertThat(responseDto.getWriterEmail()).isEqualTo(mockMember.getEmail()); + assertThat(responseDto.getWriterNickName()).isEqualTo(mockMember.getNickname()); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/autoreview/domain/comment/codepost/service/CodePostCommentServiceIntegrationTest.java b/src/test/java/org/example/autoreview/domain/comment/codepost/service/CodePostCommentServiceIntegrationTest.java new file mode 100644 index 0000000..d063102 --- /dev/null +++ b/src/test/java/org/example/autoreview/domain/comment/codepost/service/CodePostCommentServiceIntegrationTest.java @@ -0,0 +1,28 @@ +package org.example.autoreview.domain.comment.codepost.service; + +import org.example.autoreview.domain.codepost.service.CodePostCommand; +import org.example.autoreview.domain.member.service.MemberCommand; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("${spring.profiles.active:test}") +@SpringBootTest +public class CodePostCommentServiceIntegrationTest { + + @Autowired + private CodePostCommentCommand codePostCommentCommand; + + @Autowired + private CodePostCommand codePostCommand; + + @Autowired + private MemberCommand memberCommand; + + @Autowired + private CodePostCommentService codePostCommentService; + + + + +} diff --git a/src/test/java/org/example/autoreview/domain/comment/codepost/service/CodePostCommentServiceUnitTest.java b/src/test/java/org/example/autoreview/domain/comment/codepost/service/CodePostCommentServiceUnitTest.java new file mode 100644 index 0000000..afd1cb2 --- /dev/null +++ b/src/test/java/org/example/autoreview/domain/comment/codepost/service/CodePostCommentServiceUnitTest.java @@ -0,0 +1,140 @@ +package org.example.autoreview.domain.comment.codepost.service; + +import org.example.autoreview.domain.codepost.entity.CodePost; +import org.example.autoreview.domain.codepost.entity.Language; +import org.example.autoreview.domain.codepost.service.CodePostCommand; +import org.example.autoreview.domain.comment.base.dto.request.CommentSaveRequestDto; +import org.example.autoreview.domain.comment.codepost.entity.CodePostComment; +import org.example.autoreview.domain.member.entity.Member; +import org.example.autoreview.domain.member.service.MemberCommand; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CodePostCommentServiceUnitTest { + + @Mock + private CodePostCommentCommand codePostCommentCommand; + + @Mock + private CodePostCommand codePostCommand; + + @Mock + private MemberCommand memberCommand; + + @InjectMocks + private CodePostCommentService codePostCommentService; + + private Long memberId; + private String email; + private Member mockMember; + + private Long codePostId; + private CodePost mockCodePost; + + private Long commentId; + private CodePostComment mockComment; + + @BeforeEach + void setUp() { + commentId = 1L; + codePostId = 123L; + memberId = 1L; + email = "test@example.com"; + + mockMember = Member.builder() + .email(email) + .nickname("tester") + .build(); + + mockCodePost = CodePost.builder() + .writerId(memberId) + .language(Language.JAVA) + .isPublic(false) + .build(); + + mockComment = CodePostComment.builder() + .codePost(mockCodePost) + .writerId(memberId) + .body("test") + .isPublic(false) + .build(); + + ReflectionTestUtils.setField(mockMember, "id", memberId); + ReflectionTestUtils.setField(mockCodePost, "id", codePostId); + ReflectionTestUtils.setField(mockComment, "id", 1L); + } + + @Test + void save_comment() { + // given + CommentSaveRequestDto requestDto = new CommentSaveRequestDto( + codePostId, + null, + false, + null, + null, + null + ); + + CodePostComment comment = CodePostComment.builder() + .codePost(mockCodePost) + .writerId(memberId) + .body("test") + .isPublic(false) + .build(); + ReflectionTestUtils.setField(comment, "id", 1L); + when(memberCommand.findByEmail(email)).thenReturn(mockMember); + when(codePostCommand.findById(codePostId)).thenReturn(mockCodePost); + when(codePostCommentCommand.save(any())).thenReturn(comment); + + // when + Long commentId = codePostCommentService.save(requestDto, email); + + // then + assertThat(commentId).isEqualTo(1L); + assertThat(mockCodePost).isEqualTo(comment.getCodePost()); + } + + @Test + void save_reply() { + // given + CommentSaveRequestDto requestDto = new CommentSaveRequestDto( + codePostId, + null, + false, + null, + null, + commentId + ); + + CodePostComment reply = CodePostComment.builder() + .codePost(mockCodePost) + .writerId(memberId) + .body("test") + .isPublic(false) + .parent(mockComment) + .build(); + + ReflectionTestUtils.setField(reply, "id", 2L); + when(memberCommand.findByEmail(email)).thenReturn(mockMember); + when(codePostCommand.findById(codePostId)).thenReturn(mockCodePost); + when(codePostCommentCommand.save(any())).thenReturn(reply); + + // when + Long replyId = codePostCommentService.save(requestDto, email); + + // then + assertThat(replyId).isEqualTo(2L); + assertThat(reply.getParent()).isEqualTo(mockComment); + } +} \ No newline at end of file