Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
@@ -0,0 +1,10 @@
package com.campus.campus.domain.councilNotice.application.dto.request;

import java.util.List;

public record NoticeRequestDto(
String title,
String content,
List<String> imageUrls
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.campus.campus.domain.councilNotice.application.dto.response;

import java.time.LocalDateTime;

import lombok.Builder;

@Builder
public record NoticeListItemResponseDto(
Long id,
String title,
boolean isWriter,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.campus.campus.domain.councilNotice.application.dto.response;

import java.time.LocalDateTime;
import java.util.List;

import lombok.Builder;

@Builder
public record NoticeResponseDto(
Long id,
Long writerId,
String writerName,
boolean isWriter,
String title,
String content,
List<String> images,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.campus.campus.domain.councilNotice.application.exception;

import org.springframework.http.HttpStatus;

import com.campus.campus.global.common.exception.ErrorCodeInterface;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ErrorCode implements ErrorCodeInterface {

NOTICE_NOT_FOUND(2501, HttpStatus.NOT_FOUND, "공지를 찾을 수 없습니다."),
NOT_NOTICE_WRITER(2502, HttpStatus.FORBIDDEN, "작성자만 해당 공지를 수정하거나 삭제할 수 있습니다."),
NOTICE_IMAGE_LIMIT_EXCEEDED(2503, HttpStatus.BAD_REQUEST, "공지 이미지는 최대 10개까지 등록할 수 있습니다.");

private final int code;
private final HttpStatus status;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.campus.campus.domain.councilNotice.application.exception;

import com.campus.campus.global.common.exception.ApplicationException;

public class NotNoticeWriterException extends ApplicationException {
public NotNoticeWriterException() {
super(ErrorCode.NOT_NOTICE_WRITER);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.campus.campus.domain.councilNotice.application.exception;

import com.campus.campus.global.common.exception.ApplicationException;

public class NoticeImageLimitExceededException extends ApplicationException {
public NoticeImageLimitExceededException() {
super(ErrorCode.NOTICE_IMAGE_LIMIT_EXCEEDED);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.campus.campus.domain.councilNotice.application.exception;

import com.campus.campus.global.common.exception.ApplicationException;

public class NoticeNotFoundException extends ApplicationException {
public NoticeNotFoundException() {
super(ErrorCode.NOTICE_NOT_FOUND);
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mapper에 관한 메서드 명명 규칙에 대한 통일이 필요해보여요!!
저의 경우 엔티티를 새로 생성해 저장하는 경우 createStudentCouncilNotice, createCouncil 등 "create + 엔티티명"의 형태로 구현하며 응답 dto 경우 toNoticeResponseDto, toUserResponse등으로 "to + 응답dto 이름"의 형태로 이름을 만듭니다.
재영님의 경우 toEntity, toDetail 등으로 메서드명을 통일하고 계시는 느낌인데 이는 모두가 동일한 명명법을 사용하는 방법을 생각해야할 것 같아요!!
저의 경우 재영님과 같이 메서드명을 통일시키지 않은 이유는 추후 service 내 코드를 확인할 때 이름이 겹쳐 코드를 확인하고 이해하는데 혼동이 올 수 있다고 생각하여 각각 한번에 알아볼 수 있도록 하려 했기 때문입니다!!
재영님은 어떤 방식으로 메서드명을 짓는 것이 좋다고 생각하시나요??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 예를 들어 mapper의 toEntitiy가 꼭 create 하는 데에만 쓰이는 것이 아니라 update에도 쓰니까 create~이렇게 하게 되면 create할 때만 쓰이는 것인가?로 생각할 수도 있고 좀 더 뭐할 때에 쓰이는 지 명확하다고 생각을 해서 평소에는 그렇게 쓴 것이었습니다.

이 부분은 원래 먼저 올린 사람대로 규칙을 따라가자고 했어서 제가 고쳤어야 했는데, 승현님꺼 보고 나중에 고치자 하다가 깜빡했네요! 승현님 메서드명으로 바꾸겠습니다.

Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.campus.campus.domain.councilNotice.application.mapper;

import java.util.List;

import com.campus.campus.domain.council.domain.entity.StudentCouncil;
import com.campus.campus.domain.councilNotice.application.dto.request.NoticeRequestDto;
import com.campus.campus.domain.councilNotice.application.dto.response.NoticeListItemResponseDto;
import com.campus.campus.domain.councilNotice.application.dto.response.NoticeResponseDto;
import com.campus.campus.domain.councilNotice.domain.entity.NoticeImage;
import com.campus.campus.domain.councilNotice.domain.entity.StudentCouncilNotice;

public class StudentCouncilNoticeMapper {

public static StudentCouncilNotice toEntity(StudentCouncil writer, NoticeRequestDto dto) {
return StudentCouncilNotice.builder()
.title(dto.title())
.content(dto.content())
.writer(writer)
.build();
}

public static NoticeImage toEntity(StudentCouncilNotice notice, String imageUrl) {
return NoticeImage.builder()
.notice(notice)
.imageUrl(imageUrl)
.build();
}

public static NoticeResponseDto toDetail(StudentCouncilNotice notice, List<String> imageUrls, Long councilId) {
return NoticeResponseDto.builder()
.id(notice.getId())
.writerId(notice.getWriter().getId())
.writerName(notice.getWriter().getFullCouncilName())
.isWriter(notice.isWrittenByCouncil(councilId))
.title(notice.getTitle())
.content(notice.getContent())
.images(imageUrls)
.createdAt(notice.getCreatedAt())
.updatedAt(notice.getUpdatedAt())
.build();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분의 경우 이전과는 다르게 시간설정과 같은 로직이 추가되지 않았는데도 불구하고 builder()패턴을 사용하신 이유가 있을까요?

Copy link
Contributor Author

@jjaeroong jjaeroong Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

승현님 이 부분 관련해서 의견 여쭤보고 싶은 것이 있었습니다.

조회용 응답 DTO는 new로 만드는 것에 동의합니다! 그런데, 구현하면서, 필드가 많아서, 순서나 가독성이 떨어질 수 있을 것 같아 의견을 구하고자, new로 작성하기 보다는 builder로 우선 작성해두었습니다. 5개 이상의 파라미터의 경우엔 builder를 쓰는 것이 어떨까하여 의견을 묻고 싶습니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그래도 될 것 같습니다!! 재영님 의견대로 해도 좋을 것 같아요!!


public static NoticeListItemResponseDto toListItem(StudentCouncilNotice notice, Long currentUserId) {
return NoticeListItemResponseDto.builder()
.id(notice.getId())
.title(notice.getTitle())
.isWriter(notice.isWrittenByCouncil(currentUserId))
.createdAt(notice.getCreatedAt())
.updatedAt(notice.getUpdatedAt())
.build();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

마찬가지입니다!!

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package com.campus.campus.domain.councilNotice.application.service;

import java.util.ArrayList;
import java.util.List;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.campus.campus.domain.council.application.exception.StudentCouncilNotFoundException;
import com.campus.campus.domain.council.domain.entity.StudentCouncil;
import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository;
import com.campus.campus.domain.councilNotice.application.dto.request.NoticeRequestDto;
import com.campus.campus.domain.councilNotice.application.dto.response.NoticeListItemResponseDto;
import com.campus.campus.domain.councilNotice.application.dto.response.NoticeResponseDto;
import com.campus.campus.domain.councilNotice.application.exception.NotNoticeWriterException;
import com.campus.campus.domain.councilNotice.application.exception.NoticeImageLimitExceededException;
import com.campus.campus.domain.councilNotice.application.exception.NoticeNotFoundException;
import com.campus.campus.domain.councilNotice.application.mapper.StudentCouncilNoticeMapper;
import com.campus.campus.domain.councilNotice.domain.entity.NoticeImage;
import com.campus.campus.domain.councilNotice.domain.entity.StudentCouncilNotice;
import com.campus.campus.domain.councilNotice.domain.repository.NoticeImageRepository;
import com.campus.campus.domain.councilNotice.domain.repository.StudentCouncilNoticeRepository;
import com.campus.campus.global.oci.application.service.PresignedUrlService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
public class StudentCouncilNoticeService {

private static final int MAX_IMAGE_COUNT = 10;

private final StudentCouncilNoticeRepository noticeRepository;
private final StudentCouncilRepository studentCouncilRepository;
private final NoticeImageRepository noticeImageRepository;
private final PresignedUrlService presignedUrlService;

@Transactional
public NoticeResponseDto create(Long councilId, NoticeRequestDto dto) {

if (dto.imageUrls() != null && dto.imageUrls().size() > MAX_IMAGE_COUNT) {
throw new NoticeImageLimitExceededException();
}

StudentCouncil writer = studentCouncilRepository.findByIdWithDetailsAndDeletedAtIsNull(councilId)
.orElseThrow(StudentCouncilNotFoundException::new);

StudentCouncilNotice notice =
noticeRepository.save(StudentCouncilNoticeMapper.toEntity(writer, dto));

if (dto.imageUrls() != null && !dto.imageUrls().isEmpty()) {
List<NoticeImage> images = dto.imageUrls().stream()
.map(imageUrl -> StudentCouncilNoticeMapper.toEntity(notice, imageUrl))
.toList();

noticeImageRepository.saveAll(images);
}

List<String> imageUrls = noticeImageRepository
.findAllByNoticeOrderByIdAsc(notice)
.stream()
.map(NoticeImage::getImageUrl)
.toList();

return StudentCouncilNoticeMapper.toDetail(notice, imageUrls, councilId);
}

@Transactional(readOnly = true)
public NoticeResponseDto findById(Long noticeId, Long councilId) {

StudentCouncilNotice notice = noticeRepository.findByIdWithFullInfo(noticeId)
.orElseThrow(NoticeNotFoundException::new);

List<String> imageUrls = noticeImageRepository
.findAllByNoticeOrderByIdAsc(notice)
.stream()
.map(NoticeImage::getImageUrl)
.toList();

return StudentCouncilNoticeMapper.toDetail(notice, imageUrls, councilId);
}

@Transactional(readOnly = true)
public Page<NoticeListItemResponseDto> findAll(int page, int size, Long councilId) {

Pageable pageable = PageRequest.of(
Math.max(page - 1, 0),
size,
Sort.by(Sort.Direction.DESC, "createdAt")
);

Page<StudentCouncilNotice> notices = noticeRepository.findAll(pageable);

return notices.map(notice ->
StudentCouncilNoticeMapper.toListItem(notice, councilId)
);
}

@Transactional
public NoticeResponseDto update(Long councilId, Long noticeId, NoticeRequestDto dto) {

if (dto.imageUrls() != null && dto.imageUrls().size() > MAX_IMAGE_COUNT) {
throw new NoticeImageLimitExceededException();
}

StudentCouncilNotice notice = noticeRepository.findByIdWithFullInfo(noticeId)
.orElseThrow(NoticeNotFoundException::new);

if (!notice.isWrittenByCouncil(councilId)) {
throw new NotNoticeWriterException();
}

// 업데이트 전 기존 이미지 스냅샷
List<NoticeImage> oldImages = noticeImageRepository.findAllByNotice(notice);

notice.update(dto.title(), dto.content());

// DB 이미지 교체
noticeImageRepository.deleteByNotice(notice);

if (dto.imageUrls() != null) {
for (String imageUrl : dto.imageUrls()) {
noticeImageRepository.save(StudentCouncilNoticeMapper.toEntity(notice, imageUrl));
}
}

cleanupUnusedImages(oldImages, dto);

List<String> imageUrls = noticeImageRepository
.findAllByNoticeOrderByIdAsc(notice)
.stream()
.map(NoticeImage::getImageUrl)
.toList();

return StudentCouncilNoticeMapper.toDetail(notice, imageUrls, councilId);
}

@Transactional
public void delete(Long councilId, Long noticeId) {

StudentCouncilNotice notice = noticeRepository.findByIdWithFullInfo(noticeId)
.orElseThrow(NoticeNotFoundException::new);

if (!notice.isWrittenByCouncil(councilId)) {
throw new NotNoticeWriterException();
}

List<NoticeImage> images = noticeImageRepository.findAllByNotice(notice);

List<String> deleteTargets = new ArrayList<>();
images.stream()
.map(NoticeImage::getImageUrl)
.forEach(deleteTargets::add);

noticeImageRepository.deleteAll(images);
noticeRepository.delete(notice);

for (String imageUrl : deleteTargets) {
if (imageUrl == null || imageUrl.isBlank()) {
continue;
}

try {
presignedUrlService.deleteImage(imageUrl);
} catch (Exception e) {
log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl);
}
}
}

private void cleanupUnusedImages(List<NoticeImage> oldImages, NoticeRequestDto dto) {
List<String> newUrls = dto.imageUrls() == null ? List.of() : dto.imageUrls();

List<String> deleteTargets = new ArrayList<>();

// 본문 이미지 중 제거된 이미지
oldImages.stream()
.map(NoticeImage::getImageUrl)
.filter(url -> !newUrls.contains(url))
.forEach(deleteTargets::add);

//삭제
for (String imageUrl : deleteTargets) {
if (imageUrl == null || imageUrl.isBlank()) {
continue;
}

try {
presignedUrlService.deleteImage(imageUrl);
} catch (Exception e) {
log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.campus.campus.domain.councilNotice.domain.entity;

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 lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class NoticeImage {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분의 경우 따로 extends BaseEntity를 붙이지 않은 이유가 있을까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 이 부분은 수정하겠습니다!!

발견해주셔서 감사해요


@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "notice_id")
private StudentCouncilNotice notice;

@Column(nullable = false)
private String imageUrl;
}
Loading