From 3b29f58561e7241397eba4f63f19f3d1d1bc3761 Mon Sep 17 00:00:00 2001 From: jiseon Date: Wed, 7 Aug 2024 20:53:07 +0900 Subject: [PATCH 01/12] ITDS-26 feat: custom exception handling --- .../common/exception/CustomException.java | 13 ++++++++++ .../itit/common/exception/ErrorCode.java | 15 +++++++++++ .../itit/common/exception/ErrorResponse.java | 11 ++++++++ .../exception/GlobalExceptionHandler.java | 25 +++++++++++++++++++ .../itit/common/jwt/filter/JwtAuthFilter.java | 4 ++- .../dissonance/itit/service/UserService.java | 6 +++-- 6 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/dissonance/itit/common/exception/CustomException.java create mode 100644 src/main/java/com/dissonance/itit/common/exception/ErrorCode.java create mode 100644 src/main/java/com/dissonance/itit/common/exception/ErrorResponse.java create mode 100644 src/main/java/com/dissonance/itit/common/exception/GlobalExceptionHandler.java diff --git a/src/main/java/com/dissonance/itit/common/exception/CustomException.java b/src/main/java/com/dissonance/itit/common/exception/CustomException.java new file mode 100644 index 0000000..6074e01 --- /dev/null +++ b/src/main/java/com/dissonance/itit/common/exception/CustomException.java @@ -0,0 +1,13 @@ +package com.dissonance.itit.common.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java new file mode 100644 index 0000000..d63975b --- /dev/null +++ b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java @@ -0,0 +1,15 @@ +package com.dissonance.itit.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + NON_EXISTENT_EMAIL(HttpStatus.NOT_FOUND, "해당 email의 사용자가 존재하지 않습니다."), + INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "존재하지 않는 provider입니다."); + + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/common/exception/ErrorResponse.java b/src/main/java/com/dissonance/itit/common/exception/ErrorResponse.java new file mode 100644 index 0000000..46d1173 --- /dev/null +++ b/src/main/java/com/dissonance/itit/common/exception/ErrorResponse.java @@ -0,0 +1,11 @@ +package com.dissonance.itit.common.exception; + +public record ErrorResponse( + String errorCode, + String message +) { + public ErrorResponse(String errorCode, String message) { + this.errorCode = errorCode; + this.message = message; + } +} diff --git a/src/main/java/com/dissonance/itit/common/exception/GlobalExceptionHandler.java b/src/main/java/com/dissonance/itit/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..cc948c0 --- /dev/null +++ b/src/main/java/com/dissonance/itit/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,25 @@ +package com.dissonance.itit.common.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(CustomException.class) + protected ResponseEntity handleCustomException(CustomException e) { + ErrorResponse response = new ErrorResponse(e.getErrorCode().name(), e.getMessage()); + log.error("CustomException : {}", e.getMessage()); + return new ResponseEntity<>(response, e.getErrorCode().getHttpStatus()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleAllException(final Exception e) { + ErrorResponse response = new ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.name(), e.getMessage()); + log.error("handleAllException {}", e.getMessage()); + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/com/dissonance/itit/common/jwt/filter/JwtAuthFilter.java b/src/main/java/com/dissonance/itit/common/jwt/filter/JwtAuthFilter.java index 7c776b7..0079aa0 100644 --- a/src/main/java/com/dissonance/itit/common/jwt/filter/JwtAuthFilter.java +++ b/src/main/java/com/dissonance/itit/common/jwt/filter/JwtAuthFilter.java @@ -1,5 +1,7 @@ package com.dissonance.itit.common.jwt.filter; +import com.dissonance.itit.common.exception.ErrorCode; +import com.dissonance.itit.common.exception.CustomException; import com.dissonance.itit.common.jwt.util.JwtUtil; import com.dissonance.itit.domain.entity.User; import com.dissonance.itit.repository.UserRepository; @@ -44,7 +46,7 @@ protected void doFilterInternal(HttpServletRequest request, if (jwtUtil.verifyToken(accessToken)) { // AccessToken의 payload에 있는 email로 user를 조회한다. User findUser = userRepository.findByEmail(jwtUtil.getUid(accessToken)) - .orElseThrow(() -> new IllegalArgumentException("해당 email의 사용자가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_EMAIL)); // SecurityContext에 인증 객체를 등록한다. Authentication auth = getAuthentication(findUser); diff --git a/src/main/java/com/dissonance/itit/service/UserService.java b/src/main/java/com/dissonance/itit/service/UserService.java index 006e18a..ff47c43 100644 --- a/src/main/java/com/dissonance/itit/service/UserService.java +++ b/src/main/java/com/dissonance/itit/service/UserService.java @@ -1,5 +1,7 @@ package com.dissonance.itit.service; +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; import com.dissonance.itit.common.jwt.util.JwtUtil; import com.dissonance.itit.domain.enums.Role; import com.dissonance.itit.domain.entity.User; @@ -28,7 +30,7 @@ public GeneratedToken login(String provider, String token) { userInformation = oauthService.requestUserInformation(token); } else { log.info("존재하지 않는 provider: " + provider); - throw new IllegalArgumentException("존재하지 않는 provider: " + provider); + throw new CustomException(ErrorCode.INVALID_PROVIDER); } User user; @@ -59,6 +61,6 @@ public GeneratedToken login(String provider, String token) { private User findByEmail(String email) { return userRepository.findByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("해당 email의 사용자가 존재하지 않습니다.")); + .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_EMAIL)); } } \ No newline at end of file From ca635e9dbc9c3eb1fa3c9206e9a455284b5e1f5e Mon Sep 17 00:00:00 2001 From: jiseon Date: Wed, 7 Aug 2024 20:53:30 +0900 Subject: [PATCH 02/12] =?UTF-8?q?ITDS-26=20docs:=20swagger=20annotation=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dissonance/itit/controller/FeaturedPostContorller.java | 2 ++ .../java/com/dissonance/itit/controller/OauthController.java | 2 ++ .../com/dissonance/itit/dto/response/FeaturedPostRes.java | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/src/main/java/com/dissonance/itit/controller/FeaturedPostContorller.java b/src/main/java/com/dissonance/itit/controller/FeaturedPostContorller.java index f2e5b8b..4c0246f 100644 --- a/src/main/java/com/dissonance/itit/controller/FeaturedPostContorller.java +++ b/src/main/java/com/dissonance/itit/controller/FeaturedPostContorller.java @@ -2,6 +2,7 @@ import com.dissonance.itit.dto.response.FeaturedPostRes; import com.dissonance.itit.service.FeaturedPostService; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -17,6 +18,7 @@ public class FeaturedPostContorller { private final FeaturedPostService featuredPostService; @GetMapping + @Operation(summary = "추천 게시글 조회", description = "운영진 추천 게시글 5개를 조회합니다.") public ResponseEntity> getFeaturedPosts() { List featuredPostRes = featuredPostService.getFeaturedPost(); return ResponseEntity.ok(featuredPostRes); diff --git a/src/main/java/com/dissonance/itit/controller/OauthController.java b/src/main/java/com/dissonance/itit/controller/OauthController.java index d3fc941..eea848f 100644 --- a/src/main/java/com/dissonance/itit/controller/OauthController.java +++ b/src/main/java/com/dissonance/itit/controller/OauthController.java @@ -3,6 +3,7 @@ import com.dissonance.itit.dto.request.OauthTokenReq; import com.dissonance.itit.dto.response.GeneratedToken; import com.dissonance.itit.service.UserService; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -16,6 +17,7 @@ public class OauthController { // TODO: 커스텀 어노테이션으로 로그인 유저 정보 추출 @PostMapping("/{provider}") + @Operation(summary = "소셜 로그인", description = "소셜 로그인 (provider - kakao, apple)") public ResponseEntity getUserInfos(@PathVariable String provider, @Valid @RequestBody OauthTokenReq oauthTokenReq) { GeneratedToken token = userService.login(provider, oauthTokenReq.accessToken()); diff --git a/src/main/java/com/dissonance/itit/dto/response/FeaturedPostRes.java b/src/main/java/com/dissonance/itit/dto/response/FeaturedPostRes.java index 92f1be3..27d3358 100644 --- a/src/main/java/com/dissonance/itit/dto/response/FeaturedPostRes.java +++ b/src/main/java/com/dissonance/itit/dto/response/FeaturedPostRes.java @@ -1,6 +1,7 @@ package com.dissonance.itit.dto.response; import com.dissonance.itit.domain.entity.FeaturedPost; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -9,8 +10,11 @@ @Getter @Builder public class FeaturedPostRes { + @Schema(description = "추천 게시글 id", example = "1") private final Integer featuredPostId; + @Schema(description = "배너 이미지 url", example = "https://naver.com") private final String bannerImageUrl; + @Schema(description = "게시글 id", example = "1") private final Long infoPostId; public static List of(List featuredPosts) { From cb981d2be008d3548fe8a309798cdc6df962e08a Mon Sep 17 00:00:00 2001 From: jiseon Date: Tue, 13 Aug 2024 21:37:41 +0900 Subject: [PATCH 03/12] =?UTF-8?q?ITDS-30=20feat:=20=EB=AA=A8=EC=A7=91=20?= =?UTF-8?q?=EC=A7=81=EA=B5=B0=20erd=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/domain/entity/Position.java | 9 ++++++ .../itit/domain/entity/PositionInfoPost.java | 32 ------------------- 2 files changed, 9 insertions(+), 32 deletions(-) delete mode 100644 src/main/java/com/dissonance/itit/domain/entity/PositionInfoPost.java diff --git a/src/main/java/com/dissonance/itit/domain/entity/Position.java b/src/main/java/com/dissonance/itit/domain/entity/Position.java index a8d6f22..ae73304 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/Position.java +++ b/src/main/java/com/dissonance/itit/domain/entity/Position.java @@ -17,8 +17,17 @@ public class Position { @Column(name = "id") private Integer id; + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "info_post_id") + private InfoPost infoPost; + @Size(max = 50) @NotNull @Column(name = "name") private String name; + + @NotNull + @Column(name = "recruiting_count") + private Integer recruitingCount; } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/domain/entity/PositionInfoPost.java b/src/main/java/com/dissonance/itit/domain/entity/PositionInfoPost.java deleted file mode 100644 index 64b36d4..0000000 --- a/src/main/java/com/dissonance/itit/domain/entity/PositionInfoPost.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.dissonance.itit.domain.entity; - -import jakarta.persistence.*; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.*; - -@Getter -@Builder -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -@Table(name = "position_info_post") -public class PositionInfoPost { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) - private Long id; - - @NotNull - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "info_post_id") - private InfoPost infoPost; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "position_id") - private Position position; - - @Size(max = 50) - @Column(name = "custom_name", length = 50) - private String customName; -} \ No newline at end of file From 258edcc8a0ca137d5023b98085eb250c6bb63a90 Mon Sep 17 00:00:00 2001 From: jiseon Date: Tue, 13 Aug 2024 21:45:12 +0900 Subject: [PATCH 04/12] =?UTF-8?q?ITDS-30=20feat:=20s3=20bucket=EC=97=90=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../com/dissonance/itit/config/S3Config.java | 31 +++++ .../dissonance/itit/domain/entity/Image.java | 4 +- .../itit/domain/enums/Directory.java | 12 ++ .../itit/repository/ImageRepository.java | 7 ++ .../dissonance/itit/service/ImageService.java | 117 ++++++++++++++++++ src/main/resources/application.yml | 15 +++ 7 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dissonance/itit/config/S3Config.java create mode 100644 src/main/java/com/dissonance/itit/domain/enums/Directory.java create mode 100644 src/main/java/com/dissonance/itit/repository/ImageRepository.java create mode 100644 src/main/java/com/dissonance/itit/service/ImageService.java diff --git a/build.gradle b/build.gradle index 3619f49..4374bdb 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,8 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } def QDomains = [] diff --git a/src/main/java/com/dissonance/itit/config/S3Config.java b/src/main/java/com/dissonance/itit/config/S3Config.java new file mode 100644 index 0000000..9c1b892 --- /dev/null +++ b/src/main/java/com/dissonance/itit/config/S3Config.java @@ -0,0 +1,31 @@ +package com.dissonance.itit.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey); + + return (AmazonS3Client) AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCreds)) + .build(); + } +} diff --git a/src/main/java/com/dissonance/itit/domain/entity/Image.java b/src/main/java/com/dissonance/itit/domain/entity/Image.java index c7dfcd1..2f0e556 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/Image.java +++ b/src/main/java/com/dissonance/itit/domain/entity/Image.java @@ -1,5 +1,6 @@ package com.dissonance.itit.domain.entity; +import com.dissonance.itit.domain.enums.Directory; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -27,8 +28,9 @@ public class Image { @Column(name = "convert_image_name") private String convertImageName; + @Enumerated(EnumType.STRING) @Size(max = 20) @NotNull @Column(name = "directory") - private String directory; + private Directory directory; } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/domain/enums/Directory.java b/src/main/java/com/dissonance/itit/domain/enums/Directory.java new file mode 100644 index 0000000..e408e3d --- /dev/null +++ b/src/main/java/com/dissonance/itit/domain/enums/Directory.java @@ -0,0 +1,12 @@ +package com.dissonance.itit.domain.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Directory { + INFORMATION("info-posts"), RECRUITMENT("recruit-posts"); + + private final String name; +} diff --git a/src/main/java/com/dissonance/itit/repository/ImageRepository.java b/src/main/java/com/dissonance/itit/repository/ImageRepository.java new file mode 100644 index 0000000..5310e88 --- /dev/null +++ b/src/main/java/com/dissonance/itit/repository/ImageRepository.java @@ -0,0 +1,7 @@ +package com.dissonance.itit.repository; + +import com.dissonance.itit.domain.entity.Image; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/dissonance/itit/service/ImageService.java b/src/main/java/com/dissonance/itit/service/ImageService.java new file mode 100644 index 0000000..a961e8b --- /dev/null +++ b/src/main/java/com/dissonance/itit/service/ImageService.java @@ -0,0 +1,117 @@ +package com.dissonance.itit.service; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.domain.entity.Image; +import com.dissonance.itit.domain.enums.Directory; +import com.dissonance.itit.repository.ImageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +import static com.dissonance.itit.common.exception.ErrorCode.*; + +@RequiredArgsConstructor +@Service +public class ImageService { + private final AmazonS3Client amazonS3Client; + + private final ImageRepository imageRepository; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + /** + * 이미지를 업로드하고 관련 데이터베이스 레코드를 생성합니다. + * + * @param multipartFile 업로드할 이미지 파일 + * @param directory 이미지가 저장될 디렉토리 + * @return 업로드된 이미지 정보 객체 + */ + @Transactional + public Image upload(Directory directory, MultipartFile multipartFile) { + validateImage(multipartFile.getContentType()); + + String fileName = createFileName(multipartFile.getOriginalFilename(), directory.getName()); + + ObjectMetadata objectMetadata = new ObjectMetadata(); + + objectMetadata.setContentLength(multipartFile.getSize()); + + objectMetadata.setContentType(multipartFile.getContentType()); + + try (InputStream inputStream = multipartFile.getInputStream()) { + amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + + String path = amazonS3Client.getUrl(bucket, fileName).toString(); + + Image image = Image.builder() + .imageUrl(path) // TODO: CloudFront을 이용해 CDN 구축 + .directory(directory) + .convertImageName(fileName.substring(fileName.lastIndexOf("/") + 1)) + .build(); + + imageRepository.save(image); + + return image; + } catch (IOException e) { + throw new CustomException(IO_EXCEPTION); + } + } + + /** + * 주어진 콘텐츠 타입이 이미지 파일인지 검증합니다. + * + * @param contentType 콘텐츠 타입 + */ + private void validateImage(String contentType) { + if (contentType == null || !contentType.startsWith("image/")) { + throw new CustomException(INVALID_FILE_TYPE); + } + } + + /** + * S3 버킷에서 이미지를 삭제하고 관련 데이터베이스 레코드를 제거합니다. + * + * @param image 삭제할 이미지 정보 + */ + @Transactional + public void delete(Image image) { + amazonS3Client.deleteObject(bucket, image.getDirectory().getName() + "/" + image.getConvertImageName()); + + imageRepository.deleteById(image.getId()); + } + + /** + * S3 버킷에 저장될 파일 이름을 생성합니다. 파일 이름 중복을 방지하기 위해 UUID를 사용합니다. + * + * @param fileName 원본 파일 이름 + * @param dirName 이미지가 저장될 디렉토리 이름 + * @return 생성된 파일 이름 + */ + private String createFileName(String fileName, String dirName) { + return dirName + "/" + UUID.randomUUID() + "_" + fileName; + } + + /** + * 주어진 이미지 ID를 사용하여 이미지를 조회합니다. + * + * @param imageId 이미지 ID + * @return 조회된 이미지 정보 + * @throws CustomException 이미지를 찾을 수 없는 경우 발생 + */ + @Transactional(readOnly = true) + public Image findById(Long imageId) { + return imageRepository.findById(imageId).orElseThrow(() -> new CustomException(IMAGE_NOT_FOUND)); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b3d9129..0e2f982 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,6 +26,10 @@ spring: defer-datasource-initialization: true profiles: include: oauth + servlet: + multipart: + max-request-size: 10MB + max-file-size: 10MB jwt: token: @@ -42,3 +46,14 @@ logging: bind: trace org.springframework.web.reactive.function.client.ExchangeFunctions: TRACE +cloud: + aws: + s3: + bucket: itit-bucket + region: + static: ap-northeast-2 + stack: + auto: false + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} \ No newline at end of file From b2bdefe940531d7f7a07762620130136a0e061f8 Mon Sep 17 00:00:00 2001 From: jiseon Date: Sun, 18 Aug 2024 23:12:01 +0900 Subject: [PATCH 05/12] =?UTF-8?q?ITDS-30=20feat:=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B4=80=EB=A0=A8=20entity=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20repository=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dissonance/itit/domain/entity/Image.java | 1 - .../dissonance/itit/domain/entity/InfoPost.java | 16 +++++++--------- .../{Position.java => RecruitmentPosition.java} | 4 ++-- .../itit/repository/CategoryRepository.java | 7 +++++++ .../itit/repository/InfoPostRepository.java | 7 +++++++ .../RecruitmentPositionRepository.java | 7 +++++++ 6 files changed, 30 insertions(+), 12 deletions(-) rename src/main/java/com/dissonance/itit/domain/entity/{Position.java => RecruitmentPosition.java} (90%) create mode 100644 src/main/java/com/dissonance/itit/repository/CategoryRepository.java create mode 100644 src/main/java/com/dissonance/itit/repository/InfoPostRepository.java create mode 100644 src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepository.java diff --git a/src/main/java/com/dissonance/itit/domain/entity/Image.java b/src/main/java/com/dissonance/itit/domain/entity/Image.java index 2f0e556..1a59d9a 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/Image.java +++ b/src/main/java/com/dissonance/itit/domain/entity/Image.java @@ -29,7 +29,6 @@ public class Image { private String convertImageName; @Enumerated(EnumType.STRING) - @Size(max = 20) @NotNull @Column(name = "directory") private Directory directory; diff --git a/src/main/java/com/dissonance/itit/domain/entity/InfoPost.java b/src/main/java/com/dissonance/itit/domain/entity/InfoPost.java index 9ebb952..56d5d9a 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/InfoPost.java +++ b/src/main/java/com/dissonance/itit/domain/entity/InfoPost.java @@ -5,7 +5,7 @@ import jakarta.validation.constraints.Size; import lombok.*; -import java.time.LocalDateTime; +import java.time.LocalDate; @Getter @Builder @@ -34,19 +34,19 @@ public class InfoPost extends BaseTime { @NotNull @Column(name = "recruitment_start_date") - private LocalDateTime recruitmentStartDate; + private LocalDate recruitmentStartDate; @NotNull @Column(name = "recruitment_end_date") - private LocalDateTime recruitmentEndDate; + private LocalDate recruitmentEndDate; @NotNull @Column(name = "activity_start_date") - private LocalDateTime activityStartDate; + private LocalDate activityStartDate; @NotNull @Column(name = "activity_end_date") - private LocalDateTime activityEndDate; + private LocalDate activityEndDate; @Size(max = 500) @NotNull @@ -58,13 +58,11 @@ public class InfoPost extends BaseTime { @Column(name = "organization") private String organization; - @NotNull @Column(name = "reported") - private Boolean reported = false; + private Boolean reported; - @NotNull @Column(name = "recruitment_closed") - private Boolean recruitmentClosed = false; + private Boolean recruitmentClosed; @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "image_id") diff --git a/src/main/java/com/dissonance/itit/domain/entity/Position.java b/src/main/java/com/dissonance/itit/domain/entity/RecruitmentPosition.java similarity index 90% rename from src/main/java/com/dissonance/itit/domain/entity/Position.java rename to src/main/java/com/dissonance/itit/domain/entity/RecruitmentPosition.java index ae73304..3f0c9fb 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/Position.java +++ b/src/main/java/com/dissonance/itit/domain/entity/RecruitmentPosition.java @@ -10,8 +10,8 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -@Table(name = "position") -public class Position { +@Table(name = "recruitment_position") +public class RecruitmentPosition { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") diff --git a/src/main/java/com/dissonance/itit/repository/CategoryRepository.java b/src/main/java/com/dissonance/itit/repository/CategoryRepository.java new file mode 100644 index 0000000..ed8e5c5 --- /dev/null +++ b/src/main/java/com/dissonance/itit/repository/CategoryRepository.java @@ -0,0 +1,7 @@ +package com.dissonance.itit.repository; + +import com.dissonance.itit.domain.entity.Category; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/dissonance/itit/repository/InfoPostRepository.java b/src/main/java/com/dissonance/itit/repository/InfoPostRepository.java new file mode 100644 index 0000000..5ec64d8 --- /dev/null +++ b/src/main/java/com/dissonance/itit/repository/InfoPostRepository.java @@ -0,0 +1,7 @@ +package com.dissonance.itit.repository; + +import com.dissonance.itit.domain.entity.InfoPost; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InfoPostRepository extends JpaRepository { +} diff --git a/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepository.java b/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepository.java new file mode 100644 index 0000000..2875709 --- /dev/null +++ b/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepository.java @@ -0,0 +1,7 @@ +package com.dissonance.itit.repository; + +import com.dissonance.itit.domain.entity.RecruitmentPosition; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecruitmentPositionRepository extends JpaRepository { +} From 1133181c39babcf84bf70c9abe81c5317fd10318 Mon Sep 17 00:00:00 2001 From: jiseon Date: Sun, 18 Aug 2024 23:14:38 +0900 Subject: [PATCH 06/12] =?UTF-8?q?ITDS-30=20feat:=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/common/exception/ErrorCode.java | 14 +++- .../dissonance/itit/common/util/DateUtil.java | 19 +++++ .../itit/controller/InfoPostController.java | 34 +++++++++ .../itit/dto/request/InfoPostReq.java | 72 +++++++++++++++++++ .../itit/dto/response/InfoPostCreateRes.java | 15 ++++ .../itit/service/CategoryService.java | 21 ++++++ .../itit/service/InfoPostService.java | 36 ++++++++++ .../service/RecruitmentPositionService.java | 29 ++++++++ .../dissonance/itit/service/UserService.java | 6 ++ 9 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dissonance/itit/common/util/DateUtil.java create mode 100644 src/main/java/com/dissonance/itit/controller/InfoPostController.java create mode 100644 src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java create mode 100644 src/main/java/com/dissonance/itit/dto/response/InfoPostCreateRes.java create mode 100644 src/main/java/com/dissonance/itit/service/CategoryService.java create mode 100644 src/main/java/com/dissonance/itit/service/InfoPostService.java create mode 100644 src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java diff --git a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java index d63975b..99d9d75 100644 --- a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java +++ b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java @@ -7,8 +7,20 @@ @Getter @AllArgsConstructor public enum ErrorCode { + // 400 + INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "존재하지 않는 Provider입니다."), + INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "파일 형식은 이미지만 가능합니다."), + INVALID_FILE_SIZE(HttpStatus.BAD_REQUEST, "파일 용량은 10MB를 넘을 수 없습니다."), + INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "날짜 변환에 실패했습니다."), + + // 404 + NON_EXISTENT_USER_ID(HttpStatus.NOT_FOUND, "해당 id의 사용자가 존재하지 않습니다."), NON_EXISTENT_EMAIL(HttpStatus.NOT_FOUND, "해당 email의 사용자가 존재하지 않습니다."), - INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "존재하지 않는 provider입니다."); + IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 id의 이미지가 존재하지 않습니다."), + NON_EXISTENT_CATEGORY_ID(HttpStatus.NOT_FOUND, "해당 id의 카테고리가 존재하지 않습니다."), + + // 500 + IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일 입출력 에러"); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/dissonance/itit/common/util/DateUtil.java b/src/main/java/com/dissonance/itit/common/util/DateUtil.java new file mode 100644 index 0000000..0f3ee0d --- /dev/null +++ b/src/main/java/com/dissonance/itit/common/util/DateUtil.java @@ -0,0 +1,19 @@ +package com.dissonance.itit.common.util; + +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +public class DateUtil { + public static LocalDate stringToDate(String dateString) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 M월 d일"); + try { + return LocalDate.parse(dateString, formatter); + } catch (DateTimeParseException e) { + throw new CustomException(ErrorCode.INVALID_DATE_FORMAT); + } + } +} diff --git a/src/main/java/com/dissonance/itit/controller/InfoPostController.java b/src/main/java/com/dissonance/itit/controller/InfoPostController.java new file mode 100644 index 0000000..93dca82 --- /dev/null +++ b/src/main/java/com/dissonance/itit/controller/InfoPostController.java @@ -0,0 +1,34 @@ +package com.dissonance.itit.controller; + +import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.dto.request.InfoPostReq; +import com.dissonance.itit.dto.response.InfoPostCreateRes; +import com.dissonance.itit.service.InfoPostService; +import com.dissonance.itit.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/info-posts") +public class InfoPostController { + private final InfoPostService infoPostService; + private final UserService userService; + + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation(summary = "공고 게시글 등록", description = "공고 게시글을 등록합니다.") + public ResponseEntity createInfoPost(@RequestPart MultipartFile imgFile, + @Valid @RequestPart InfoPostReq infoPostReq) { + User loginUser = userService.findById(1L); // TODO: 로그인 유저 정보 적용 예정 + InfoPostCreateRes infoPostCreateRes = infoPostService.createInfoPost(imgFile, infoPostReq, loginUser); + return ResponseEntity.ok(infoPostCreateRes); + } +} diff --git a/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java b/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java new file mode 100644 index 0000000..acbf03a --- /dev/null +++ b/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java @@ -0,0 +1,72 @@ +package com.dissonance.itit.dto.request; + +import com.dissonance.itit.domain.entity.Category; +import com.dissonance.itit.domain.entity.Image; +import com.dissonance.itit.domain.entity.InfoPost; +import com.dissonance.itit.domain.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +import java.util.List; + +import static com.dissonance.itit.common.util.DateUtil.stringToDate; + +public record InfoPostReq ( + @NotBlank(message = "제목은 필수 입력입니다.") + @Schema(description = "제목", example = "공모전1") + String title, + @NotNull(message = "카테고리 id는 필수 입력입니다.") + @Schema(description = "공고 카테고리 id", example = "3") + Integer categoryId, + @Schema(description = "모집 기관 or 단체", example = "DDD") + String organization, + @NotBlank(message = "모집 시작일은 필수 입력입니다.") + @Schema(description = "모집 시작 일자", example = "2024년 8월 10일") + String recruitmentStartDate, + @NotBlank(message = "모집 마감일은 필수 입력입니다.") + @Schema(description = "모집 종료 일자", example = "2024년 8월 18일") + String recruitmentEndDate, + List positionInfos, + @NotBlank(message = "활동 시작일은 필수 입력입니다.") + @Schema(description = "활동 시작 일자", example = "2024년 10월 1일") + String activityStartDate, + @NotBlank(message = "활동 종료일은 필수 입력입니다.") + @Schema(description = "활동 종료 일자", example = "2024년 12월 31일") + String activityEndDate, + @Schema(description = "활동 내용",example = "여러분의 창의력과 디자인 역량을 발휘해볼 특별한 기회를 놓치지 마세요") + String content, + @NotBlank(message = "공고 url은 필수 입력입니다.") + @Schema(description = "공고 url", example = "https://www.google.com/") + String detailUrl +) { + @Getter + public static class PositionInfo { + @NotBlank(message = "모집 직군명은 필수 입력입니다.") + @Schema(description = "모집 직군명", example = "개발자") + String positionName; + @NotNull(message = "모집 인원수는 필수 입력입니다.") + @Schema(description = "모집 인원수", example = "3") + Integer recruitingCount; + } + + public InfoPost toEntity(Image image, User author, Category category) { + return InfoPost.builder() + .image(image) + .author(author) + .category(category) + .title(title()) + .organization(organization()) + .content(content()) + .viewCount(0) + .recruitmentStartDate(stringToDate(recruitmentStartDate())) + .recruitmentEndDate(stringToDate(recruitmentEndDate())) + .activityStartDate(stringToDate(activityStartDate())) + .activityEndDate(stringToDate(activityEndDate())) + .detailUrl(detailUrl()) + .reported(false) + .recruitmentClosed(false) + .build(); + } +} diff --git a/src/main/java/com/dissonance/itit/dto/response/InfoPostCreateRes.java b/src/main/java/com/dissonance/itit/dto/response/InfoPostCreateRes.java new file mode 100644 index 0000000..8703317 --- /dev/null +++ b/src/main/java/com/dissonance/itit/dto/response/InfoPostCreateRes.java @@ -0,0 +1,15 @@ +package com.dissonance.itit.dto.response; + +import com.dissonance.itit.domain.entity.InfoPost; +import io.swagger.v3.oas.annotations.media.Schema; + +public record InfoPostCreateRes( + @Schema(name = "게시글 ID", description = "게시글 ID", example = "1") + Long id, + @Schema(name = "게시글 제목", description = "게시글 제목", example = "게시글 제목") + String title +) { + public static InfoPostCreateRes of(InfoPost infoPost) { + return new InfoPostCreateRes(infoPost.getId(), infoPost.getTitle()); + } +} diff --git a/src/main/java/com/dissonance/itit/service/CategoryService.java b/src/main/java/com/dissonance/itit/service/CategoryService.java new file mode 100644 index 0000000..dcc77ed --- /dev/null +++ b/src/main/java/com/dissonance/itit/service/CategoryService.java @@ -0,0 +1,21 @@ +package com.dissonance.itit.service; + +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; +import com.dissonance.itit.domain.entity.Category; +import com.dissonance.itit.repository.CategoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CategoryService { + private final CategoryRepository categoryRepository; + + @Transactional(readOnly = true) + public Category findById(Integer id) { + return categoryRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_CATEGORY_ID)); + } +} diff --git a/src/main/java/com/dissonance/itit/service/InfoPostService.java b/src/main/java/com/dissonance/itit/service/InfoPostService.java new file mode 100644 index 0000000..a2d7366 --- /dev/null +++ b/src/main/java/com/dissonance/itit/service/InfoPostService.java @@ -0,0 +1,36 @@ +package com.dissonance.itit.service; + +import com.dissonance.itit.domain.entity.Category; +import com.dissonance.itit.domain.entity.Image; +import com.dissonance.itit.domain.entity.InfoPost; +import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.domain.enums.Directory; +import com.dissonance.itit.dto.request.InfoPostReq; +import com.dissonance.itit.dto.response.InfoPostCreateRes; +import com.dissonance.itit.repository.InfoPostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class InfoPostService { + private final InfoPostRepository infoPostRepository; + + private final ImageService imageService; + private final CategoryService categoryService; + private final RecruitmentPositionService recruitmentPositionService; + + @Transactional + public InfoPostCreateRes createInfoPost(MultipartFile imgFile, InfoPostReq infoPostReq, User author) { + Image image = imageService.upload(Directory.INFORMATION, imgFile); + Category category = categoryService.findById(infoPostReq.categoryId()); + + InfoPost infoPost = infoPostRepository.save(infoPostReq.toEntity(image, author, category)); + + recruitmentPositionService.addPositions(infoPost, infoPostReq.positionInfos()); + + return InfoPostCreateRes.of(infoPost); + } +} diff --git a/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java b/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java new file mode 100644 index 0000000..62fd732 --- /dev/null +++ b/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java @@ -0,0 +1,29 @@ +package com.dissonance.itit.service; + +import com.dissonance.itit.domain.entity.InfoPost; +import com.dissonance.itit.domain.entity.RecruitmentPosition; +import com.dissonance.itit.dto.request.InfoPostReq; +import com.dissonance.itit.repository.RecruitmentPositionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RecruitmentPositionService { + private final RecruitmentPositionRepository recruitmentPositionRepository; + + @Transactional + public void addPositions(InfoPost infoPost, List positionInfos) { + positionInfos.forEach(positionInfo -> { + RecruitmentPosition newRecruitmentPosition = RecruitmentPosition.builder() + .infoPost(infoPost) + .name(positionInfo.getPositionName()) + .recruitingCount(positionInfo.getRecruitingCount()) + .build(); + recruitmentPositionRepository.save(newRecruitmentPosition); + }); + } +} diff --git a/src/main/java/com/dissonance/itit/service/UserService.java b/src/main/java/com/dissonance/itit/service/UserService.java index ff47c43..c41d3c8 100644 --- a/src/main/java/com/dissonance/itit/service/UserService.java +++ b/src/main/java/com/dissonance/itit/service/UserService.java @@ -63,4 +63,10 @@ private User findByEmail(String email) { return userRepository.findByEmail(email) .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_EMAIL)); } + + @Transactional(readOnly = true) + public User findById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_USER_ID)); + } } \ No newline at end of file From 216008f70cf1eed8745f59b080e04cb010cc73a6 Mon Sep 17 00:00:00 2001 From: jiseon Date: Sun, 18 Aug 2024 23:16:45 +0900 Subject: [PATCH 07/12] =?UTF-8?q?ITDS-30=20fix:=20@RequestPart=20content?= =?UTF-8?q?=20type=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20converter=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...MultipartJackson2HttpMessageConverter.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/com/dissonance/itit/common/converter/MultipartJackson2HttpMessageConverter.java diff --git a/src/main/java/com/dissonance/itit/common/converter/MultipartJackson2HttpMessageConverter.java b/src/main/java/com/dissonance/itit/common/converter/MultipartJackson2HttpMessageConverter.java new file mode 100644 index 0000000..a5587f2 --- /dev/null +++ b/src/main/java/com/dissonance/itit/common/converter/MultipartJackson2HttpMessageConverter.java @@ -0,0 +1,33 @@ +package com.dissonance.itit.common.converter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Type; + +@Component +public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter { + /** + * Converter for support http request with header Content-Type: multipart/form-data + */ + public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, MediaType.APPLICATION_OCTET_STREAM); + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + @Override + public boolean canWrite(Type type, Class clazz, MediaType mediaType) { + return false; + } + + @Override + protected boolean canWrite(MediaType mediaType) { + return false; + } +} \ No newline at end of file From 74cd4cfcf44130aa695911e24915b464d02b3b1d Mon Sep 17 00:00:00 2001 From: jiseon Date: Mon, 19 Aug 2024 22:11:35 +0900 Subject: [PATCH 08/12] =?UTF-8?q?ITDS-30=20test:=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20api=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/dto/request/InfoPostReq.java | 11 +- .../itit/service/InfoPostServiceTest.java | 125 ++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java diff --git a/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java b/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java index acbf03a..816f3e6 100644 --- a/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java +++ b/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java @@ -1,18 +1,21 @@ package com.dissonance.itit.dto.request; +import static com.dissonance.itit.common.util.DateUtil.*; + +import java.util.List; + import com.dissonance.itit.domain.entity.Category; import com.dissonance.itit.domain.entity.Image; import com.dissonance.itit.domain.entity.InfoPost; import com.dissonance.itit.domain.entity.User; + import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import lombok.Builder; import lombok.Getter; -import java.util.List; - -import static com.dissonance.itit.common.util.DateUtil.stringToDate; - +@Builder public record InfoPostReq ( @NotBlank(message = "제목은 필수 입력입니다.") @Schema(description = "제목", example = "공모전1") diff --git a/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java b/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java new file mode 100644 index 0000000..f44812d --- /dev/null +++ b/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java @@ -0,0 +1,125 @@ +package com.dissonance.itit.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import com.dissonance.itit.domain.entity.Category; +import com.dissonance.itit.domain.entity.Image; +import com.dissonance.itit.domain.entity.InfoPost; +import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.domain.enums.Directory; +import com.dissonance.itit.domain.enums.Role; +import com.dissonance.itit.dto.request.InfoPostReq; +import com.dissonance.itit.dto.response.InfoPostCreateRes; +import com.dissonance.itit.repository.InfoPostRepository; + +@ExtendWith(MockitoExtension.class) +public class InfoPostServiceTest { + @InjectMocks + private InfoPostService infoPostService; + + @Mock + private InfoPostRepository infoPostRepository; + @Mock + private ImageService imageService; + @Mock + private CategoryService categoryService; + @Mock + private RecruitmentPositionService recruitmentPositionService; + + @Test + @DisplayName("공고를 생성한다.") + public void createInfoPost_returnInfoPostCreateRes() { + // given + MultipartFile imgFile = getMockMultipartFile(); + InfoPostReq infoPostReq = createInfoPostReq(); + User author = createUser(); + Image image = createImage(); + Category category = createCategory(); + InfoPost infoPost = createInfoPost(infoPostReq, author, image, category); + InfoPostCreateRes expectedResponse = InfoPostCreateRes.of(infoPost); + + given(imageService.upload(Directory.INFORMATION, imgFile)).willReturn(image); + given(categoryService.findById(anyInt())).willReturn(category); + given(infoPostRepository.save(any())).willReturn(infoPost); + + // When + InfoPostCreateRes actualResponse = infoPostService.createInfoPost(imgFile, infoPostReq, author); + + // Then + assertThat(actualResponse).isEqualTo(expectedResponse); + verify(imageService).upload(Directory.INFORMATION, imgFile); + verify(categoryService).findById(infoPostReq.categoryId()); + verify(infoPostRepository).save(any()); + verify(recruitmentPositionService).addPositions(infoPost, infoPostReq.positionInfos()); + } + + + // TODO: test fixture class 분리 + private static MockMultipartFile getMockMultipartFile() { + return new MockMultipartFile( + "대표 썸네일 이미지", + "thumbnail.png", + MediaType.IMAGE_PNG_VALUE, + "thumbnail".getBytes() + ); + } + + private InfoPostReq createInfoPostReq() { + return InfoPostReq.builder() + .title("공고공고") + .content("내용내용") + .organization("ddd") + .categoryId(4) + .activityStartDate("2000년 2월 2일") + .activityEndDate("2000년 2월 9일") + .recruitmentStartDate("2000년 8월 1일") + .recruitmentEndDate("2000년 12월 31일") + .detailUrl("https://www.naver.com/") + .build(); + } + + private User createUser() { + return User.builder() + .id(1L) + .name("김홍돌") + .role(Role.ADMIN) + .build(); + } + + private Image createImage() { + return Image.builder() + .id(5L) + .build(); + } + + private Category createCategory() { + return Category.builder() + .id(2) + .name("해커톤") + .build(); + } + + private InfoPost createInfoPost(InfoPostReq infoPostReq, User author, Image image, Category category) { + return InfoPost.builder() + .title(infoPostReq.title()) + .content(infoPostReq.content()) + .organization(infoPostReq.organization()) + .detailUrl(infoPostReq.detailUrl()) + .image(image) + .category(category) + .author(author) + .build(); + } +} From 6964f486d74430ac54e729c973c463672222adf0 Mon Sep 17 00:00:00 2001 From: jiseon Date: Wed, 21 Aug 2024 22:13:20 +0900 Subject: [PATCH 09/12] =?UTF-8?q?ITDS-33=20test:=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20api=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/common/exception/ErrorCode.java | 32 +++++----- .../dissonance/itit/common/util/DateUtil.java | 31 ++++++---- .../itit/controller/InfoPostController.java | 46 +++++++++----- .../itit/dto/response/InfoPostDetailRes.java | 62 +++++++++++++++++++ .../repository/InfoPostRepositorySupport.java | 59 ++++++++++++++++++ .../itit/service/InfoPostService.java | 55 +++++++++++----- 6 files changed, 227 insertions(+), 58 deletions(-) create mode 100644 src/main/java/com/dissonance/itit/dto/response/InfoPostDetailRes.java create mode 100644 src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java diff --git a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java index 99d9d75..c314da0 100644 --- a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java +++ b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java @@ -1,27 +1,29 @@ package com.dissonance.itit.common.exception; +import org.springframework.http.HttpStatus; + import lombok.AllArgsConstructor; import lombok.Getter; -import org.springframework.http.HttpStatus; @Getter @AllArgsConstructor public enum ErrorCode { - // 400 - INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "존재하지 않는 Provider입니다."), - INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "파일 형식은 이미지만 가능합니다."), - INVALID_FILE_SIZE(HttpStatus.BAD_REQUEST, "파일 용량은 10MB를 넘을 수 없습니다."), - INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "날짜 변환에 실패했습니다."), + // 400 + INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "존재하지 않는 Provider입니다."), + INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "파일 형식은 이미지만 가능합니다."), + INVALID_FILE_SIZE(HttpStatus.BAD_REQUEST, "파일 용량은 10MB를 넘을 수 없습니다."), + INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "날짜 변환에 실패했습니다."), - // 404 - NON_EXISTENT_USER_ID(HttpStatus.NOT_FOUND, "해당 id의 사용자가 존재하지 않습니다."), - NON_EXISTENT_EMAIL(HttpStatus.NOT_FOUND, "해당 email의 사용자가 존재하지 않습니다."), - IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 id의 이미지가 존재하지 않습니다."), - NON_EXISTENT_CATEGORY_ID(HttpStatus.NOT_FOUND, "해당 id의 카테고리가 존재하지 않습니다."), + // 404 + NON_EXISTENT_USER_ID(HttpStatus.NOT_FOUND, "해당 id의 사용자가 존재하지 않습니다."), + NON_EXISTENT_EMAIL(HttpStatus.NOT_FOUND, "해당 email의 사용자가 존재하지 않습니다."), + IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 id의 이미지가 존재하지 않습니다."), + NON_EXISTENT_CATEGORY_ID(HttpStatus.NOT_FOUND, "해당 id의 카테고리가 존재하지 않습니다."), + NON_EXISTENT_INFO_POST_ID(HttpStatus.NOT_FOUND, "해당 id의 공고 게시글이 존재하지 않습니다."), - // 500 - IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일 입출력 에러"); + // 500 + IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일 입출력 에러"); - private final HttpStatus httpStatus; - private final String message; + private final HttpStatus httpStatus; + private final String message; } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/common/util/DateUtil.java b/src/main/java/com/dissonance/itit/common/util/DateUtil.java index 0f3ee0d..9b742e9 100644 --- a/src/main/java/com/dissonance/itit/common/util/DateUtil.java +++ b/src/main/java/com/dissonance/itit/common/util/DateUtil.java @@ -1,19 +1,28 @@ package com.dissonance.itit.common.util; -import com.dissonance.itit.common.exception.CustomException; -import com.dissonance.itit.common.exception.ErrorCode; - import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; + public class DateUtil { - public static LocalDate stringToDate(String dateString) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 M월 d일"); - try { - return LocalDate.parse(dateString, formatter); - } catch (DateTimeParseException e) { - throw new CustomException(ErrorCode.INVALID_DATE_FORMAT); - } - } + public static LocalDate stringToDate(String dateString) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 M월 d일"); + try { + return LocalDate.parse(dateString, formatter); + } catch (DateTimeParseException e) { + throw new CustomException(ErrorCode.INVALID_DATE_FORMAT); + } + } + + public static String formatPeriod(LocalDate startDate, LocalDate endDate) { + return formatDate(startDate) + " ~ " + formatDate(endDate); + } + + public static String formatDate(LocalDate date) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"); + return date.format(formatter); + } } diff --git a/src/main/java/com/dissonance/itit/controller/InfoPostController.java b/src/main/java/com/dissonance/itit/controller/InfoPostController.java index 93dca82..7d8e078 100644 --- a/src/main/java/com/dissonance/itit/controller/InfoPostController.java +++ b/src/main/java/com/dissonance/itit/controller/InfoPostController.java @@ -1,34 +1,46 @@ package com.dissonance.itit.controller; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + import com.dissonance.itit.domain.entity.User; import com.dissonance.itit.dto.request.InfoPostReq; import com.dissonance.itit.dto.response.InfoPostCreateRes; +import com.dissonance.itit.dto.response.InfoPostDetailRes; import com.dissonance.itit.service.InfoPostService; import com.dissonance.itit.service.UserService; + import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @RequestMapping("/info-posts") public class InfoPostController { - private final InfoPostService infoPostService; - private final UserService userService; + private final InfoPostService infoPostService; + private final UserService userService; + + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation(summary = "공고 게시글 등록", description = "공고 게시글을 등록합니다.") + public ResponseEntity createInfoPost(@RequestPart MultipartFile imgFile, + @Valid @RequestPart InfoPostReq infoPostReq) { + User loginUser = userService.findById(1L); // TODO: 로그인 유저 정보 적용 예정 + InfoPostCreateRes infoPostCreateRes = infoPostService.createInfoPost(imgFile, infoPostReq, loginUser); + return ResponseEntity.ok(infoPostCreateRes); + } - @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) - @Operation(summary = "공고 게시글 등록", description = "공고 게시글을 등록합니다.") - public ResponseEntity createInfoPost(@RequestPart MultipartFile imgFile, - @Valid @RequestPart InfoPostReq infoPostReq) { - User loginUser = userService.findById(1L); // TODO: 로그인 유저 정보 적용 예정 - InfoPostCreateRes infoPostCreateRes = infoPostService.createInfoPost(imgFile, infoPostReq, loginUser); - return ResponseEntity.ok(infoPostCreateRes); - } + @GetMapping("/{infoPostId}") + @Operation(summary = "공고 게시글 조회", description = "공고 게시글을 상세 조회합니다.") + public ResponseEntity getInfoPostDetail(@PathVariable Long infoPostId) { + InfoPostDetailRes infoPostDetailRes = infoPostService.getInfoPostDetailById(infoPostId); + return ResponseEntity.ok(infoPostDetailRes); + } } diff --git a/src/main/java/com/dissonance/itit/dto/response/InfoPostDetailRes.java b/src/main/java/com/dissonance/itit/dto/response/InfoPostDetailRes.java new file mode 100644 index 0000000..100a3ad --- /dev/null +++ b/src/main/java/com/dissonance/itit/dto/response/InfoPostDetailRes.java @@ -0,0 +1,62 @@ +package com.dissonance.itit.dto.response; + +import static com.dissonance.itit.common.util.DateUtil.*; + +import java.time.LocalDate; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class InfoPostDetailRes { + private final String title; + private final String categoryName; + private final String organization; + private final String RecruitmentPeriod; + private final List positionInfos; + private final String activityPeriod; + private final String content; + private final String detailUrl; + private final Integer viewCount; + + @Getter + @AllArgsConstructor + public static class PositionInfo { // TODO: 아예 독립적인 DTO로 분리하는 쪽이 더 낫겠음 + String positionName; + Integer recruitingCount; + } + + @Getter + @AllArgsConstructor + public static class InfoPostInfo { + String title; + String categoryName; + String organization; + LocalDate recruitmentStartDate; + LocalDate recruitmentEndDate; + LocalDate activityStartDate; + LocalDate activityEndDate; + String content; + String detailUrl; + Integer viewCount; + } + + public static InfoPostDetailRes of(InfoPostInfo infoPostInfo, List positionInfos) { + return InfoPostDetailRes.builder() + .title(infoPostInfo.getTitle()) + .categoryName(infoPostInfo.getCategoryName()) + .organization(infoPostInfo.getOrganization() == null ? "" : infoPostInfo.getOrganization()) + .RecruitmentPeriod( + formatPeriod(infoPostInfo.getRecruitmentStartDate(), infoPostInfo.getRecruitmentEndDate())) + .positionInfos(positionInfos) + .activityPeriod( + formatPeriod(infoPostInfo.getActivityStartDate(), infoPostInfo.getActivityEndDate())) + .content(infoPostInfo.getContent() == null ? "" : infoPostInfo.getContent()) + .detailUrl(infoPostInfo.getDetailUrl()) + .viewCount(infoPostInfo.getViewCount()) + .build(); + } +}현 \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java b/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java new file mode 100644 index 0000000..f2b6ee1 --- /dev/null +++ b/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java @@ -0,0 +1,59 @@ +package com.dissonance.itit.repository; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.dissonance.itit.domain.entity.QInfoPost; +import com.dissonance.itit.domain.entity.QRecruitmentPosition; +import com.dissonance.itit.dto.response.InfoPostDetailRes; +import com.dissonance.itit.dto.response.InfoPostDetailRes.InfoPostInfo; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class InfoPostRepositorySupport { + private final JPAQueryFactory jpaQueryFactory; + private final QInfoPost infoPost = QInfoPost.infoPost; + private final QRecruitmentPosition recruitmentPosition = QRecruitmentPosition.recruitmentPosition; + + public InfoPostInfo findById(Long infoPostId) { + incrementViewCount(infoPostId); + + return jpaQueryFactory.select(Projections.constructor(InfoPostInfo.class, + infoPost.title.as("title"), + infoPost.category.name.as("categoryName"), + infoPost.organization.as("organization"), + infoPost.recruitmentStartDate.as("recruitmentStartDate"), + infoPost.recruitmentEndDate.as("recruitmentEndDate"), + infoPost.activityStartDate.as("activityStartDate"), + infoPost.activityEndDate.as("activityEndDate"), + infoPost.content.as("content"), + infoPost.detailUrl.as("detailUrl"), + infoPost.viewCount.as("viewCount") + )) + .from(infoPost) + .where(infoPost.id.eq(infoPostId)) + .fetchOne(); + } + + public List findByInfoPostId(Long infoPostId) { + return jpaQueryFactory.select(Projections.constructor(InfoPostDetailRes.PositionInfo.class, + recruitmentPosition.name.as("positionName"), + recruitmentPosition.recruitingCount.as("recruitingCount") + )) + .from(recruitmentPosition) + .where(recruitmentPosition.infoPost.id.eq(infoPostId)) + .fetch(); + } + + private void incrementViewCount(Long infoPostId) { + jpaQueryFactory.update(infoPost) + .set(infoPost.viewCount, infoPost.viewCount.add(1)) + .where(infoPost.id.eq(infoPostId)) + .execute(); + } +} diff --git a/src/main/java/com/dissonance/itit/service/InfoPostService.java b/src/main/java/com/dissonance/itit/service/InfoPostService.java index a2d7366..6899a0e 100644 --- a/src/main/java/com/dissonance/itit/service/InfoPostService.java +++ b/src/main/java/com/dissonance/itit/service/InfoPostService.java @@ -1,5 +1,13 @@ package com.dissonance.itit.service; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; import com.dissonance.itit.domain.entity.Category; import com.dissonance.itit.domain.entity.Image; import com.dissonance.itit.domain.entity.InfoPost; @@ -7,30 +15,47 @@ import com.dissonance.itit.domain.enums.Directory; import com.dissonance.itit.dto.request.InfoPostReq; import com.dissonance.itit.dto.response.InfoPostCreateRes; +import com.dissonance.itit.dto.response.InfoPostDetailRes; +import com.dissonance.itit.dto.response.InfoPostDetailRes.InfoPostInfo; +import com.dissonance.itit.dto.response.InfoPostDetailRes.PositionInfo; import com.dissonance.itit.repository.InfoPostRepository; +import com.dissonance.itit.repository.InfoPostRepositorySupport; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor public class InfoPostService { - private final InfoPostRepository infoPostRepository; + private final InfoPostRepository infoPostRepository; + private final InfoPostRepositorySupport infoPostRepositorySupport; + + private final ImageService imageService; + private final CategoryService categoryService; + private final RecruitmentPositionService recruitmentPositionService; + + @Transactional + public InfoPostCreateRes createInfoPost(MultipartFile imgFile, InfoPostReq infoPostReq, User author) { + Image image = imageService.upload(Directory.INFORMATION, imgFile); + Category category = categoryService.findById(infoPostReq.categoryId()); + + InfoPost infoPost = infoPostRepository.save(infoPostReq.toEntity(image, author, category)); + + recruitmentPositionService.addPositions(infoPost, infoPostReq.positionInfos()); - private final ImageService imageService; - private final CategoryService categoryService; - private final RecruitmentPositionService recruitmentPositionService; + return InfoPostCreateRes.of(infoPost); + } - @Transactional - public InfoPostCreateRes createInfoPost(MultipartFile imgFile, InfoPostReq infoPostReq, User author) { - Image image = imageService.upload(Directory.INFORMATION, imgFile); - Category category = categoryService.findById(infoPostReq.categoryId()); + @Transactional(readOnly = true) + public InfoPostDetailRes getInfoPostDetailById(Long infoPostId) { + InfoPostInfo infoPostInfo = infoPostRepositorySupport.findById(infoPostId); - InfoPost infoPost = infoPostRepository.save(infoPostReq.toEntity(image, author, category)); + if (infoPostInfo == null) { + throw new CustomException(ErrorCode.NON_EXISTENT_INFO_POST_ID); + } - recruitmentPositionService.addPositions(infoPost, infoPostReq.positionInfos()); + List positionInfos = infoPostRepositorySupport.findByInfoPostId( + infoPostId); // TODO: 적합한 도메인으로 옮기기 - return InfoPostCreateRes.of(infoPost); - } + return InfoPostDetailRes.of(infoPostInfo, positionInfos); + } } From 33b7f0e8d9ed8edd45291c6af4169865ff0102ab Mon Sep 17 00:00:00 2001 From: jiseon Date: Thu, 22 Aug 2024 21:42:39 +0900 Subject: [PATCH 10/12] =?UTF-8?q?ITDS-33=20refactor:=20inner=20class=20dto?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC,=20position=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=8D=94=20=EC=A0=81=EC=A0=88?= =?UTF-8?q?=ED=95=9C=20=EC=9C=84=EC=B9=98=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/dto/common/PositionInfo.java | 7 ++ .../itit/dto/request/InfoPostReq.java | 102 ++++++++---------- .../itit/dto/response/InfoPostDetailRes.java | 32 +++--- .../repository/InfoPostRepositorySupport.java | 18 +--- .../RecruitmentPositionRepositorySupport.java | 29 +++++ .../service/RecruitmentPositionService.java | 42 +++++--- 6 files changed, 123 insertions(+), 107 deletions(-) create mode 100644 src/main/java/com/dissonance/itit/dto/common/PositionInfo.java create mode 100644 src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepositorySupport.java diff --git a/src/main/java/com/dissonance/itit/dto/common/PositionInfo.java b/src/main/java/com/dissonance/itit/dto/common/PositionInfo.java new file mode 100644 index 0000000..df5a9ab --- /dev/null +++ b/src/main/java/com/dissonance/itit/dto/common/PositionInfo.java @@ -0,0 +1,7 @@ +package com.dissonance.itit.dto.common; + +public record PositionInfo( + String positionName, + Integer recruitingCount +) { +} diff --git a/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java b/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java index 816f3e6..beda5ee 100644 --- a/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java +++ b/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java @@ -8,68 +8,58 @@ import com.dissonance.itit.domain.entity.Image; import com.dissonance.itit.domain.entity.InfoPost; import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.dto.common.PositionInfo; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Builder; -import lombok.Getter; @Builder -public record InfoPostReq ( - @NotBlank(message = "제목은 필수 입력입니다.") - @Schema(description = "제목", example = "공모전1") - String title, - @NotNull(message = "카테고리 id는 필수 입력입니다.") - @Schema(description = "공고 카테고리 id", example = "3") - Integer categoryId, - @Schema(description = "모집 기관 or 단체", example = "DDD") - String organization, - @NotBlank(message = "모집 시작일은 필수 입력입니다.") - @Schema(description = "모집 시작 일자", example = "2024년 8월 10일") - String recruitmentStartDate, - @NotBlank(message = "모집 마감일은 필수 입력입니다.") - @Schema(description = "모집 종료 일자", example = "2024년 8월 18일") - String recruitmentEndDate, - List positionInfos, - @NotBlank(message = "활동 시작일은 필수 입력입니다.") - @Schema(description = "활동 시작 일자", example = "2024년 10월 1일") - String activityStartDate, - @NotBlank(message = "활동 종료일은 필수 입력입니다.") - @Schema(description = "활동 종료 일자", example = "2024년 12월 31일") - String activityEndDate, - @Schema(description = "활동 내용",example = "여러분의 창의력과 디자인 역량을 발휘해볼 특별한 기회를 놓치지 마세요") - String content, - @NotBlank(message = "공고 url은 필수 입력입니다.") - @Schema(description = "공고 url", example = "https://www.google.com/") - String detailUrl +public record InfoPostReq( + @NotBlank(message = "제목은 필수 입력입니다.") + @Schema(description = "제목", example = "공모전1") + String title, + @NotNull(message = "카테고리 id는 필수 입력입니다.") + @Schema(description = "공고 카테고리 id", example = "3") + Integer categoryId, + @Schema(description = "모집 기관 or 단체", example = "DDD") + String organization, + @NotBlank(message = "모집 시작일은 필수 입력입니다.") + @Schema(description = "모집 시작 일자", example = "2024년 8월 10일") + String recruitmentStartDate, + @NotBlank(message = "모집 마감일은 필수 입력입니다.") + @Schema(description = "모집 종료 일자", example = "2024년 8월 18일") + String recruitmentEndDate, + List positionInfos, + @NotBlank(message = "활동 시작일은 필수 입력입니다.") + @Schema(description = "활동 시작 일자", example = "2024년 10월 1일") + String activityStartDate, + @NotBlank(message = "활동 종료일은 필수 입력입니다.") + @Schema(description = "활동 종료 일자", example = "2024년 12월 31일") + String activityEndDate, + @Schema(description = "활동 내용", example = "여러분의 창의력과 디자인 역량을 발휘해볼 특별한 기회를 놓치지 마세요") + String content, + @NotBlank(message = "공고 url은 필수 입력입니다.") + @Schema(description = "공고 url", example = "https://www.google.com/") + String detailUrl ) { - @Getter - public static class PositionInfo { - @NotBlank(message = "모집 직군명은 필수 입력입니다.") - @Schema(description = "모집 직군명", example = "개발자") - String positionName; - @NotNull(message = "모집 인원수는 필수 입력입니다.") - @Schema(description = "모집 인원수", example = "3") - Integer recruitingCount; - } - - public InfoPost toEntity(Image image, User author, Category category) { - return InfoPost.builder() - .image(image) - .author(author) - .category(category) - .title(title()) - .organization(organization()) - .content(content()) - .viewCount(0) - .recruitmentStartDate(stringToDate(recruitmentStartDate())) - .recruitmentEndDate(stringToDate(recruitmentEndDate())) - .activityStartDate(stringToDate(activityStartDate())) - .activityEndDate(stringToDate(activityEndDate())) - .detailUrl(detailUrl()) - .reported(false) - .recruitmentClosed(false) - .build(); - } + public InfoPost toEntity(Image image, User author, Category category) { + return InfoPost.builder() + .image(image) + .author(author) + .category(category) + .title(title()) + .organization(organization()) + .content(content()) + .viewCount(0) + .recruitmentStartDate(stringToDate(recruitmentStartDate())) + .recruitmentEndDate(stringToDate(recruitmentEndDate())) + .activityStartDate(stringToDate(activityStartDate())) + .activityEndDate(stringToDate(activityEndDate())) + .detailUrl(detailUrl()) + .reported(false) + .recruitmentClosed(false) + .build(); + } } diff --git a/src/main/java/com/dissonance/itit/dto/response/InfoPostDetailRes.java b/src/main/java/com/dissonance/itit/dto/response/InfoPostDetailRes.java index 100a3ad..c262914 100644 --- a/src/main/java/com/dissonance/itit/dto/response/InfoPostDetailRes.java +++ b/src/main/java/com/dissonance/itit/dto/response/InfoPostDetailRes.java @@ -5,6 +5,8 @@ import java.time.LocalDate; import java.util.List; +import com.dissonance.itit.dto.common.PositionInfo; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -22,26 +24,20 @@ public class InfoPostDetailRes { private final String detailUrl; private final Integer viewCount; - @Getter - @AllArgsConstructor - public static class PositionInfo { // TODO: 아예 독립적인 DTO로 분리하는 쪽이 더 낫겠음 - String positionName; - Integer recruitingCount; - } - @Getter @AllArgsConstructor public static class InfoPostInfo { - String title; - String categoryName; - String organization; - LocalDate recruitmentStartDate; - LocalDate recruitmentEndDate; - LocalDate activityStartDate; - LocalDate activityEndDate; - String content; - String detailUrl; - Integer viewCount; + private String title; + private String categoryName; + private String organization; + private LocalDate recruitmentStartDate; + private LocalDate recruitmentEndDate; + private LocalDate activityStartDate; + private LocalDate activityEndDate; + private String content; + private String detailUrl; + private Integer viewCount; + private Boolean reported; } public static InfoPostDetailRes of(InfoPostInfo infoPostInfo, List positionInfos) { @@ -59,4 +55,4 @@ public static InfoPostDetailRes of(InfoPostInfo infoPostInfo, List .viewCount(infoPostInfo.getViewCount()) .build(); } -}현 \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java b/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java index f2b6ee1..90649d7 100644 --- a/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java +++ b/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java @@ -1,12 +1,8 @@ package com.dissonance.itit.repository; -import java.util.List; - import org.springframework.stereotype.Repository; import com.dissonance.itit.domain.entity.QInfoPost; -import com.dissonance.itit.domain.entity.QRecruitmentPosition; -import com.dissonance.itit.dto.response.InfoPostDetailRes; import com.dissonance.itit.dto.response.InfoPostDetailRes.InfoPostInfo; import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -18,7 +14,6 @@ public class InfoPostRepositorySupport { private final JPAQueryFactory jpaQueryFactory; private final QInfoPost infoPost = QInfoPost.infoPost; - private final QRecruitmentPosition recruitmentPosition = QRecruitmentPosition.recruitmentPosition; public InfoPostInfo findById(Long infoPostId) { incrementViewCount(infoPostId); @@ -33,23 +28,14 @@ public InfoPostInfo findById(Long infoPostId) { infoPost.activityEndDate.as("activityEndDate"), infoPost.content.as("content"), infoPost.detailUrl.as("detailUrl"), - infoPost.viewCount.as("viewCount") + infoPost.viewCount.as("viewCount"), + infoPost.reported.as("reported") )) .from(infoPost) .where(infoPost.id.eq(infoPostId)) .fetchOne(); } - public List findByInfoPostId(Long infoPostId) { - return jpaQueryFactory.select(Projections.constructor(InfoPostDetailRes.PositionInfo.class, - recruitmentPosition.name.as("positionName"), - recruitmentPosition.recruitingCount.as("recruitingCount") - )) - .from(recruitmentPosition) - .where(recruitmentPosition.infoPost.id.eq(infoPostId)) - .fetch(); - } - private void incrementViewCount(Long infoPostId) { jpaQueryFactory.update(infoPost) .set(infoPost.viewCount, infoPost.viewCount.add(1)) diff --git a/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepositorySupport.java b/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepositorySupport.java new file mode 100644 index 0000000..dc8b757 --- /dev/null +++ b/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepositorySupport.java @@ -0,0 +1,29 @@ +package com.dissonance.itit.repository; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.dissonance.itit.domain.entity.QRecruitmentPosition; +import com.dissonance.itit.dto.common.PositionInfo; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class RecruitmentPositionRepositorySupport { + private final JPAQueryFactory jpaQueryFactory; + private final QRecruitmentPosition recruitmentPosition = QRecruitmentPosition.recruitmentPosition; + + public List findByInfoPostId(Long infoPostId) { + return jpaQueryFactory.select(Projections.constructor(PositionInfo.class, + recruitmentPosition.name.as("positionName"), + recruitmentPosition.recruitingCount.as("recruitingCount") + )) + .from(recruitmentPosition) + .where(recruitmentPosition.infoPost.id.eq(infoPostId)) + .fetch(); + } +} diff --git a/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java b/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java index 62fd732..c9641a1 100644 --- a/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java +++ b/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java @@ -1,29 +1,37 @@ package com.dissonance.itit.service; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.dissonance.itit.domain.entity.InfoPost; import com.dissonance.itit.domain.entity.RecruitmentPosition; -import com.dissonance.itit.dto.request.InfoPostReq; +import com.dissonance.itit.dto.common.PositionInfo; import com.dissonance.itit.repository.RecruitmentPositionRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import com.dissonance.itit.repository.RecruitmentPositionRepositorySupport; -import java.util.List; +import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor public class RecruitmentPositionService { - private final RecruitmentPositionRepository recruitmentPositionRepository; + private final RecruitmentPositionRepository recruitmentPositionRepository; + private final RecruitmentPositionRepositorySupport recruitmentPositionRepositorySupport; + + @Transactional + public void addPositions(InfoPost infoPost, List positionInfos) { + positionInfos.forEach(positionInfo -> { + RecruitmentPosition newRecruitmentPosition = RecruitmentPosition.builder() + .infoPost(infoPost) + .name(positionInfo.positionName()) + .recruitingCount(positionInfo.recruitingCount()) + .build(); + recruitmentPositionRepository.save(newRecruitmentPosition); + }); + } - @Transactional - public void addPositions(InfoPost infoPost, List positionInfos) { - positionInfos.forEach(positionInfo -> { - RecruitmentPosition newRecruitmentPosition = RecruitmentPosition.builder() - .infoPost(infoPost) - .name(positionInfo.getPositionName()) - .recruitingCount(positionInfo.getRecruitingCount()) - .build(); - recruitmentPositionRepository.save(newRecruitmentPosition); - }); - } + public List findPositionInfosByInfoPostId(Long infoPostId) { + return recruitmentPositionRepositorySupport.findByInfoPostId(infoPostId); + } } From 6d2f13b81fb4ff220fb4b12956d73278f760b169 Mon Sep 17 00:00:00 2001 From: jiseon Date: Thu, 22 Aug 2024 21:43:21 +0900 Subject: [PATCH 11/12] =?UTF-8?q?ITDS-33=20feat:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=8B=A0=EA=B3=A0=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/common/exception/ErrorCode.java | 1 + .../exception/GlobalExceptionHandler.java | 27 ++-- .../itit/controller/InfoPostController.java | 8 + .../itit/domain/entity/InfoPost.java | 147 ++++++++++-------- .../itit/service/InfoPostService.java | 18 ++- src/main/resources/application.yml | 3 - 6 files changed, 120 insertions(+), 84 deletions(-) diff --git a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java index c314da0..e71ee78 100644 --- a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java +++ b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java @@ -20,6 +20,7 @@ public enum ErrorCode { IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 id의 이미지가 존재하지 않습니다."), NON_EXISTENT_CATEGORY_ID(HttpStatus.NOT_FOUND, "해당 id의 카테고리가 존재하지 않습니다."), NON_EXISTENT_INFO_POST_ID(HttpStatus.NOT_FOUND, "해당 id의 공고 게시글이 존재하지 않습니다."), + REPORTED_INFO_POST_ID(HttpStatus.NOT_FOUND, "해당 id의 게시글은 신고 처리되었습니다."), // 500 IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일 입출력 에러"); diff --git a/src/main/java/com/dissonance/itit/common/exception/GlobalExceptionHandler.java b/src/main/java/com/dissonance/itit/common/exception/GlobalExceptionHandler.java index cc948c0..1571284 100644 --- a/src/main/java/com/dissonance/itit/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dissonance/itit/common/exception/GlobalExceptionHandler.java @@ -1,25 +1,26 @@ package com.dissonance.itit.common.exception; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import lombok.extern.slf4j.Slf4j; + @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(CustomException.class) - protected ResponseEntity handleCustomException(CustomException e) { - ErrorResponse response = new ErrorResponse(e.getErrorCode().name(), e.getMessage()); - log.error("CustomException : {}", e.getMessage()); - return new ResponseEntity<>(response, e.getErrorCode().getHttpStatus()); - } + @ExceptionHandler(CustomException.class) + protected ResponseEntity handleCustomException(CustomException e) { + ErrorResponse response = new ErrorResponse(e.getErrorCode().name(), e.getMessage()); + log.error("CustomException : {}", e.getMessage()); + return new ResponseEntity<>(response, e.getErrorCode().getHttpStatus()); + } - @ExceptionHandler(Exception.class) - public ResponseEntity handleAllException(final Exception e) { - ErrorResponse response = new ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.name(), e.getMessage()); - log.error("handleAllException {}", e.getMessage()); - return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); - } + @ExceptionHandler(Exception.class) + public ResponseEntity handleAllException(final Exception e) { + ErrorResponse response = new ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.name(), e.getMessage()); + log.error("handleAllException {}", e.getMessage()); + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } } diff --git a/src/main/java/com/dissonance/itit/controller/InfoPostController.java b/src/main/java/com/dissonance/itit/controller/InfoPostController.java index 7d8e078..2499262 100644 --- a/src/main/java/com/dissonance/itit/controller/InfoPostController.java +++ b/src/main/java/com/dissonance/itit/controller/InfoPostController.java @@ -3,6 +3,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -43,4 +44,11 @@ public ResponseEntity getInfoPostDetail(@PathVariable Long in InfoPostDetailRes infoPostDetailRes = infoPostService.getInfoPostDetailById(infoPostId); return ResponseEntity.ok(infoPostDetailRes); } + + @PatchMapping("/{infoPostId}/reports") + @Operation(summary = "공고 게시글 신고", description = "공고 게시글을 신고 처리합니다. (즉시 반영)") + public ResponseEntity reportedInfoPost(@PathVariable Long infoPostId) { + Long resultId = infoPostService.reportedInfoPost(infoPostId); + return ResponseEntity.ok(resultId + "번 게시글의 신고가 성공적으로 접수되었습니다."); + } } diff --git a/src/main/java/com/dissonance/itit/domain/entity/InfoPost.java b/src/main/java/com/dissonance/itit/domain/entity/InfoPost.java index 56d5d9a..3636506 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/InfoPost.java +++ b/src/main/java/com/dissonance/itit/domain/entity/InfoPost.java @@ -1,11 +1,25 @@ package com.dissonance.itit.domain.entity; -import jakarta.persistence.*; +import java.time.LocalDate; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import lombok.*; - -import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Builder @@ -14,65 +28,68 @@ @Entity @Table(name = "info_post") public class InfoPost extends BaseTime { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private Long id; - - @Size(max = 255) - @NotNull - @Column(name = "title") - private String title; - - @Size(max = 2000) - @Column(name = "content") - private String content; - - @NotNull - @Column(name = "view_count") - private Integer viewCount; - - @NotNull - @Column(name = "recruitment_start_date") - private LocalDate recruitmentStartDate; - - @NotNull - @Column(name = "recruitment_end_date") - private LocalDate recruitmentEndDate; - - @NotNull - @Column(name = "activity_start_date") - private LocalDate activityStartDate; - - @NotNull - @Column(name = "activity_end_date") - private LocalDate activityEndDate; - - @Size(max = 500) - @NotNull - @Column(name = "detail_url") - private String detailUrl; - - @Size(max = 50) - @NotNull - @Column(name = "organization") - private String organization; - - @Column(name = "reported") - private Boolean reported; - - @Column(name = "recruitment_closed") - private Boolean recruitmentClosed; - - @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "image_id") - private Image image; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "author_id") - private User author; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "category_id") - private Category category; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Size(max = 255) + @NotNull + @Column(name = "title") + private String title; + + @Size(max = 2000) + @Column(name = "content") + private String content; + + @NotNull + @Column(name = "view_count") + private Integer viewCount; + + @NotNull + @Column(name = "recruitment_start_date") + private LocalDate recruitmentStartDate; + + @NotNull + @Column(name = "recruitment_end_date") + private LocalDate recruitmentEndDate; + + @NotNull + @Column(name = "activity_start_date") + private LocalDate activityStartDate; + + @NotNull + @Column(name = "activity_end_date") + private LocalDate activityEndDate; + + @Size(max = 500) + @NotNull + @Column(name = "detail_url") + private String detailUrl; + + @Size(max = 50) + @Column(name = "organization") + private String organization; + + @Column(name = "reported") + private Boolean reported; + + @Column(name = "recruitment_closed") + private Boolean recruitmentClosed; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "image_id") + private Image image; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id") + private User author; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private Category category; + + public void updateReported() { + this.reported = true; + } } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/service/InfoPostService.java b/src/main/java/com/dissonance/itit/service/InfoPostService.java index 6899a0e..488a882 100644 --- a/src/main/java/com/dissonance/itit/service/InfoPostService.java +++ b/src/main/java/com/dissonance/itit/service/InfoPostService.java @@ -13,11 +13,11 @@ import com.dissonance.itit.domain.entity.InfoPost; import com.dissonance.itit.domain.entity.User; import com.dissonance.itit.domain.enums.Directory; +import com.dissonance.itit.dto.common.PositionInfo; import com.dissonance.itit.dto.request.InfoPostReq; import com.dissonance.itit.dto.response.InfoPostCreateRes; import com.dissonance.itit.dto.response.InfoPostDetailRes; import com.dissonance.itit.dto.response.InfoPostDetailRes.InfoPostInfo; -import com.dissonance.itit.dto.response.InfoPostDetailRes.PositionInfo; import com.dissonance.itit.repository.InfoPostRepository; import com.dissonance.itit.repository.InfoPostRepositorySupport; @@ -53,9 +53,21 @@ public InfoPostDetailRes getInfoPostDetailById(Long infoPostId) { throw new CustomException(ErrorCode.NON_EXISTENT_INFO_POST_ID); } - List positionInfos = infoPostRepositorySupport.findByInfoPostId( - infoPostId); // TODO: 적합한 도메인으로 옮기기 + if (infoPostInfo.getReported()) { + throw new CustomException(ErrorCode.REPORTED_INFO_POST_ID); + } + + List positionInfos = recruitmentPositionService.findPositionInfosByInfoPostId( + infoPostId); return InfoPostDetailRes.of(infoPostInfo, positionInfos); } + + @Transactional + public Long reportedInfoPost(Long infoPostId) { + InfoPost infoPost = infoPostRepository.findById(infoPostId) + .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_INFO_POST_ID)); + infoPost.updateReported(); + return infoPost.getId(); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0e2f982..9661b71 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -41,9 +41,6 @@ logging: hibernate: type: trace stat: debug - orm: - jdbc: - bind: trace org.springframework.web.reactive.function.client.ExchangeFunctions: TRACE cloud: From c0aac9dde4d06c1c2a486a7d760fb06edb226ab4 Mon Sep 17 00:00:00 2001 From: jiseon Date: Thu, 22 Aug 2024 22:22:17 +0900 Subject: [PATCH 12/12] =?UTF-8?q?ITDS-33=20test:=20Test=20Fixtrues=20packa?= =?UTF-8?q?ge=20=EB=B6=84=EB=A6=AC,=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EA=B3=BC=20=EC=8B=A0=EA=B3=A0=20api=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/dto/common/PositionInfo.java | 2 +- .../dissonance/itit/fixture/TestFixture.java | 125 +++++++++++++ .../itit/service/InfoPostServiceTest.java | 165 +++++++++++------- 3 files changed, 229 insertions(+), 63 deletions(-) create mode 100644 src/test/java/com/dissonance/itit/fixture/TestFixture.java diff --git a/src/main/java/com/dissonance/itit/dto/common/PositionInfo.java b/src/main/java/com/dissonance/itit/dto/common/PositionInfo.java index df5a9ab..59959ad 100644 --- a/src/main/java/com/dissonance/itit/dto/common/PositionInfo.java +++ b/src/main/java/com/dissonance/itit/dto/common/PositionInfo.java @@ -2,6 +2,6 @@ public record PositionInfo( String positionName, - Integer recruitingCount + int recruitingCount ) { } diff --git a/src/test/java/com/dissonance/itit/fixture/TestFixture.java b/src/test/java/com/dissonance/itit/fixture/TestFixture.java new file mode 100644 index 0000000..11748d0 --- /dev/null +++ b/src/test/java/com/dissonance/itit/fixture/TestFixture.java @@ -0,0 +1,125 @@ +package com.dissonance.itit.fixture; + +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; + +import com.dissonance.itit.domain.entity.Category; +import com.dissonance.itit.domain.entity.Image; +import com.dissonance.itit.domain.entity.InfoPost; +import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.domain.enums.Role; +import com.dissonance.itit.dto.common.PositionInfo; +import com.dissonance.itit.dto.request.InfoPostReq; + +public class TestFixture { + public static MockMultipartFile getMockMultipartFile() { + return new MockMultipartFile( + "대표 썸네일 이미지", + "thumbnail.png", + MediaType.IMAGE_PNG_VALUE, + "thumbnail".getBytes() + ); + } + + public static InfoPostReq createInfoPostReq() { + return InfoPostReq.builder() + .title("공고공고") + .content("내용내용") + .organization("ddd") + .categoryId(4) + .activityStartDate("2000년 2월 2일") + .activityEndDate("2000년 2월 9일") + .recruitmentStartDate("2000년 8월 1일") + .recruitmentEndDate("2000년 12월 31일") + .detailUrl("https://www.naver.com/") + .build(); + } + + public static User createUser() { + return User.builder() + .id(1L) + .name("김홍돌") + .role(Role.ADMIN) + .build(); + } + + public static Image createImage() { + return Image.builder() + .id(5L) + .build(); + } + + public static Category createCategory() { + return Category.builder() + .id(2) + .name("해커톤") + .build(); + } + + public static InfoPost createInfoPost(InfoPostReq infoPostReq, User author, Image image, Category category) { + return InfoPost.builder() + .id(1L) + .title(infoPostReq.title()) + .content(infoPostReq.content()) + .organization(infoPostReq.organization()) + .detailUrl(infoPostReq.detailUrl()) + .image(image) + .category(category) + .author(author) + .build(); + } + + public static List createMultiplePositionInfos() { + return List.of( + new PositionInfo("개발자", 0), + new PositionInfo("기획자", 1), + new PositionInfo("디자이너", 2) + ); + } + + public static List createMultipleInfoPosts(User author, Image image, Category category) { + InfoPostReq infoPostReq1 = InfoPostReq.builder() + .title("공고 1") + .content("내용 1") + .organization("조직 1") + .categoryId(4) + .activityStartDate("2000년 1월 1일") + .activityEndDate("2000년 1월 7일") + .recruitmentStartDate("2000년 6월 1일") + .recruitmentEndDate("2000년 6월 30일") + .detailUrl("https://example.com/1") + .build(); + + InfoPostReq infoPostReq2 = InfoPostReq.builder() + .title("공고 2") + .content("내용 2") + .organization("조직 2") + .categoryId(5) + .activityStartDate("2000년 2월 1일") + .activityEndDate("2000년 2월 7일") + .recruitmentStartDate("2000년 7월 1일") + .recruitmentEndDate("2000년 7월 31일") + .detailUrl("https://example.com/2") + .build(); + + InfoPostReq infoPostReq3 = InfoPostReq.builder() + .title("공고 3") + .content("내용 3") + .organization("조직 3") + .categoryId(6) + .activityStartDate("2000년 3월 1일") + .activityEndDate("2000년 3월 7일") + .recruitmentStartDate("2000년 8월 1일") + .recruitmentEndDate("2000년 8월 31일") + .detailUrl("https://example.com/3") + .build(); + + InfoPost infoPost1 = createInfoPost(infoPostReq1, author, image, category); + InfoPost infoPost2 = createInfoPost(infoPostReq2, author, image, category); + InfoPost infoPost3 = createInfoPost(infoPostReq3, author, image, category); + + return List.of(infoPost1, infoPost2, infoPost3); + } +} diff --git a/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java b/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java index f44812d..afef3fb 100644 --- a/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java +++ b/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java @@ -1,8 +1,11 @@ package com.dissonance.itit.service; import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -10,19 +13,22 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; import com.dissonance.itit.domain.entity.Category; import com.dissonance.itit.domain.entity.Image; import com.dissonance.itit.domain.entity.InfoPost; import com.dissonance.itit.domain.entity.User; import com.dissonance.itit.domain.enums.Directory; -import com.dissonance.itit.domain.enums.Role; +import com.dissonance.itit.dto.common.PositionInfo; import com.dissonance.itit.dto.request.InfoPostReq; import com.dissonance.itit.dto.response.InfoPostCreateRes; +import com.dissonance.itit.dto.response.InfoPostDetailRes; +import com.dissonance.itit.fixture.TestFixture; import com.dissonance.itit.repository.InfoPostRepository; +import com.dissonance.itit.repository.InfoPostRepositorySupport; @ExtendWith(MockitoExtension.class) public class InfoPostServiceTest { @@ -32,6 +38,8 @@ public class InfoPostServiceTest { @Mock private InfoPostRepository infoPostRepository; @Mock + private InfoPostRepositorySupport infoPostRepositorySupport; + @Mock private ImageService imageService; @Mock private CategoryService categoryService; @@ -39,15 +47,15 @@ public class InfoPostServiceTest { private RecruitmentPositionService recruitmentPositionService; @Test - @DisplayName("공고를 생성한다.") + @DisplayName("공고 생성 성공") public void createInfoPost_returnInfoPostCreateRes() { - // given - MultipartFile imgFile = getMockMultipartFile(); - InfoPostReq infoPostReq = createInfoPostReq(); - User author = createUser(); - Image image = createImage(); - Category category = createCategory(); - InfoPost infoPost = createInfoPost(infoPostReq, author, image, category); + // given + MockMultipartFile imgFile = TestFixture.getMockMultipartFile(); + InfoPostReq infoPostReq = TestFixture.createInfoPostReq(); + User author = TestFixture.createUser(); + Image image = TestFixture.createImage(); + Category category = TestFixture.createCategory(); + InfoPost infoPost = TestFixture.createInfoPost(infoPostReq, author, image, category); InfoPostCreateRes expectedResponse = InfoPostCreateRes.of(infoPost); given(imageService.upload(Directory.INFORMATION, imgFile)).willReturn(image); @@ -61,65 +69,98 @@ public void createInfoPost_returnInfoPostCreateRes() { assertThat(actualResponse).isEqualTo(expectedResponse); verify(imageService).upload(Directory.INFORMATION, imgFile); verify(categoryService).findById(infoPostReq.categoryId()); - verify(infoPostRepository).save(any()); + verify(infoPostRepository).save(any(InfoPost.class)); verify(recruitmentPositionService).addPositions(infoPost, infoPostReq.positionInfos()); } - - // TODO: test fixture class 분리 - private static MockMultipartFile getMockMultipartFile() { - return new MockMultipartFile( - "대표 썸네일 이미지", - "thumbnail.png", - MediaType.IMAGE_PNG_VALUE, - "thumbnail".getBytes() - ); - } - - private InfoPostReq createInfoPostReq() { - return InfoPostReq.builder() - .title("공고공고") - .content("내용내용") - .organization("ddd") - .categoryId(4) - .activityStartDate("2000년 2월 2일") - .activityEndDate("2000년 2월 9일") - .recruitmentStartDate("2000년 8월 1일") - .recruitmentEndDate("2000년 12월 31일") - .detailUrl("https://www.naver.com/") - .build(); + @Test + @DisplayName("공고 상세 조회 성공") + void getInfoPostDetailById_returnInfoPostDetailRes() { + // given + Long infoPostId = 1L; + InfoPostDetailRes.InfoPostInfo infoPostInfo = new InfoPostDetailRes.InfoPostInfo( + "Title", "Category", "Organization", + LocalDate.now(), LocalDate.now().plusDays(5), + LocalDate.now(), LocalDate.now().plusMonths(1), + "Content", "www.detailUrl.com", 100, false); + + List positionInfos = TestFixture.createMultiplePositionInfos(); + + given(infoPostRepositorySupport.findById(infoPostId)).willReturn(infoPostInfo); + given(recruitmentPositionService.findPositionInfosByInfoPostId(infoPostId)).willReturn(positionInfos); + + // when + InfoPostDetailRes result = infoPostService.getInfoPostDetailById(infoPostId); + + // then + assertThat(result.getTitle()).isEqualTo(infoPostInfo.getTitle()); + assertThat(result.getContent()).isEqualTo(infoPostInfo.getContent()); + assertThat(result.getPositionInfos()).isEqualTo(positionInfos); } - private User createUser() { - return User.builder() - .id(1L) - .name("김홍돌") - .role(Role.ADMIN) - .build(); + @Test + @DisplayName("공고 상세 조회시 존재하지 않는 ID로 조회하여 exception 발생") + void getInfoPostDetailById_throwCustomException_givenNonExistentId() { + // given + Long infoPostId = 999L; + given(infoPostRepositorySupport.findById(infoPostId)).willReturn(null); + + // when & then + assertThatThrownBy(() -> infoPostService.getInfoPostDetailById(infoPostId)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.NON_EXISTENT_INFO_POST_ID.getMessage()); } - private Image createImage() { - return Image.builder() - .id(5L) - .build(); + @Test + @DisplayName("공고 상세 조회시 신고된 않는 ID로 조회하여 exception 발생") + void getInfoPostDetailById_throwCustomException_givenReportedInfoPostId() { + // given + Long infoPostId = 1L; + InfoPostDetailRes.InfoPostInfo reportedInfoPost = new InfoPostDetailRes.InfoPostInfo( + "Title", "Category", "Organization", + LocalDate.now(), LocalDate.now().plusDays(5), + LocalDate.now(), LocalDate.now().plusMonths(1), + "Content", "www.detailUrl.com", 100, true); + + given(infoPostRepositorySupport.findById(infoPostId)).willReturn(reportedInfoPost); + + // when & then + assertThatThrownBy(() -> infoPostService.getInfoPostDetailById(infoPostId)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.REPORTED_INFO_POST_ID.getMessage()); } - private Category createCategory() { - return Category.builder() - .id(2) - .name("해커톤") - .build(); + @Test + @DisplayName("게시글 신고") + void reportedInfoPost_returnInfoPostId() { + // given + Long infoPostId = 1L; + InfoPostReq infoPostReq = TestFixture.createInfoPostReq(); + User author = TestFixture.createUser(); + Image image = TestFixture.createImage(); + Category category = TestFixture.createCategory(); + InfoPost infoPost = TestFixture.createInfoPost(infoPostReq, author, image, category); + + given(infoPostRepository.findById(infoPostId)).willReturn(Optional.of(infoPost)); + + // when + Long result = infoPostService.reportedInfoPost(infoPostId); + + // then + assertThat(result).isEqualTo(infoPostId); + verify(infoPostRepository).findById(infoPostId); } - private InfoPost createInfoPost(InfoPostReq infoPostReq, User author, Image image, Category category) { - return InfoPost.builder() - .title(infoPostReq.title()) - .content(infoPostReq.content()) - .organization(infoPostReq.organization()) - .detailUrl(infoPostReq.detailUrl()) - .image(image) - .category(category) - .author(author) - .build(); + @Test + @DisplayName("게시글 신고시 존재하지 않는 ID로 조회하여 exception 발생") + void reportedInfoPost_throwCustomException_givenNonExistentId() { + // given + Long infoPostId = 999L; + given(infoPostRepository.findById(infoPostId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> infoPostService.reportedInfoPost(infoPostId)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.NON_EXISTENT_INFO_POST_ID.getMessage()); } }