Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
64a12ed
feat: 소식지 생성 api 추가
Gyuhyeok99 Jun 12, 2025
43048bc
test: 소식지 생성 테스트 추가
Gyuhyeok99 Jun 12, 2025
b6316ee
refactor: @NotBlank 적용 및 검증 메시지 개선
Gyuhyeok99 Jun 12, 2025
657c0a9
feat: 소식지 수정 api 추가
Gyuhyeok99 Jun 12, 2025
af29b51
feat: NewsFixture에 News 생성 메서드 추가
Gyuhyeok99 Jun 12, 2025
958b57d
test: 소식지 수정 테스트 추가
Gyuhyeok99 Jun 12, 2025
e7b42af
feat: 소식지 삭제 api 추가
Gyuhyeok99 Jun 12, 2025
30f0b45
test: 소식지 삭제 테스트 추가
Gyuhyeok99 Jun 12, 2025
01864cc
refactor: NewsService → NewsCommandService로 이름 변경
Gyuhyeok99 Jun 12, 2025
75ad00d
feat: 소식지 목록 조회 api 추가
Gyuhyeok99 Jun 12, 2025
d370832
test: 소식지 목록 조회 테스트 추가
Gyuhyeok99 Jun 12, 2025
ea0bf83
feat: 소식지 조회 api 추가
Gyuhyeok99 Jun 12, 2025
780e1f3
test: 소식지 조회 테스트 추가
Gyuhyeok99 Jun 12, 2025
78c44e0
fix: 소식지 설명 길이 제한 메시지 오타 수정
Gyuhyeok99 Jun 12, 2025
a9c030b
fix: 소식지 수정 및 삭제 시 S3 이미지 삭제 시점 수정
Gyuhyeok99 Jun 12, 2025
6bfdd95
test: NewsCommandServiceTest public 제거
Gyuhyeok99 Jun 12, 2025
c792f2f
fix: 소식지 수정 시 S3 이미지 삭제 시점 수정
Gyuhyeok99 Jun 12, 2025
4764af0
refactor: 소식지 업데이트 시 개별 파라미터에서 DTO로 변경
Gyuhyeok99 Jun 20, 2025
0a13606
test: 소식지 업데이트 테스트 시 assertAll로 그룹화
Gyuhyeok99 Jun 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import org.springframework.http.HttpStatus;

import static com.example.solidconnection.application.service.ApplicationSubmissionService.APPLICATION_UPDATE_COUNT_LIMIT;
import static com.example.solidconnection.news.service.NewsCommandService.MAX_DESCRIPTION_LENGTH;
import static com.example.solidconnection.news.service.NewsCommandService.MAX_TITLE_LENGTH;
import static com.example.solidconnection.news.service.NewsCommandService.MAX_URL_LENGTH;
import static com.example.solidconnection.siteuser.service.MyPageService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES;

