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 3df76a5..342da3c 100644 --- a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java +++ b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java @@ -17,6 +17,7 @@ public enum ErrorCode { // 403 UNAUTHORIZED_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "권한없는 Refresh Token입니다."), + NO_INFO_POST_UPDATE_PERMISSION(HttpStatus.FORBIDDEN, "게시글 수정 권한이 없습니다."), // 404 NON_EXISTENT_USER_ID(HttpStatus.NOT_FOUND, "해당 id의 사용자가 존재하지 않습니다."), 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 9b742e9..c9c467f 100644 --- a/src/main/java/com/dissonance/itit/common/util/DateUtil.java +++ b/src/main/java/com/dissonance/itit/common/util/DateUtil.java @@ -9,6 +9,10 @@ public class DateUtil { public static LocalDate stringToDate(String dateString) { + if (dateString == null) { + return null; + } + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 M월 d일"); try { return LocalDate.parse(dateString, formatter); @@ -22,6 +26,9 @@ public static String formatPeriod(LocalDate startDate, LocalDate endDate) { } public static String formatDate(LocalDate date) { + if (date == null) + return ""; + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"); return date.format(formatter); } diff --git a/src/main/java/com/dissonance/itit/config/SecurityConfig.java b/src/main/java/com/dissonance/itit/config/SecurityConfig.java index f6739d3..15c87f0 100644 --- a/src/main/java/com/dissonance/itit/config/SecurityConfig.java +++ b/src/main/java/com/dissonance/itit/config/SecurityConfig.java @@ -44,10 +44,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { ) .authorizeHttpRequests(authorizeRequests -> authorizeRequests - .requestMatchers("/oauth/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() - .requestMatchers(HttpMethod.POST, "/info-posts").hasRole("ADMIN") + .requestMatchers("/oauth/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/info-posts/**", + "/featured-posts/**").permitAll() + .requestMatchers("/admin/info-posts/**").hasRole("ADMIN") .requestMatchers(HttpMethod.PATCH, "/info-posts/{infoPostId}/reports").authenticated() - .requestMatchers("/info-posts/**", "/featured-posts/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/com/dissonance/itit/config/SwaggerConfig.java b/src/main/java/com/dissonance/itit/config/SwaggerConfig.java index 1fbf636..351c51d 100644 --- a/src/main/java/com/dissonance/itit/config/SwaggerConfig.java +++ b/src/main/java/com/dissonance/itit/config/SwaggerConfig.java @@ -1,43 +1,44 @@ package com.dissonance.itit.config; +import java.util.Collections; +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.util.Collections; -import java.util.List; @Configuration public class SwaggerConfig { - @Bean - public OpenAPI openAPI(){ - Info info = new Info() - .title("ITIT API Document") - .version("v1.0") - .description("ITIT API 문서입니다."); - - Server prodServer = new Server(); - prodServer.description("Production Server") - .url("https://dissonance-server.duckdns.org/api/v1"); - - Server devServer = new Server(); - devServer.description("Development Server") - .setUrl("http://localhost:8080/api/v1"); - - SecurityScheme securityScheme = new SecurityScheme() - .type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT") - .in(SecurityScheme.In.HEADER).name("Authorization"); - SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth"); - - return new OpenAPI() - .info(info) - .servers(List.of(prodServer, devServer)) - .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) - .security(Collections.singletonList(securityRequirement)); - } + @Bean + public OpenAPI openAPI() { + Info info = new Info() + .title("Mozip API Document") + .version("v1.0") + .description("Mozip API 문서입니다."); + + Server prodServer = new Server(); + prodServer.description("Production Server") + .url("https://dissonance-server.duckdns.org/api/v1"); + + Server devServer = new Server(); + devServer.description("Development Server") + .setUrl("http://localhost:8080/api/v1"); + + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT") + .in(SecurityScheme.In.HEADER).name("Authorization"); + SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth"); + + return new OpenAPI() + .info(info) + .servers(List.of(prodServer, devServer)) + .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) + .security(Collections.singletonList(securityRequirement)); + } } diff --git a/src/main/java/com/dissonance/itit/controller/AdminInfoPostController.java b/src/main/java/com/dissonance/itit/controller/AdminInfoPostController.java new file mode 100644 index 0000000..c48b540 --- /dev/null +++ b/src/main/java/com/dissonance/itit/controller/AdminInfoPostController.java @@ -0,0 +1,65 @@ +package com.dissonance.itit.controller; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.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.common.annotation.CurrentUser; +import com.dissonance.itit.common.util.ApiResponse; +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.dto.response.InfoPostUpdateRes; +import com.dissonance.itit.service.InfoPostService; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin/info-posts") +public class AdminInfoPostController { + private final InfoPostService infoPostService; + + @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) + @Operation(summary = "공고 게시글 등록", description = "공고 게시글을 등록합니다.") + public ApiResponse createInfoPost(@RequestPart MultipartFile imgFile, + @Valid @RequestPart InfoPostReq infoPostReq, @CurrentUser User loginUser) { + InfoPostCreateRes infoPostCreateRes = infoPostService.createInfoPost(imgFile, infoPostReq, loginUser); + return ApiResponse.success(infoPostCreateRes); + } + + @DeleteMapping("/{infoPostId}") + @Operation(summary = "공고 게시글 삭제", description = "공고 게시글을 삭제합니다.") + public ApiResponse deleteInfoPost(@PathVariable Long infoPostId) { + infoPostService.deleteInfoPostById(infoPostId); + return ApiResponse.success("게시글 삭제 성공"); + } + + @GetMapping("/{infoPostId}") + @Operation(summary = "공고 수정 - 게시글 조회", description = "공고 게시글 수정 페이지에 띄울 상세 정보를 조회합니다.") + public ApiResponse getInfoPostDetailByIdForUpdate(@PathVariable Long infoPostId) { + InfoPostUpdateRes infoPostUpdateRes = infoPostService.getInfoPostDetailByIdForUpdate(infoPostId); + return ApiResponse.success(infoPostUpdateRes); + } + + @PutMapping(value = "/{infoPostId}", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, + MediaType.APPLICATION_JSON_VALUE}) + @Operation(summary = "공고 게시글 수정", description = "공고 게시글을 수정합니다.") + public ApiResponse updateInfoPost(@PathVariable Long infoPostId, + @RequestPart(required = false) MultipartFile imgFile, + @Valid @RequestPart InfoPostReq infoPostReq, @CurrentUser User loginUser) { + InfoPostDetailRes infoPostDetailRes = infoPostService.updateInfoPost(infoPostId, imgFile, infoPostReq, + loginUser); + return ApiResponse.success(infoPostDetailRes); + } +} diff --git a/src/main/java/com/dissonance/itit/controller/InfoPostController.java b/src/main/java/com/dissonance/itit/controller/InfoPostController.java index 90c889e..a1d7fd9 100644 --- a/src/main/java/com/dissonance/itit/controller/InfoPostController.java +++ b/src/main/java/com/dissonance/itit/controller/InfoPostController.java @@ -2,28 +2,21 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.http.MediaType; 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; -import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; import com.dissonance.itit.common.annotation.CurrentUser; import com.dissonance.itit.common.util.ApiResponse; 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.dto.response.InfoPostRes; import com.dissonance.itit.service.InfoPostService; import com.dissonance.itit.service.ReportService; import io.swagger.v3.oas.annotations.Operation; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -33,14 +26,6 @@ public class InfoPostController { private final InfoPostService infoPostService; private final ReportService reportService; - @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) - @Operation(summary = "공고 게시글 등록", description = "공고 게시글을 등록합니다.") - public ApiResponse createInfoPost(@RequestPart MultipartFile imgFile, - @Valid @RequestPart InfoPostReq infoPostReq, @CurrentUser User loginUser) { - InfoPostCreateRes infoPostCreateRes = infoPostService.createInfoPost(imgFile, infoPostReq, loginUser); - return ApiResponse.success(infoPostCreateRes); - } - @GetMapping("/{infoPostId}") @Operation(summary = "공고 게시글 조회", description = "공고 게시글을 상세 조회합니다.") public ApiResponse getInfoPostDetail(@PathVariable Long infoPostId) { 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 1a59d9a..a19d00b 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/Image.java +++ b/src/main/java/com/dissonance/itit/domain/entity/Image.java @@ -1,10 +1,23 @@ package com.dissonance.itit.domain.entity; import com.dissonance.itit.domain.enums.Directory; -import jakarta.persistence.*; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import lombok.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Builder @@ -13,23 +26,26 @@ @Entity @Table(name = "image") public class Image { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Size(max = 500) + @NotNull + @Column(name = "image_url") + private String imageUrl; - @Size(max = 500) - @NotNull - @Column(name = "image_url") - private String imageUrl; + @Size(max = 255) + @NotNull + @Column(name = "convert_image_name") + private String convertImageName; - @Size(max = 255) - @NotNull - @Column(name = "convert_image_name") - private String convertImageName; + @Enumerated(EnumType.STRING) + @NotNull + @Column(name = "directory") + private Directory directory; - @Enumerated(EnumType.STRING) - @NotNull - @Column(name = "directory") - private Directory directory; + @OneToOne(mappedBy = "image") + private InfoPost infoPost; } \ No newline at end of file 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 3636506..5533bc1 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/InfoPost.java +++ b/src/main/java/com/dissonance/itit/domain/entity/InfoPost.java @@ -2,6 +2,8 @@ import java.time.LocalDate; +import com.dissonance.itit.dto.request.InfoPostUpdateReq; + import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -38,7 +40,8 @@ public class InfoPost extends BaseTime { @Column(name = "title") private String title; - @Size(max = 2000) + @NotNull + @Size(max = 4000) @Column(name = "content") private String content; @@ -46,19 +49,15 @@ public class InfoPost extends BaseTime { @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; @@ -67,7 +66,8 @@ public class InfoPost extends BaseTime { @Column(name = "detail_url") private String detailUrl; - @Size(max = 50) + @Size(max = 100) + @NotNull @Column(name = "organization") private String organization; @@ -77,7 +77,7 @@ public class InfoPost extends BaseTime { @Column(name = "recruitment_closed") private Boolean recruitmentClosed; - @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "image_id") private Image image; @@ -89,7 +89,23 @@ public class InfoPost extends BaseTime { @JoinColumn(name = "category_id") private Category category; - public void updateReported() { - this.reported = true; + public void updateImage(Image newImage) { + this.image = newImage; + } + + public void update(InfoPostUpdateReq updateReq) { + this.category = updateReq.category(); + this.title = updateReq.title(); + this.organization = updateReq.organization(); + this.content = updateReq.content(); + this.recruitmentStartDate = updateReq.recruitmentStartDate(); + this.recruitmentEndDate = updateReq.recruitmentEndDate(); + this.activityStartDate = updateReq.activityStartDate(); + this.activityEndDate = updateReq.activityEndDate(); + this.detailUrl = updateReq.detailUrl(); + } + + public boolean isAuthor(User loginUser) { + return this.getAuthor().getId().equals(loginUser.getId()); } } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/domain/entity/RecruitmentPosition.java b/src/main/java/com/dissonance/itit/domain/entity/RecruitmentPosition.java index 3f0c9fb..104fded 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/RecruitmentPosition.java +++ b/src/main/java/com/dissonance/itit/domain/entity/RecruitmentPosition.java @@ -1,9 +1,21 @@ package com.dissonance.itit.domain.entity; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import lombok.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Builder @@ -12,22 +24,18 @@ @Entity @Table(name = "recruitment_position") public class RecruitmentPosition { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private Integer id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Integer id; - @NotNull - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "info_post_id") - private InfoPost infoPost; + @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; + @Size(max = 50) + @NotNull + @Column(name = "name") + private String name; } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/dto/common/PositionInfo.java b/src/main/java/com/dissonance/itit/dto/common/PositionInfo.java deleted file mode 100644 index 59959ad..0000000 --- a/src/main/java/com/dissonance/itit/dto/common/PositionInfo.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.dissonance.itit.dto.common; - -public record PositionInfo( - String positionName, - int 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 beda5ee..8bd337e 100644 --- a/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java +++ b/src/main/java/com/dissonance/itit/dto/request/InfoPostReq.java @@ -8,7 +8,6 @@ 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; @@ -23,21 +22,19 @@ public record InfoPostReq( @NotNull(message = "카테고리 id는 필수 입력입니다.") @Schema(description = "공고 카테고리 id", example = "3") Integer categoryId, + @NotBlank(message = "모집 기관, 단체는 필수 입력입니다.") @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 = "활동 시작일은 필수 입력입니다.") + List positionInfos, @Schema(description = "활동 시작 일자", example = "2024년 10월 1일") String activityStartDate, - @NotBlank(message = "활동 종료일은 필수 입력입니다.") @Schema(description = "활동 종료 일자", example = "2024년 12월 31일") String activityEndDate, + @NotBlank(message = "활동 내용은 필수 입력입니다.") @Schema(description = "활동 내용", example = "여러분의 창의력과 디자인 역량을 발휘해볼 특별한 기회를 놓치지 마세요") String content, @NotBlank(message = "공고 url은 필수 입력입니다.") diff --git a/src/main/java/com/dissonance/itit/dto/request/InfoPostUpdateReq.java b/src/main/java/com/dissonance/itit/dto/request/InfoPostUpdateReq.java new file mode 100644 index 0000000..4a98b7a --- /dev/null +++ b/src/main/java/com/dissonance/itit/dto/request/InfoPostUpdateReq.java @@ -0,0 +1,36 @@ +package com.dissonance.itit.dto.request; + +import static com.dissonance.itit.common.util.DateUtil.*; + +import java.time.LocalDate; + +import com.dissonance.itit.domain.entity.Category; + +import lombok.Builder; + +@Builder +public record InfoPostUpdateReq( + Category category, + String title, + String organization, + String content, + LocalDate recruitmentStartDate, + LocalDate recruitmentEndDate, + LocalDate activityStartDate, + LocalDate activityEndDate, + String detailUrl +) { + public static InfoPostUpdateReq from(Category category, InfoPostReq req) { + return InfoPostUpdateReq.builder() + .category(category) + .title(req.title()) + .organization(req.organization()) + .content(req.content()) + .recruitmentStartDate(stringToDate(req.recruitmentStartDate())) + .recruitmentEndDate(stringToDate(req.recruitmentEndDate())) + .activityStartDate(stringToDate(req.activityStartDate())) + .activityEndDate(stringToDate(req.activityEndDate())) + .detailUrl(req.detailUrl()) + .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 34bf5c8..924f7fd 100644 --- a/src/main/java/com/dissonance/itit/dto/response/InfoPostDetailRes.java +++ b/src/main/java/com/dissonance/itit/dto/response/InfoPostDetailRes.java @@ -5,7 +5,7 @@ import java.time.LocalDate; import java.util.List; -import com.dissonance.itit.dto.common.PositionInfo; +import com.dissonance.itit.domain.entity.InfoPost; import lombok.AllArgsConstructor; import lombok.Builder; @@ -18,7 +18,7 @@ public class InfoPostDetailRes { private final String categoryName; private final String organization; private final String RecruitmentPeriod; - private final List positionInfos; + private final List positionInfos; private final String activityPeriod; private final String content; private final String detailUrl; @@ -42,20 +42,37 @@ public static class InfoPostInfo { private final String imageUrl; } - public static InfoPostDetailRes of(InfoPostInfo infoPostInfo, List positionInfos) { + public static InfoPostDetailRes of(InfoPostInfo infoPostInfo, List positionInfos) { return InfoPostDetailRes.builder() .title(infoPostInfo.getTitle()) .categoryName(infoPostInfo.getCategoryName()) - .organization(infoPostInfo.getOrganization() == null ? "" : infoPostInfo.getOrganization()) + .organization(infoPostInfo.getOrganization()) .RecruitmentPeriod( formatPeriod(infoPostInfo.getRecruitmentStartDate(), infoPostInfo.getRecruitmentEndDate())) .positionInfos(positionInfos) .activityPeriod( formatPeriod(infoPostInfo.getActivityStartDate(), infoPostInfo.getActivityEndDate())) - .content(infoPostInfo.getContent() == null ? "" : infoPostInfo.getContent()) + .content(infoPostInfo.getContent()) .detailUrl(infoPostInfo.getDetailUrl()) .imageUrl(infoPostInfo.getImageUrl()) .viewCount(infoPostInfo.getViewCount()) .build(); } + + public static InfoPostDetailRes of(InfoPost infoPost, List positionInfos) { + return InfoPostDetailRes.builder() + .title(infoPost.getTitle()) + .categoryName(infoPost.getCategory().getName()) + .organization(infoPost.getOrganization()) + .RecruitmentPeriod( + formatPeriod(infoPost.getRecruitmentStartDate(), infoPost.getRecruitmentEndDate())) + .positionInfos(positionInfos) + .activityPeriod( + formatPeriod(infoPost.getActivityStartDate(), infoPost.getActivityEndDate())) + .content(infoPost.getContent()) + .detailUrl(infoPost.getDetailUrl()) + .imageUrl(infoPost.getImage().getImageUrl()) + .viewCount(infoPost.getViewCount()) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/dto/response/InfoPostUpdateRes.java b/src/main/java/com/dissonance/itit/dto/response/InfoPostUpdateRes.java new file mode 100644 index 0000000..ae8683f --- /dev/null +++ b/src/main/java/com/dissonance/itit/dto/response/InfoPostUpdateRes.java @@ -0,0 +1,68 @@ +package com.dissonance.itit.dto.response; + +import static com.dissonance.itit.common.util.DateUtil.*; + +import java.time.LocalDate; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class InfoPostUpdateRes { + @Schema(description = "제목", example = "공모전1") + private String title; + @Schema(description = "공고 카테고리 id", example = "3") + private Integer categoryId; + @Schema(description = "모집 기관 or 단체", example = "DDD") + private String organization; + @Schema(description = "모집 시작 일자", example = "2024년 8월 10일") + private String recruitmentStartDate; + @Schema(description = "모집 종료 일자", example = "2024년 8월 18일") + private String recruitmentEndDate; + private List positionInfos; + @Schema(description = "활동 시작 일자", example = "2024년 10월 1일") + private String activityStartDate; + @Schema(description = "활동 종료 일자", example = "2024년 12월 31일") + private String activityEndDate; + @Schema(description = "활동 내용", example = "여러분의 창의력과 디자인 역량을 발휘해볼 특별한 기회를 놓치지 마세요") + private String content; + @Schema(description = "공고 url", example = "https://www.google.com/") + private String detailUrl; + @Schema(description = "이메일 url", example = "https://www.s3.bucket/") + private String imageUrl; + + @Getter + @AllArgsConstructor + public static class InfoPostInfo { + private String title; + private Integer categoryId; + private String organization; + private LocalDate recruitmentStartDate; + private LocalDate recruitmentEndDate; + private LocalDate activityStartDate; + private LocalDate activityEndDate; + private String content; + private String detailUrl; + private String imageUrl; + } + + public static InfoPostUpdateRes of(InfoPostUpdateRes.InfoPostInfo infoPostInfo, List positionInfos) { + return InfoPostUpdateRes.builder() + .title(infoPostInfo.getTitle()) + .categoryId(infoPostInfo.getCategoryId()) + .organization(infoPostInfo.getOrganization()) + .recruitmentStartDate(formatDate(infoPostInfo.getRecruitmentStartDate())) + .recruitmentEndDate(formatDate(infoPostInfo.getRecruitmentEndDate())) + .positionInfos(positionInfos) + .activityStartDate(formatDate(infoPostInfo.getActivityStartDate())) + .activityEndDate(formatDate(infoPostInfo.getActivityEndDate())) + .content(infoPostInfo.getContent()) + .detailUrl(infoPostInfo.getDetailUrl()) + .imageUrl(infoPostInfo.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java b/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java index 03001b8..9cf9dfa 100644 --- a/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java +++ b/src/main/java/com/dissonance/itit/repository/InfoPostRepositorySupport.java @@ -13,6 +13,7 @@ import com.dissonance.itit.domain.entity.QInfoPost; import com.dissonance.itit.dto.response.InfoPostDetailRes.InfoPostInfo; import com.dissonance.itit.dto.response.InfoPostRes; +import com.dissonance.itit.dto.response.InfoPostUpdateRes; import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; @@ -29,7 +30,7 @@ public class InfoPostRepositorySupport { private final JPAQueryFactory jpaQueryFactory; private final QInfoPost infoPost = QInfoPost.infoPost; - public InfoPostInfo findById(Long infoPostId) { + public InfoPostInfo findInfoPostWithDetails(Long infoPostId) { incrementViewCount(infoPostId); return jpaQueryFactory.select(Projections.constructor(InfoPostInfo.class, @@ -136,4 +137,22 @@ private BooleanExpression createCondition(Integer categoryId) { return condition; } + + public InfoPostUpdateRes.InfoPostInfo findInfoPostForUpdate(Long infoPostId) { + return jpaQueryFactory.select(Projections.constructor(InfoPostUpdateRes.InfoPostInfo.class, + infoPost.title.as("title"), + infoPost.category.id.as("categoryId"), + 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.image.imageUrl.as("imageUrl") + )) + .from(infoPost) + .where(infoPost.id.eq(infoPostId)) + .fetchOne(); + } } diff --git a/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepository.java b/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepository.java index 2875709..99f66a7 100644 --- a/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepository.java +++ b/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepository.java @@ -1,7 +1,9 @@ package com.dissonance.itit.repository; -import com.dissonance.itit.domain.entity.RecruitmentPosition; import org.springframework.data.jpa.repository.JpaRepository; +import com.dissonance.itit.domain.entity.RecruitmentPosition; + public interface RecruitmentPositionRepository extends JpaRepository { + void deleteByInfoPostId(Long infoPostId); } diff --git a/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepositorySupport.java b/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepositorySupport.java index dc8b757..4aa40c1 100644 --- a/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepositorySupport.java +++ b/src/main/java/com/dissonance/itit/repository/RecruitmentPositionRepositorySupport.java @@ -5,8 +5,6 @@ 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; @@ -17,11 +15,8 @@ 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") - )) + public List findByInfoPostId(Long infoPostId) { + return jpaQueryFactory.select(recruitmentPosition.name) .from(recruitmentPosition) .where(recruitmentPosition.infoPost.id.eq(infoPostId)) .fetch(); diff --git a/src/main/java/com/dissonance/itit/service/ImageService.java b/src/main/java/com/dissonance/itit/service/ImageService.java index a961e8b..75e55b6 100644 --- a/src/main/java/com/dissonance/itit/service/ImageService.java +++ b/src/main/java/com/dissonance/itit/service/ImageService.java @@ -1,5 +1,16 @@ package com.dissonance.itit.service; +import static com.dissonance.itit.common.exception.ErrorCode.*; + +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +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 com.amazonaws.services.s3.AmazonS3Client; import com.amazonaws.services.s3.model.CannedAccessControlList; import com.amazonaws.services.s3.model.ObjectMetadata; @@ -8,110 +19,103 @@ 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.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @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)); - } + 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/java/com/dissonance/itit/service/InfoPostService.java b/src/main/java/com/dissonance/itit/service/InfoPostService.java index bfe6971..ec54880 100644 --- a/src/main/java/com/dissonance/itit/service/InfoPostService.java +++ b/src/main/java/com/dissonance/itit/service/InfoPostService.java @@ -15,12 +15,13 @@ 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.request.InfoPostUpdateReq; 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.InfoPostRes; +import com.dissonance.itit.dto.response.InfoPostUpdateRes; import com.dissonance.itit.repository.InfoPostRepository; import com.dissonance.itit.repository.InfoPostRepositorySupport; @@ -50,7 +51,7 @@ public InfoPostCreateRes createInfoPost(MultipartFile imgFile, InfoPostReq infoP @Transactional(readOnly = true) public InfoPostDetailRes getInfoPostDetailById(Long infoPostId) { - InfoPostInfo infoPostInfo = infoPostRepositorySupport.findById(infoPostId); + InfoPostInfo infoPostInfo = infoPostRepositorySupport.findInfoPostWithDetails(infoPostId); if (infoPostInfo == null) { throw new CustomException(ErrorCode.NON_EXISTENT_INFO_POST_ID); @@ -60,8 +61,7 @@ public InfoPostDetailRes getInfoPostDetailById(Long infoPostId) { throw new CustomException(ErrorCode.REPORTED_INFO_POST_ID); } - List positionInfos = recruitmentPositionService.findPositionInfosByInfoPostId( - infoPostId); + List positionInfos = recruitmentPositionService.findPositionInfosByInfoPostId(infoPostId); return InfoPostDetailRes.of(infoPostInfo, positionInfos); } @@ -76,4 +76,58 @@ public InfoPost findById(Long infoPostId) { public Page getInfoPostsByCategoryId(Integer categoryId, Pageable pageable) { return infoPostRepositorySupport.findInfoPostsByCategoryId(categoryId, pageable); } + + @Transactional + public void deleteInfoPostById(Long infoPostId) { + InfoPost infoPost = findById(infoPostId); + imageService.delete(infoPost.getImage()); + + infoPostRepository.deleteById(infoPostId); + } + + public InfoPostUpdateRes getInfoPostDetailByIdForUpdate(Long infoPostId) { + InfoPostUpdateRes.InfoPostInfo infoPostInfo = infoPostRepositorySupport.findInfoPostForUpdate(infoPostId); + + if (infoPostInfo == null) { + throw new CustomException(ErrorCode.NON_EXISTENT_INFO_POST_ID); + } + + List positionInfos = recruitmentPositionService.findPositionInfosByInfoPostId( + infoPostId); + + return InfoPostUpdateRes.of(infoPostInfo, positionInfos); + } + + @Transactional + public InfoPostDetailRes updateInfoPost(Long infoPostId, MultipartFile imgFile, InfoPostReq infoPostReq, + User loginUser) { + InfoPost infoPost = findById(infoPostId); + + validateAuthor(infoPost, loginUser); + + Category category = infoPost.getCategory().getId().equals(infoPostReq.categoryId()) + ? infoPost.getCategory() + : categoryService.findById(infoPostReq.categoryId()); + + InfoPostUpdateReq updateReq = InfoPostUpdateReq.from(category, infoPostReq); + infoPost.update(updateReq); + + recruitmentPositionService.updatePositions(infoPost, infoPostReq.positionInfos()); + List positionInfos = recruitmentPositionService.findPositionInfosByInfoPostId(infoPostId); + + // TODO: S3 Transaction 처리 (데이터 정합성) + if (imgFile != null && !imgFile.isEmpty()) { + imageService.delete(infoPost.getImage()); + Image newImage = imageService.upload(Directory.INFORMATION, imgFile); + infoPost.updateImage(newImage); + } + + return InfoPostDetailRes.of(infoPost, positionInfos); + } + + private void validateAuthor(InfoPost infoPost, User loginUser) { + if (!infoPost.isAuthor(loginUser)) { + throw new CustomException(ErrorCode.NO_INFO_POST_UPDATE_PERMISSION); + } + } } diff --git a/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java b/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java index c9641a1..f25d5af 100644 --- a/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java +++ b/src/main/java/com/dissonance/itit/service/RecruitmentPositionService.java @@ -7,7 +7,6 @@ import com.dissonance.itit.domain.entity.InfoPost; import com.dissonance.itit.domain.entity.RecruitmentPosition; -import com.dissonance.itit.dto.common.PositionInfo; import com.dissonance.itit.repository.RecruitmentPositionRepository; import com.dissonance.itit.repository.RecruitmentPositionRepositorySupport; @@ -20,18 +19,25 @@ public class RecruitmentPositionService { 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); - }); + public void addPositions(InfoPost infoPost, List positionInfos) { + List recruitmentPositions = positionInfos.stream() + .map(positionInfo -> + RecruitmentPosition.builder() + .infoPost(infoPost) + .name(positionInfo) + .build() + ).toList(); + recruitmentPositionRepository.saveAll(recruitmentPositions); } - public List findPositionInfosByInfoPostId(Long infoPostId) { + public List findPositionInfosByInfoPostId(Long infoPostId) { return recruitmentPositionRepositorySupport.findByInfoPostId(infoPostId); } + + @Transactional + public void updatePositions(InfoPost infoPost, List positionInfos) { + // TODO: 전부 삭제 후 재생성 -> 기존 상태에 따라 infoPostId와 name이 같은 경우 그대로 두고, 삭제 혹은 생성된 경우만 처리하도록 변경 + recruitmentPositionRepository.deleteByInfoPostId(infoPost.getId()); + addPositions(infoPost, positionInfos); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4133967..af309a6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,7 +22,6 @@ spring: properties: hibernate: format_sql: true - use_sql_comments: true defer-datasource-initialization: true profiles: include: oauth diff --git a/src/test/java/com/dissonance/itit/fixture/TestFixture.java b/src/test/java/com/dissonance/itit/fixture/TestFixture.java index af9445d..248ec73 100644 --- a/src/test/java/com/dissonance/itit/fixture/TestFixture.java +++ b/src/test/java/com/dissonance/itit/fixture/TestFixture.java @@ -10,7 +10,6 @@ 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; import com.dissonance.itit.dto.response.InfoPostRes; @@ -46,6 +45,14 @@ public static User createUser() { .build(); } + public static User createAnotherUser() { + return User.builder() + .id(2L) + .name("김홍시") + .role(Role.USER) + .build(); + } + public static Image createImage() { return Image.builder() .id(5L) @@ -59,6 +66,13 @@ public static Category createCategory() { .build(); } + public static Category createAnotherCategory() { + return Category.builder() + .id(4) + .name("IT 동아리") + .build(); + } + public static InfoPost createInfoPost(InfoPostReq infoPostReq, User author, Image image, Category category) { return InfoPost.builder() .id(1L) @@ -72,11 +86,21 @@ public static InfoPost createInfoPost(InfoPostReq infoPostReq, User author, Imag .build(); } - public static List createMultiplePositionInfos() { + public static InfoPost createInfoPostWithImage(Image image) { + return InfoPost.builder() + .id(1L) + .title("Post 1") + .image(image) + .author(createUser()) + .category(createCategory()) + .build(); + } + + public static List createMultiplePositionInfos() { return List.of( - new PositionInfo("개발자", 0), - new PositionInfo("기획자", 1), - new PositionInfo("디자이너", 2) + "개발자 0명", + "기획자 1명", + "디자이너 2명" ); } diff --git a/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java b/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java index c36e350..a016a63 100644 --- a/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java +++ b/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java @@ -27,11 +27,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.InfoPostRes; +import com.dissonance.itit.dto.response.InfoPostUpdateRes; import com.dissonance.itit.fixture.TestFixture; import com.dissonance.itit.repository.InfoPostRepository; import com.dissonance.itit.repository.InfoPostRepositorySupport; @@ -65,7 +65,7 @@ public void createInfoPost_returnInfoPostCreateRes() { InfoPostCreateRes expectedResponse = InfoPostCreateRes.of(infoPost); given(imageService.upload(Directory.INFORMATION, imgFile)).willReturn(image); - given(categoryService.findById(anyInt())).willReturn(category); + given(categoryService.findById(anyInt())).willReturn(TestFixture.createAnotherCategory()); given(infoPostRepository.save(any())).willReturn(infoPost); // When @@ -88,11 +88,11 @@ void getInfoPostDetailById_returnInfoPostDetailRes() { "Title", "Category", "Organization", LocalDate.now(), LocalDate.now().plusDays(5), LocalDate.now(), LocalDate.now().plusMonths(1), - "Content", "www.detailUrl.com", 100, false); + "Content", "www.detailUrl.com", 100, false, "www.imageUrl.com"); - List positionInfos = TestFixture.createMultiplePositionInfos(); + List positionInfos = TestFixture.createMultiplePositionInfos(); - given(infoPostRepositorySupport.findById(infoPostId)).willReturn(infoPostInfo); + given(infoPostRepositorySupport.findInfoPostWithDetails(infoPostId)).willReturn(infoPostInfo); given(recruitmentPositionService.findPositionInfosByInfoPostId(infoPostId)).willReturn(positionInfos); // when @@ -109,7 +109,7 @@ void getInfoPostDetailById_returnInfoPostDetailRes() { void getInfoPostDetailById_throwCustomException_givenNonExistentId() { // given Long infoPostId = 999L; - given(infoPostRepositorySupport.findById(infoPostId)).willReturn(null); + given(infoPostRepositorySupport.findInfoPostWithDetails(infoPostId)).willReturn(null); // when & then assertThatThrownBy(() -> infoPostService.getInfoPostDetailById(infoPostId)) @@ -126,9 +126,9 @@ void getInfoPostDetailById_throwCustomException_givenReportedInfoPostId() { "Title", "Category", "Organization", LocalDate.now(), LocalDate.now().plusDays(5), LocalDate.now(), LocalDate.now().plusMonths(1), - "Content", "www.detailUrl.com", 100, true); + "Content", "www.detailUrl.com", 100, true, "www.imageUrl.com"); - given(infoPostRepositorySupport.findById(infoPostId)).willReturn(reportedInfoPost); + given(infoPostRepositorySupport.findInfoPostWithDetails(infoPostId)).willReturn(reportedInfoPost); // when & then assertThatThrownBy(() -> infoPostService.getInfoPostDetailById(infoPostId)) @@ -174,4 +174,146 @@ void findById_throwCustomException_givenNonExistentId() { .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.NON_EXISTENT_INFO_POST_ID.getMessage()); } + + @Test + @DisplayName("InfoPost 삭제 성공") + void deleteInfoPostById_success() { + // given + Long infoPostId = 1L; + Image image = TestFixture.createImage(); + InfoPost infoPost = TestFixture.createInfoPostWithImage(image); + + given(infoPostRepository.findById(infoPostId)).willReturn(Optional.of(infoPost)); + + // when + infoPostService.deleteInfoPostById(infoPostId); + + // then + verify(imageService).delete(infoPost.getImage()); + verify(infoPostRepository).deleteById(infoPostId); + } + + @Test + @DisplayName("존재하지 않는 ID로 InfoPost 삭제 시도시 CustomException 발생") + void deleteInfoPostById_throwCustomException_givenNonExistentId() { + // given + Long infoPostId = 999L; + given(infoPostRepository.findById(infoPostId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> infoPostService.deleteInfoPostById(infoPostId)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.NON_EXISTENT_INFO_POST_ID.getMessage()); + } + + @Test + @DisplayName("수정을 위한 공고 상세 조회 성공") + void getInfoPostDetailByIdForUpdate_returnInfoPostUpdateRes() { + // given + Long infoPostId = 1L; + InfoPostUpdateRes.InfoPostInfo infoPostInfo = new InfoPostUpdateRes.InfoPostInfo( + "Title", 2, "Organization", + LocalDate.now(), LocalDate.now().plusDays(5), + LocalDate.now(), LocalDate.now().plusMonths(1), + "Content", "www.detailUrl.com", "www.imgUrl.com"); + + List positionInfos = TestFixture.createMultiplePositionInfos(); + + given(infoPostRepositorySupport.findInfoPostForUpdate(infoPostId)).willReturn(infoPostInfo); + given(recruitmentPositionService.findPositionInfosByInfoPostId(infoPostId)).willReturn(positionInfos); + + // when + InfoPostUpdateRes result = infoPostService.getInfoPostDetailByIdForUpdate(infoPostId); + + // then + assertThat(result.getTitle()).isEqualTo(infoPostInfo.getTitle()); + assertThat(result.getContent()).isEqualTo(infoPostInfo.getContent()); + assertThat(result.getPositionInfos()).isEqualTo(positionInfos); + } + + @Test + @DisplayName("수정을 위한 공고 상세 조회시 존재하지 않는 ID로 조회하여 exception 발생") + void getInfoPostDetailByIdForUpdate_throwCustomException_givenNonExistentId() { + // given + Long infoPostId = 999L; + given(infoPostRepositorySupport.findInfoPostForUpdate(infoPostId)).willReturn(null); + + // when & then + assertThatThrownBy(() -> infoPostService.getInfoPostDetailByIdForUpdate(infoPostId)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.NON_EXISTENT_INFO_POST_ID.getMessage()); + } + + @Test + @DisplayName("공고 수정 성공 - 이미지 변경 있음") + void updateInfoPost_withNewImage_returnInfoPostDetailRes() { + // given + Long infoPostId = 1L; + MockMultipartFile imgFile = TestFixture.getMockMultipartFile(); + InfoPostReq infoPostReq = TestFixture.createInfoPostReq(); + User loginUser = TestFixture.createUser(); + Image oldImage = TestFixture.createImage(); + Image newImage = TestFixture.createImage(); + Category newCategory = TestFixture.createCategory(); + InfoPost infoPost = TestFixture.createInfoPostWithImage(oldImage); + + given(infoPostRepository.findById(infoPostId)).willReturn(Optional.of(infoPost)); + given(categoryService.findById(infoPostReq.categoryId())).willReturn(newCategory); + given(imageService.upload(Directory.INFORMATION, imgFile)).willReturn(newImage); + given(recruitmentPositionService.findPositionInfosByInfoPostId(infoPostId)) + .willReturn(TestFixture.createMultiplePositionInfos()); + + // when + InfoPostDetailRes result = infoPostService.updateInfoPost(infoPostId, imgFile, infoPostReq, loginUser); + + // then + assertThat(result).isNotNull(); + verify(imageService).delete(oldImage); + verify(imageService).upload(Directory.INFORMATION, imgFile); + verify(recruitmentPositionService).updatePositions(infoPost, infoPostReq.positionInfos()); + } + + @Test + @DisplayName("공고 수정 성공 - 이미지 변경 없음") + void updateInfoPost_withoutNewImage_returnInfoPostDetailRes() { + // given + Long infoPostId = 1L; + InfoPostReq infoPostReq = TestFixture.createInfoPostReq(); + User loginUser = TestFixture.createUser(); + Category sameCategory = TestFixture.createCategory(); + InfoPost infoPost = TestFixture.createInfoPost(infoPostReq, loginUser, TestFixture.createImage(), sameCategory); + + given(infoPostRepository.findById(infoPostId)).willReturn(Optional.of(infoPost)); + given(categoryService.findById(any())).willReturn(TestFixture.createCategory()); + given(recruitmentPositionService.findPositionInfosByInfoPostId(infoPostId)) + .willReturn(TestFixture.createMultiplePositionInfos()); + + // when + InfoPostDetailRes result = infoPostService.updateInfoPost(infoPostId, null, infoPostReq, loginUser); + + // then + assertThat(result).isNotNull(); + verify(imageService, never()).delete(any()); + verify(imageService, never()).upload(any(), any()); + verify(recruitmentPositionService).updatePositions(infoPost, infoPostReq.positionInfos()); + } + + @Test + @DisplayName("권한 없는 사용자의 공고 수정 시도시 exception 발생") + void updateInfoPost_throwCustomException_givenUnauthorizedUser() { + // given + Long infoPostId = 1L; + InfoPostReq infoPostReq = TestFixture.createInfoPostReq(); + User author = TestFixture.createUser(); + User unauthorizedUser = TestFixture.createAnotherUser(); + InfoPost infoPost = TestFixture.createInfoPost(infoPostReq, author, TestFixture.createImage(), + TestFixture.createCategory()); + + given(infoPostRepository.findById(infoPostId)).willReturn(Optional.of(infoPost)); + + // when & then + assertThatThrownBy(() -> infoPostService.updateInfoPost(infoPostId, null, infoPostReq, unauthorizedUser)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.NO_INFO_POST_UPDATE_PERMISSION.getMessage()); + } }