Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
722444e
feat: 공지사항 엔티티 생성
boyekim Mar 28, 2026
9b482ab
feat: 공지사항 전체 조회 api 구현
boyekim Mar 28, 2026
4b79bca
feat: 공지사항 등록 api 구현
boyekim Mar 29, 2026
c440c35
refactor: 전체 조회 조건 추가
boyekim Mar 29, 2026
27ae010
refactor: 공지 생성 시 api 호출 레벨에서 검증 추가
boyekim Mar 29, 2026
979eea8
feat: 공지 수정 api 구현
boyekim Mar 29, 2026
a341e4c
feat: 공지 삭제 api 구현
boyekim Mar 29, 2026
ed71ea0
test: 공지 CRUD 단위 테스트 및 객체 협력 테스트 추가
boyekim Mar 29, 2026
fd83c52
feat: MethodArgumentNotValidException 핸들러 추가
boyekim Mar 30, 2026
cee4753
feat: OperationType 전체 카테고리 추가
boyekim Mar 30, 2026
60bb57b
feat: OperationType 리뷰 카테고리 추가
boyekim Apr 4, 2026
6c5affa
refactor: BaseEntity에 `@Getter` 적용
boyekim Apr 5, 2026
b445714
refactor: UserReview에 BaseEntity extends 추가
boyekim Apr 5, 2026
0b78d1a
refactor: Notice에 OperationType null 비허용
boyekim Apr 5, 2026
96d52ef
refactor: Test에 불필요한 entityManager 삭제
boyekim Apr 5, 2026
eb1dd87
feat: BaseEntity에 updatedAt 필드 추가
boyekim Apr 5, 2026
9d9f639
refactor: UpdatedNoticeResponse 필드 수정
boyekim Apr 5, 2026
66f736c
refactor: updatedAt 중간 반영 되도록 로직 추가
boyekim Apr 5, 2026
6cf169b
test: updatedAt 검증 가능하도록 테스트 수정
boyekim Apr 5, 2026
8e4b713
test: updatedAt 수정
boyekim Apr 5, 2026
55ec91b
refactor: 엔티티 생성 책임 DTO로 이관하여 캡슐화
boyekim Apr 7, 2026
a316c0c
test: 공지 수정 테스트 수정 및 BaseEntityListenerTest 추가
boyekim Apr 7, 2026
7f4f279
feat: 클라이언트용 공지 조회 api 구현
boyekim Apr 7, 2026
c3684b0
test: 테스트 추가
boyekim Apr 7, 2026
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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-mail'
// implementation 'io.micrometer:micrometer-registry-datadog'
Expand Down
74 changes: 74 additions & 0 deletions src/main/java/kr/allcll/backend/admin/notice/AdminNoticeApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package kr.allcll.backend.admin.notice;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import kr.allcll.backend.admin.AdminRequestValidator;
import kr.allcll.backend.admin.notice.dto.CreateNoticeRequest;
import kr.allcll.backend.admin.notice.dto.CreateNoticeResponse;
import kr.allcll.backend.admin.notice.dto.AdminNoticesResponse;
import kr.allcll.backend.admin.notice.dto.UpdateNoticeRequest;
import kr.allcll.backend.admin.notice.dto.UpdateNoticeResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
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.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AdminNoticeApi {

private final AdminNoticeService adminNoticeService;
private final AdminRequestValidator validator;

@GetMapping("/api/admin/notices")
public ResponseEntity<AdminNoticesResponse> getAllNotice(HttpServletRequest request) {
if (validator.isRateLimited(request) || validator.isUnauthorized(request)) {
return ResponseEntity.status(401).build();
}
AdminNoticesResponse response = adminNoticeService.getAllNotice();
return ResponseEntity.ok(response);
}

@PostMapping("/api/admin/notices")
public ResponseEntity<CreateNoticeResponse> createNotice(
HttpServletRequest request,
@Valid @RequestBody CreateNoticeRequest createNoticeRequest
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Map bean validation failures to 4xx responses

Adding @Valid here makes invalid payloads throw MethodArgumentNotValidException, but GlobalExceptionHandler currently has no dedicated handler and its catch-all Exception path returns SERVER_ERROR (500). That means a bad notice create/update request is reported as a server fault instead of a client input error, which will break client-side error handling and monitoring by inflating 5xx rates for ordinary validation mistakes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ㅇㅈ ExceptionHandler 추가하겠습니다

) {
if (validator.isRateLimited(request) || validator.isUnauthorized(request)) {
return ResponseEntity.status(401).build();
}
CreateNoticeResponse response = adminNoticeService.createNewNotice(createNoticeRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@PatchMapping("/api/admin/notices/{id}")
public ResponseEntity<UpdateNoticeResponse> modifyNotice(
HttpServletRequest request,
@PathVariable Long id,
@Valid @RequestBody UpdateNoticeRequest updateNoticeRequest
) {
if (validator.isRateLimited(request) || validator.isUnauthorized(request)) {
return ResponseEntity.status(401).build();
}
UpdateNoticeResponse response = adminNoticeService.updateNotice(id, updateNoticeRequest);
return ResponseEntity.ok(response);
}

@DeleteMapping("/api/admin/notices/{id}")
public ResponseEntity<Void> deleteNotice(
HttpServletRequest request,
@PathVariable Long id
) {
if (validator.isRateLimited(request) || validator.isUnauthorized(request)) {
return ResponseEntity.status(401).build();
}
adminNoticeService.deleteNotice(id);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package kr.allcll.backend.admin.notice;

import java.util.List;
import kr.allcll.backend.admin.notice.dto.CreateNoticeRequest;
import kr.allcll.backend.admin.notice.dto.CreateNoticeResponse;
import kr.allcll.backend.admin.notice.dto.AdminNoticesResponse;
import kr.allcll.backend.admin.notice.dto.UpdateNoticeRequest;
import kr.allcll.backend.admin.notice.dto.UpdateNoticeResponse;
import kr.allcll.backend.domain.notice.Notice;
import kr.allcll.backend.domain.notice.NoticeRepository;
import kr.allcll.backend.support.exception.AllcllErrorCode;
import kr.allcll.backend.support.exception.AllcllException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AdminNoticeService {

private final NoticeRepository noticeRepository;

public AdminNoticesResponse getAllNotice() {
List<Notice> allNotices = noticeRepository.findAllOrderedByCreatedAt();
return AdminNoticesResponse.from(allNotices);
}

@Transactional
public CreateNoticeResponse createNewNotice(CreateNoticeRequest createNoticeRequest) {
Notice notice = noticeRepository.save(createNoticeRequest.toEntity());
return CreateNoticeResponse.from(notice);
}

@Transactional
public UpdateNoticeResponse updateNotice(Long id, UpdateNoticeRequest updateNoticeRequest) {
Notice notice = noticeRepository.findActiveById(id)
.orElseThrow(() -> new AllcllException(AllcllErrorCode.NOTICE_NOT_FOUND, id));
notice.update(
updateNoticeRequest.title(),
updateNoticeRequest.content(),
updateNoticeRequest.operationType()
);
noticeRepository.flush();
return UpdateNoticeResponse.from(notice);
}

@Transactional
public void deleteNotice(Long id) {
Notice notice = noticeRepository.findActiveById(id)
.orElseThrow(() -> new AllcllException(AllcllErrorCode.NOTICE_NOT_FOUND, id));
softDeleteNotice(notice);
}

private void softDeleteNotice(Notice notice) {
notice.delete();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.allcll.backend.admin.notice.dto;

import java.time.LocalDateTime;
import kr.allcll.backend.domain.notice.Notice;
import kr.allcll.backend.domain.operationPeriod.OperationType;

public record AdminNoticeResponse(
long id,
String title,
String content,
OperationType operationType,
LocalDateTime createdAt
) {

public static AdminNoticeResponse from(Notice notice) {
return new AdminNoticeResponse(
notice.getId(),
notice.getTitle(),
notice.getContent(),
notice.getOperationType(),
notice.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package kr.allcll.backend.admin.notice.dto;

import java.util.List;
import kr.allcll.backend.domain.notice.Notice;

public record AdminNoticesResponse(
List<AdminNoticeResponse> notices
) {

public static AdminNoticesResponse from(List<Notice> allNotices) {
return new AdminNoticesResponse(
allNotices.stream()
.map(AdminNoticeResponse::from)
.toList()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package kr.allcll.backend.admin.notice.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import kr.allcll.backend.domain.notice.Notice;
import kr.allcll.backend.domain.operationPeriod.OperationType;

public record CreateNoticeRequest(
@NotBlank
@Size(max = 250)
String title,

@NotBlank
@Size(max = 1000)
String content,

@NotNull
OperationType operationType
) {

public Notice toEntity() {
return Notice.of(
title,
content,
operationType
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.allcll.backend.admin.notice.dto;

import java.time.LocalDateTime;
import kr.allcll.backend.domain.notice.Notice;
import kr.allcll.backend.domain.operationPeriod.OperationType;

public record CreateNoticeResponse(
long id,
String title,
String content,
OperationType operationType,
LocalDateTime createdAt
) {

public static CreateNoticeResponse from(Notice notice) {
return new CreateNoticeResponse(
notice.getId(),
notice.getTitle(),
notice.getContent(),
notice.getOperationType(),
notice.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package kr.allcll.backend.admin.notice.dto;

import jakarta.validation.constraints.Size;
import kr.allcll.backend.domain.operationPeriod.OperationType;

public record UpdateNoticeRequest(
@Size(max = 250)
Copy link
Copy Markdown
Contributor

@2Jin1031 2Jin1031 Apr 4, 2026

Choose a reason for hiding this comment

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

CreateNoticeRequest에서는 @NotBlank로 DTO 측에서 한번 검증을 하고 들어온 것 같은데 update에서는 제외하신 이유가 있으실까요?? Notice.update 에서 null 검증을 하는 것보다 UpdateNoticeRequest에도 @NotBlank를 붙이면 DTO 단에서 검증이 끝나고, 도메인의 Notice.update에서는 null 체크를 뺄 수 있어 더 깔끔해질 것 같아서용

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

PATCH 요청이라 수정하지 않은 필드는 보내지 않을 것으로 보고 있어서, 해당 값들은 null로 들어올 수 있다고 판단했습니다.
그래서 UpdateNoticeRequest에 @NotBlank를 적용하지 않았습니다~ 명세상 잘못된 요청이 아니기 때문에 Request에서 막지는 않고, update 시 유효한 값만 Notice에서 추가해주는 것이 자연스럽다고 생각했습니다.
어떻게 생각하시나요? 다른 방법이 좋아보이시나요?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

아,, 맞네요
update 시 미 업데이트인 null 데이터 ,, 이부분저도 고민했던 부분이었던 것 같습니다
나중에 더 엔티티가 커지면 관리 지점이 늘어나긴 ㅎㅏ겟지만 가능성이 크지 않은 것 같아서 지금 방향성이 좋은 것 같아요!
감사합니다!!

String title,

@Size(max = 1000)
String content,
Comment on lines +7 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject blank notice fields on partial updates

The update DTO only enforces @Size, so values like "" or whitespace-only strings pass validation and are then persisted by Notice.update(...). This allows existing notices to end up with empty title/content even though creation explicitly requires non-blank fields, creating inconsistent data rules and potentially unreadable notices in admin/user views.

Useful? React with 👍 / 👎.


OperationType operationType
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.allcll.backend.admin.notice.dto;

import java.time.LocalDateTime;
import kr.allcll.backend.domain.notice.Notice;
import kr.allcll.backend.domain.operationPeriod.OperationType;

public record UpdateNoticeResponse(
long id,
String title,
String content,
OperationType operationType,
LocalDateTime updatedAt
) {

public static UpdateNoticeResponse from(Notice notice) {
return new UpdateNoticeResponse(
notice.getId(),
notice.getTitle(),
notice.getContent(),
notice.getOperationType(),
notice.getUpdatedAt()
);
}
}
66 changes: 66 additions & 0 deletions src/main/java/kr/allcll/backend/domain/notice/Notice.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package kr.allcll.backend.domain.notice;

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.Table;
import kr.allcll.backend.domain.operationPeriod.OperationType;
import kr.allcll.backend.support.entity.BaseEntity;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Table(name = "notices")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Notice extends BaseEntity {

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

@Column(nullable = false, length = 250)
private String title;

@Column(nullable = false, length = 1000)
private String content;

@Column(nullable = false)
@Enumerated(EnumType.STRING)
private OperationType operationType;
Copy link
Copy Markdown
Contributor

@2Jin1031 2Jin1031 Apr 4, 2026

Choose a reason for hiding this comment

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

@Column(nullable = false) 이것도 함께 추가하는 건 어떻게 생각하시나요??
enum으로 관리되고 있어서 null이 비지니스상 필요하진 않을 것 같아서요!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

음 그렇네요. 추가했습니다


private Notice(String title, String content, OperationType operationType) {
this.title = title;
this.content = content;
this.operationType = operationType;
}

public static Notice of(String title, String content, OperationType operationType) {
return new Notice(
title,
content,
operationType
);
}

public void update(String title, String content, OperationType operationType) {
if (title != null) {
this.title = title;
}
if (content != null) {
this.content = content;
}
if (operationType != null) {
this.operationType = operationType;
}
}

public void delete() {
super.delete();
}
}
20 changes: 20 additions & 0 deletions src/main/java/kr/allcll/backend/domain/notice/NoticeApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.allcll.backend.domain.notice;

import kr.allcll.backend.domain.notice.dto.NoticesResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class NoticeApi {

private final NoticeService noticeService;

@GetMapping("/api/notices")
public ResponseEntity<NoticesResponse> getAllNotice() {
NoticesResponse response = noticeService.getAllNotice();
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package kr.allcll.backend.domain.notice;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface NoticeRepository extends JpaRepository<Notice, Long> {

@Query("""
select n from Notice n
where n.isDeleted = false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

이것도 @SQLRestriction("deleted_at IS NULL") 이걸 도메인 위의 어노테이션으로 붙이면서 까묵지 않고 처리할 수 있게 될 것 같아 제안드려봅니당

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

이러면 조건을 항상 추가해줄 수 있군요?! 엔티티 전체적으로 반영하면 좋을 것 같아서 @SQLDelete과 함께 pr로 따로 묶어서 올리는것 어떨까요?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

좋습니다~!

order by n.createdAt desc
""")
List<Notice> findAllOrderedByCreatedAt();

@Query("""
select n from Notice n
where n.id = :id
and n.isDeleted = false
""")
Optional<Notice> findActiveById(Long id);
}
Loading
Loading