@Getter
Expand Down Expand Up @@ -42,6 +45,7 @@ public enum ErrorCode {
COUNTRY_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 국가를 찾을 수 없습니다."),
GPA_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학점입니다."),
LANGUAGE_TEST_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 어학성적입니다."),
NEWS_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 소식지입니다."),

// auth
USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."),
Expand Down Expand Up @@ -96,6 +100,13 @@ public enum ErrorCode {
USER_DO_NOT_HAVE_GPA(HttpStatus.BAD_REQUEST.value(), "해당 유저의 학점을 찾을 수 없음"),
REJECTED_REASON_REQUIRED(HttpStatus.BAD_REQUEST.value(), "거절 사유가 필요합니다."),

// news
NEWS_TITLE_TOO_LONG(HttpStatus.BAD_REQUEST.value(), "소식지 제목은 " + MAX_TITLE_LENGTH + "자 이하여야 합니다."),
NEWS_TITLE_EMPTY(HttpStatus.BAD_REQUEST.value(), "소식지 제목은 빈 값일 수 없습니다."),
NEWS_DESCRIPTION_TOO_LONG(HttpStatus.BAD_REQUEST.value(), "소식지 설명은 " + MAX_DESCRIPTION_LENGTH + "자 이하여야 합니다."),
NEWS_URL_INVALID(HttpStatus.BAD_REQUEST.value(), "올바른 URL 형식이 아닙니다."),
NEWS_URL_TOO_LONG(HttpStatus.BAD_REQUEST.value(), "소식지 URL은 " + MAX_URL_LENGTH + "자 이하여야 합니다."),

// general
JSON_PARSING_FAILED(HttpStatus.BAD_REQUEST.value(), "JSON 파싱을 할 수 없습니다."),
JWT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "JWT 토큰을 처리할 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.example.solidconnection.news.controller;

import com.example.solidconnection.common.resolver.AuthorizedUser;
import com.example.solidconnection.news.dto.NewsCreateRequest;
import com.example.solidconnection.news.dto.NewsCommandResponse;
import com.example.solidconnection.news.dto.NewsFindResponse;
import com.example.solidconnection.news.dto.NewsResponse;
import com.example.solidconnection.news.dto.NewsUpdateRequest;
import com.example.solidconnection.news.service.NewsCommandService;
import com.example.solidconnection.news.service.NewsQueryService;
import com.example.solidconnection.security.annotation.RequireAdminAccess;
import com.example.solidconnection.siteuser.domain.SiteUser;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequiredArgsConstructor
@RequestMapping("/news")
public class NewsController {

private final NewsQueryService newsQueryService;
private final NewsCommandService newsCommandService;

// todo: 추후 검색 조건 및 Slice 적용
@GetMapping
public ResponseEntity<NewsResponse> searchNews() {
NewsResponse newsResponse = newsQueryService.searchNews();
return ResponseEntity.ok(newsResponse);
}

@GetMapping(value = "/{news_id}")
public ResponseEntity<NewsFindResponse> findNewsById(
@AuthorizedUser SiteUser siteUser,
@PathVariable("news_id") Long newsId
) {
NewsFindResponse newsFindResponse = newsQueryService.findNewsById(newsId);
return ResponseEntity.ok(newsFindResponse);
}

@RequireAdminAccess
@PostMapping
public ResponseEntity<NewsCommandResponse> createNews(
@AuthorizedUser SiteUser siteUser,
@Valid @RequestPart("newsCreateRequest") NewsCreateRequest newsCreateRequest,
@RequestParam(value = "file") MultipartFile imageFile
) {
NewsCommandResponse newsCommandResponse = newsCommandService.createNews(newsCreateRequest, imageFile);
return ResponseEntity.ok(newsCommandResponse);
}

@RequireAdminAccess
@PatchMapping(value = "/{news_id}")
public ResponseEntity<NewsCommandResponse> updateNews(
@AuthorizedUser SiteUser siteUser,
@PathVariable("news_id") Long newsId,
@Valid @RequestPart(value = "newsUpdateRequest") NewsUpdateRequest newsUpdateRequest,
@RequestParam(value = "file", required = false) MultipartFile imageFile
) {
NewsCommandResponse newsCommandResponse = newsCommandService.updateNews(newsId, newsUpdateRequest, imageFile);
return ResponseEntity.ok(newsCommandResponse);
}

@RequireAdminAccess
@DeleteMapping(value = "/{news_id}")
public ResponseEntity<NewsCommandResponse> deleteNewsById(
@AuthorizedUser SiteUser siteUser,
@PathVariable("news_id") Long newsId
) {
NewsCommandResponse newsCommandResponse = newsCommandService.deleteNewsById(newsId);
return ResponseEntity.ok(newsCommandResponse);
}
}
27 changes: 27 additions & 0 deletions src/main/java/com/example/solidconnection/news/domain/News.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,31 @@ public class News extends BaseEntity {

@Column(length = 500)
private String url;

public News(
String title,
String description,
String thumbnailUrl,
String url) {
this.title = title;
this.description = description;
this.thumbnailUrl = thumbnailUrl;
this.url = url;
}

public void updateTitle(String title) {
this.title = title;
}

public void updateDescription(String description) {
this.description = description;
}

public void updateUrl(String url) {
this.url = url;
}

public void updateThumbnailUrl(String thumbnailUrl) {
this.thumbnailUrl = thumbnailUrl;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.solidconnection.news.dto;

import com.example.solidconnection.news.domain.News;

public record NewsCommandResponse(
long id
) {
public static NewsCommandResponse from(News news) {
return new NewsCommandResponse(
news.getId()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.example.solidconnection.news.dto;

import com.example.solidconnection.news.domain.News;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record NewsCreateRequest(
@NotBlank(message = "소식지 제목을 입력해주세요.")
@Size(max = 255, message = "소식지 제목은 최대 255자 이하여야 합니다.")
String title,

@NotBlank(message = "소식지 설명을 입력해주세요.")
@Size(max = 255, message = "소식지 설명은 최대 255자 이하여야 합니다.")
String description,

@NotBlank(message = "소식지 URL을 입력해주세요.")
@Size(max = 500, message = "소식지 URL은 최대 500자 이하여야 합니다.")
String url
) {
public News toEntity(String thumbnailUrl) {
return new News(
title,
description,
thumbnailUrl,
url
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.solidconnection.news.dto;

import com.example.solidconnection.news.domain.News;

import java.time.ZonedDateTime;

public record NewsFindResponse(
long id,
String title,
String description,
String thumbnailUrl,
String url,
ZonedDateTime createdAt,
ZonedDateTime updatedAt
) {
public static NewsFindResponse from(News news) {
return new NewsFindResponse(
news.getId(),
news.getTitle(),
news.getDescription(),
news.getThumbnailUrl(),
news.getUrl(),
news.getCreatedAt(),
news.getUpdatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.example.solidconnection.news.dto;

import com.example.solidconnection.news.domain.News;

import java.time.ZonedDateTime;

public record NewsItemResponse(
long id,
String title,
String thumbnailUrl,
String url,
ZonedDateTime createdAt,
ZonedDateTime updatedAt
) {
public static NewsItemResponse from(News news) {
return new NewsItemResponse(
news.getId(),
news.getTitle(),
news.getThumbnailUrl(),
news.getUrl(),
news.getCreatedAt(),
news.getUpdatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.solidconnection.news.dto;

import java.util.List;

public record NewsResponse(
List<NewsItemResponse> newsItemsResponseList
) {
public static NewsResponse from(List<NewsItemResponse> newsItemsResponseList) {
return new NewsResponse(newsItemsResponseList);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.example.solidconnection.news.dto;

import jakarta.validation.constraints.Size;

public record NewsUpdateRequest(

@Size(max = 255, message = "소식지 제목은 최대 255자 이하여야 합니다.")
String title,

@Size(max = 255, message = "소식지 설명은 최대 255자 이하여야 합니다.")
String description,

@Size(max = 500, message = "소식지 URL은 최대 500자 이하여야 합니다.")
String url
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.solidconnection.news.repository;

import com.example.solidconnection.news.domain.News;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface NewsRepository extends JpaRepository<News, Long> {

List<News> findAllByOrderByUpdatedAtDesc();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.example.solidconnection.news.service;

import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.news.domain.News;
import com.example.solidconnection.news.dto.NewsCreateRequest;
import com.example.solidconnection.news.dto.NewsCommandResponse;
import com.example.solidconnection.news.dto.NewsUpdateRequest;
import com.example.solidconnection.news.repository.NewsRepository;
import com.example.solidconnection.s3.domain.ImgType;
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
import com.example.solidconnection.s3.service.S3Service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import static com.example.solidconnection.common.exception.ErrorCode.NEWS_DESCRIPTION_TOO_LONG;
import static com.example.solidconnection.common.exception.ErrorCode.NEWS_NOT_FOUND;
import static com.example.solidconnection.common.exception.ErrorCode.NEWS_TITLE_EMPTY;
import static com.example.solidconnection.common.exception.ErrorCode.NEWS_TITLE_TOO_LONG;
import static com.example.solidconnection.common.exception.ErrorCode.NEWS_URL_INVALID;
import static com.example.solidconnection.common.exception.ErrorCode.NEWS_URL_TOO_LONG;

@Service
@RequiredArgsConstructor
public class NewsCommandService {

public static final int MAX_TITLE_LENGTH = 255;
public static final int MAX_DESCRIPTION_LENGTH = 255;
public static final int MAX_URL_LENGTH = 500;

private final S3Service s3Service;
private final NewsRepository newsRepository;

@Transactional
public NewsCommandResponse createNews(NewsCreateRequest newsCreateRequest, MultipartFile imageFile) {
UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS);
News news = newsCreateRequest.toEntity(uploadedFile.fileUrl());
News savedNews = newsRepository.save(news);
return NewsCommandResponse.from(savedNews);
}

@Transactional
public NewsCommandResponse updateNews(Long newsId, NewsUpdateRequest newsUpdateRequest, MultipartFile imageFile) {
News news = newsRepository.findById(newsId)
.orElseThrow(() -> new CustomException(NEWS_NOT_FOUND));
if (newsUpdateRequest.title() != null) {
validateTitle(newsUpdateRequest.title());
news.updateTitle(newsUpdateRequest.title());
}
if (newsUpdateRequest.description() != null) {
validateDescription(newsUpdateRequest.description());
news.updateDescription(newsUpdateRequest.description());
}
if (newsUpdateRequest.url() != null) {
validateUrl(newsUpdateRequest.url());
news.updateUrl(newsUpdateRequest.url());
}
if (imageFile != null) {
UploadedFileUrlResponse uploadedFile = s3Service.uploadFile(imageFile, ImgType.NEWS);
s3Service.deletePostImage(news.getThumbnailUrl());
String thumbnailImageUrl = uploadedFile.fileUrl();
news.updateThumbnailUrl(thumbnailImageUrl);
}
News savedNews = newsRepository.save(news);
return NewsCommandResponse.from(savedNews);
}

@Transactional
public NewsCommandResponse deleteNewsById(Long newsId) {
News news = newsRepository.findById(newsId)
.orElseThrow(() -> new CustomException(NEWS_NOT_FOUND));
newsRepository.deleteById(newsId);
s3Service.deletePostImage(news.getThumbnailUrl());
return NewsCommandResponse.from(news);
}

private void validateTitle(String title) {
if (title.trim().isEmpty()) {
throw new CustomException(NEWS_TITLE_EMPTY);
}
if (title.length() > MAX_TITLE_LENGTH) {
throw new CustomException(NEWS_TITLE_TOO_LONG);
}
}

private void validateDescription(String description) {
if (description.length() > MAX_DESCRIPTION_LENGTH) {
throw new CustomException(NEWS_DESCRIPTION_TOO_LONG);
}
}

private void validateUrl(String url) {
if (!url.matches("^https?://.*")) {
throw new CustomException(NEWS_URL_INVALID);
}
if (url.length() > MAX_URL_LENGTH) {
throw new CustomException(NEWS_URL_TOO_LONG);
}
}
}
Loading
Loading