diff --git a/.gitignore b/.gitignore index 541c61b1..5f5a138d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ out/ ### VS Code ### .vscode/ + +### macOS ### +.DS_Store #*.properties diff --git a/layer-admin/src/main/java/org/layer/admin/notice/controller/dto/AdminNoticeRequest.java b/layer-admin/src/main/java/org/layer/admin/notice/controller/dto/AdminNoticeRequest.java index 79b79b2d..95e391fe 100644 --- a/layer-admin/src/main/java/org/layer/admin/notice/controller/dto/AdminNoticeRequest.java +++ b/layer-admin/src/main/java/org/layer/admin/notice/controller/dto/AdminNoticeRequest.java @@ -6,13 +6,16 @@ import org.layer.domain.notice.entity.NoticeCategory; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; public class AdminNoticeRequest { public record CreateNoticeRequest( @NotNull + @Size(max = 100) String title, @NotNull + @Size(max = 800) String content, @NotNull NoticeCategory category, @@ -38,8 +41,10 @@ public Notice toEntity() { public record UpdateNoticeRequest( @NotNull + @Size(max = 100) String title, @NotNull + @Size(max = 800) String content, @NotNull NoticeCategory category, diff --git a/layer-admin/src/main/java/org/layer/admin/popup/controller/AdminPopupController.java b/layer-admin/src/main/java/org/layer/admin/popup/controller/AdminPopupController.java new file mode 100644 index 00000000..82ac1403 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/popup/controller/AdminPopupController.java @@ -0,0 +1,62 @@ +package org.layer.admin.popup.controller; + +import org.layer.admin.popup.controller.dto.AdminPopupRequest.CreatePopupRequest; +import org.layer.admin.popup.controller.dto.AdminPopupRequest.UpdatePopupActiveRequest; +import org.layer.admin.popup.controller.dto.AdminPopupRequest.UpdatePopupRequest; +import org.layer.admin.popup.controller.dto.AdminPopupResponse.PopupCreateResponse; +import org.layer.admin.popup.controller.dto.AdminPopupResponse.PopupDetail; +import org.layer.admin.popup.controller.dto.AdminPopupResponse.PopupPage; +import org.layer.admin.popup.service.AdminPopupService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin/popup") +public class AdminPopupController { + + private final AdminPopupService adminPopupService; + + @GetMapping("") + public ResponseEntity getPopups( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(adminPopupService.getPopups(page, size)); + } + + @GetMapping("/{popupId}") + public ResponseEntity getPopup(@PathVariable Long popupId) { + return ResponseEntity.ok(adminPopupService.getPopup(popupId)); + } + + @PostMapping("") + public ResponseEntity createPopup(@RequestBody @Valid CreatePopupRequest request) { + Long popupId = adminPopupService.createPopup(request); + return ResponseEntity.ok(PopupCreateResponse.builder().popupId(popupId).build()); + } + + @PutMapping("/{popupId}") + public ResponseEntity updatePopup(@PathVariable Long popupId, + @RequestBody @Valid UpdatePopupRequest request) { + adminPopupService.updatePopup(popupId, request); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/{popupId}/active") + public ResponseEntity updatePopupActive(@PathVariable Long popupId, + @RequestBody @Valid UpdatePopupActiveRequest request) { + adminPopupService.updatePopupActive(popupId, request); + return ResponseEntity.ok().build(); + } +} diff --git a/layer-admin/src/main/java/org/layer/admin/popup/controller/dto/AdminPopupRequest.java b/layer-admin/src/main/java/org/layer/admin/popup/controller/dto/AdminPopupRequest.java new file mode 100644 index 00000000..9bf79fd6 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/popup/controller/dto/AdminPopupRequest.java @@ -0,0 +1,55 @@ +package org.layer.admin.popup.controller.dto; + +import org.layer.domain.popup.entity.Popup; +import org.layer.domain.popup.entity.PopupIconType; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public class AdminPopupRequest { + + public record CreatePopupRequest( + @NotNull + PopupIconType iconType, + @NotNull + @Size(max = 30) + String title, + @NotNull + @Size(max = 800) + String content, + String moveButtonUrl, + @NotNull + Boolean isActive + ) { + public Popup toEntity() { + return Popup.builder() + .iconType(iconType) + .title(title) + .content(content) + .moveButtonUrl(moveButtonUrl) + .isActive(isActive) + .build(); + } + } + + public record UpdatePopupRequest( + @NotNull + PopupIconType iconType, + @NotNull + @Size(max = 30) + String title, + @NotNull + @Size(max = 800) + String content, + String moveButtonUrl, + @NotNull + Boolean isActive + ) { + } + + public record UpdatePopupActiveRequest( + @NotNull + Boolean isActive + ) { + } +} diff --git a/layer-admin/src/main/java/org/layer/admin/popup/controller/dto/AdminPopupResponse.java b/layer-admin/src/main/java/org/layer/admin/popup/controller/dto/AdminPopupResponse.java new file mode 100644 index 00000000..ac58d16b --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/popup/controller/dto/AdminPopupResponse.java @@ -0,0 +1,78 @@ +package org.layer.admin.popup.controller.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import org.layer.domain.popup.entity.Popup; +import org.layer.domain.popup.entity.PopupIconType; +import org.springframework.data.domain.Page; + +import lombok.Builder; + +public class AdminPopupResponse { + + @Builder + public record PopupCreateResponse( + Long popupId + ) { + } + + @Builder + public record PopupSummary( + Long id, + PopupIconType iconType, + String title, + Boolean isActive, + LocalDateTime createdAt + ) { + public static PopupSummary of(Popup popup) { + return PopupSummary.builder() + .id(popup.getId()) + .iconType(popup.getIconType()) + .title(popup.getTitle()) + .isActive(popup.getIsActive()) + .createdAt(popup.getCreatedAt()) + .build(); + } + } + + @Builder + public record PopupDetail( + Long id, + PopupIconType iconType, + String title, + String content, + String moveButtonUrl, + Boolean isActive, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public static PopupDetail of(Popup popup) { + return PopupDetail.builder() + .id(popup.getId()) + .iconType(popup.getIconType()) + .title(popup.getTitle()) + .content(popup.getContent()) + .moveButtonUrl(popup.getMoveButtonUrl()) + .isActive(popup.getIsActive()) + .createdAt(popup.getCreatedAt()) + .updatedAt(popup.getUpdatedAt()) + .build(); + } + } + + @Builder + public record PopupPage( + List popups, + long totalCount, + int totalPages + ) { + public static PopupPage of(Page page) { + return PopupPage.builder() + .popups(page.getContent().stream().map(PopupSummary::of).toList()) + .totalCount(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .build(); + } + } +} diff --git a/layer-admin/src/main/java/org/layer/admin/popup/service/AdminPopupService.java b/layer-admin/src/main/java/org/layer/admin/popup/service/AdminPopupService.java new file mode 100644 index 00000000..504de4b3 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/admin/popup/service/AdminPopupService.java @@ -0,0 +1,54 @@ +package org.layer.admin.popup.service; + +import org.layer.admin.popup.controller.dto.AdminPopupRequest.CreatePopupRequest; +import org.layer.admin.popup.controller.dto.AdminPopupRequest.UpdatePopupActiveRequest; +import org.layer.admin.popup.controller.dto.AdminPopupRequest.UpdatePopupRequest; +import org.layer.admin.popup.controller.dto.AdminPopupResponse.PopupDetail; +import org.layer.admin.popup.controller.dto.AdminPopupResponse.PopupPage; +import org.layer.domain.popup.entity.Popup; +import org.layer.domain.popup.repository.PopupRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminPopupService { + + private final PopupRepository popupRepository; + + public PopupPage getPopups(int page, int size) { + Page popups = popupRepository.findAll( + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); + return PopupPage.of(popups); + } + + public PopupDetail getPopup(Long popupId) { + Popup popup = popupRepository.findByIdOrThrow(popupId); + return PopupDetail.of(popup); + } + + @Transactional + public Long createPopup(CreatePopupRequest request) { + Popup popup = popupRepository.save(request.toEntity()); + return popup.getId(); + } + + @Transactional + public void updatePopup(Long popupId, UpdatePopupRequest request) { + Popup popup = popupRepository.findByIdOrThrow(popupId); + popup.update(request.iconType(), request.title(), request.content(), request.moveButtonUrl(), + request.isActive()); + } + + @Transactional + public void updatePopupActive(Long popupId, UpdatePopupActiveRequest request) { + Popup popup = popupRepository.findByIdOrThrow(popupId); + popup.updateActive(request.isActive()); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/notice/controller/dto/NoticeResponse.java b/layer-api/src/main/java/org/layer/domain/notice/controller/dto/NoticeResponse.java index b72d6ce7..6d5654b1 100644 --- a/layer-api/src/main/java/org/layer/domain/notice/controller/dto/NoticeResponse.java +++ b/layer-api/src/main/java/org/layer/domain/notice/controller/dto/NoticeResponse.java @@ -18,6 +18,8 @@ public record NoticeSummary( Long id, @Schema(description = "제목") String title, + @Schema(description = "본문") + String content, @Schema(description = "카테고리") NoticeCategory category, @Schema(description = "상단 고정 여부") @@ -33,6 +35,7 @@ public static NoticeSummary of(Notice notice) { return NoticeSummary.builder() .id(notice.getId()) .title(notice.getTitle()) + .content(notice.getContent()) .category(notice.getCategory()) .isPinned(notice.getIsPinned()) .startAt(notice.getStartAt()) diff --git a/layer-api/src/main/java/org/layer/domain/popup/controller/PopupController.java b/layer-api/src/main/java/org/layer/domain/popup/controller/PopupController.java new file mode 100644 index 00000000..b7162edd --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/popup/controller/PopupController.java @@ -0,0 +1,23 @@ +package org.layer.domain.popup.controller; + +import org.layer.domain.popup.controller.dto.PopupResponse.PopupListResponse; +import org.layer.domain.popup.service.PopupService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/popup") +public class PopupController { + + private final PopupService popupService; + + @GetMapping("/list") + public ResponseEntity getPopups() { + return ResponseEntity.ok(popupService.getExposedPopups()); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/popup/controller/dto/PopupResponse.java b/layer-api/src/main/java/org/layer/domain/popup/controller/dto/PopupResponse.java new file mode 100644 index 00000000..0e068e4c --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/popup/controller/dto/PopupResponse.java @@ -0,0 +1,49 @@ +package org.layer.domain.popup.controller.dto; + +import java.util.List; + +import org.layer.domain.popup.entity.Popup; +import org.layer.domain.popup.entity.PopupIconType; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +public class PopupResponse { + + @Builder + @Schema(description = "팝업 정보") + public record PopupElement( + @Schema(description = "팝업 아이디") + Long id, + @Schema(description = "아이콘 id", example = "default") + PopupIconType iconType, + @Schema(description = "제목") + String title, + @Schema(description = "내용") + String content, + @Schema(description = "이동 버튼 URL. null이면 이동 버튼 없이 확인 버튼만 노출") + String moveButtonUrl + ) { + public static PopupElement of(Popup popup) { + return PopupElement.builder() + .id(popup.getId()) + .iconType(popup.getIconType()) + .title(popup.getTitle()) + .content(popup.getContent()) + .moveButtonUrl(popup.getMoveButtonUrl()) + .build(); + } + } + + @Builder + @Schema + public record PopupListResponse( + List popups + ) { + public static PopupListResponse of(List popups) { + return PopupListResponse.builder() + .popups(popups.stream().map(PopupElement::of).toList()) + .build(); + } + } +} diff --git a/layer-api/src/main/java/org/layer/domain/popup/service/PopupService.java b/layer-api/src/main/java/org/layer/domain/popup/service/PopupService.java new file mode 100644 index 00000000..936a13a2 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/popup/service/PopupService.java @@ -0,0 +1,20 @@ +package org.layer.domain.popup.service; + +import org.layer.domain.popup.controller.dto.PopupResponse.PopupListResponse; +import org.layer.domain.popup.repository.PopupRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PopupService { + + private final PopupRepository popupRepository; + + public PopupListResponse getExposedPopups() { + return PopupListResponse.of(popupRepository.findAllByIsActiveTrueOrderByCreatedAtDesc()); + } +} diff --git a/layer-domain/src/main/java/org/layer/domain/popup/entity/Popup.java b/layer-domain/src/main/java/org/layer/domain/popup/entity/Popup.java new file mode 100644 index 00000000..2e0278ad --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/popup/entity/Popup.java @@ -0,0 +1,76 @@ +package org.layer.domain.popup.entity; + +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.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.layer.domain.common.BaseTimeEntity; + +@Getter +@Entity +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Popup extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @Enumerated(EnumType.STRING) + private PopupIconType iconType; + + @NotNull + private String title; + + @NotNull + @Column(columnDefinition = "LONGTEXT") + private String content; + + // 이동 버튼 노출 여부는 이 값의 존재 여부로 판단한다 (null이면 확인 버튼만 노출) + private String moveButtonUrl; + + @NotNull + private Boolean isActive; + + @Builder + public Popup(PopupIconType iconType, String title, String content, String moveButtonUrl, Boolean isActive) { + this.iconType = iconType; + this.title = title; + this.content = content; + this.moveButtonUrl = normalizeMoveButtonUrl(moveButtonUrl); + this.isActive = isActive; + } + + public void update(PopupIconType iconType, String title, String content, String moveButtonUrl, Boolean isActive) { + this.iconType = iconType; + this.title = title; + this.content = content; + this.moveButtonUrl = normalizeMoveButtonUrl(moveButtonUrl); + this.isActive = isActive; + } + + public void updateActive(Boolean isActive) { + this.isActive = isActive; + } + + // 관리자가 폼에 빈 문자열을 제출해도 "주소 없음"으로 동일하게 취급되도록 null로 정규화한다. + // 클라이언트는 moveButtonUrl != null 여부만으로 이동 버튼 노출을 판단하므로 여기서 통일해야 한다. + private String normalizeMoveButtonUrl(String moveButtonUrl) { + if (moveButtonUrl == null || moveButtonUrl.isBlank()) { + return null; + } + return moveButtonUrl; + } +} diff --git a/layer-domain/src/main/java/org/layer/domain/popup/entity/PopupIconType.java b/layer-domain/src/main/java/org/layer/domain/popup/entity/PopupIconType.java new file mode 100644 index 00000000..a2bedc21 --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/popup/entity/PopupIconType.java @@ -0,0 +1,36 @@ +package org.layer.domain.popup.entity; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +// FE에 사전 정의된 SVG 에셋과 매핑되는 아이콘 식별자. code가 API 계약값이므로 이름을 바꾸지 않는다. +public enum PopupIconType { + DEFAULT("default"), + IMPORTANT("important"), + MAINTENANCE("maintenance"), + UPDATE("update"), + EVENT("event"), + TIP("tip"), + SECURITY("security"); + + private final String code; + + PopupIconType(String code) { + this.code = code; + } + + @JsonValue + public String getCode() { + return code; + } + + @JsonCreator + public static PopupIconType from(String code) { + return Arrays.stream(values()) + .filter(type -> type.code.equalsIgnoreCase(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 아이콘 id입니다: " + code)); + } +} diff --git a/layer-domain/src/main/java/org/layer/domain/popup/exception/PopupException.java b/layer-domain/src/main/java/org/layer/domain/popup/exception/PopupException.java new file mode 100644 index 00000000..8f78d0a7 --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/popup/exception/PopupException.java @@ -0,0 +1,10 @@ +package org.layer.domain.popup.exception; + +import org.layer.common.exception.BaseCustomException; +import org.layer.common.exception.ExceptionType; + +public class PopupException extends BaseCustomException { + public PopupException(ExceptionType exceptionType) { + super(exceptionType); + } +} diff --git a/layer-domain/src/main/java/org/layer/domain/popup/repository/PopupRepository.java b/layer-domain/src/main/java/org/layer/domain/popup/repository/PopupRepository.java new file mode 100644 index 00000000..a2ff2a92 --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/popup/repository/PopupRepository.java @@ -0,0 +1,18 @@ +package org.layer.domain.popup.repository; + +import static org.layer.global.exception.PopupExceptionType.*; + +import java.util.List; + +import org.layer.domain.popup.entity.Popup; +import org.layer.domain.popup.exception.PopupException; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PopupRepository extends JpaRepository { + default Popup findByIdOrThrow(Long popupId) { + return findById(popupId) + .orElseThrow(() -> new PopupException(NOT_FOUND_POPUP)); + } + + List findAllByIsActiveTrueOrderByCreatedAtDesc(); +} diff --git a/layer-domain/src/main/java/org/layer/global/exception/PopupExceptionType.java b/layer-domain/src/main/java/org/layer/global/exception/PopupExceptionType.java new file mode 100644 index 00000000..d014dc35 --- /dev/null +++ b/layer-domain/src/main/java/org/layer/global/exception/PopupExceptionType.java @@ -0,0 +1,24 @@ +package org.layer.global.exception; + +import org.layer.common.exception.ExceptionType; +import org.springframework.http.HttpStatus; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum PopupExceptionType implements ExceptionType { + NOT_FOUND_POPUP(HttpStatus.NOT_FOUND, "찾을 수 없는 팝업입니다."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus httpStatus() { + return status; + } + + @Override + public String message() { + return message; + } +} diff --git a/scripts/migrations/2026-08-02_add_notice.sql b/scripts/migrations/2026-08-02_add_notice.sql deleted file mode 100644 index da39eaef..00000000 --- a/scripts/migrations/2026-08-02_add_notice.sql +++ /dev/null @@ -1,22 +0,0 @@ --- 공지사항(CMS) 기능 추가를 위한 스키마 변경 --- --- 이 프로젝트는 Flyway/Liquibase 같은 자동 마이그레이션 도구가 없고 --- hibernate.ddl-auto=validate 로 운영되므로, 새 애플리케이션을 배포하기 "전"에 --- 이 스크립트를 DB에 직접 실행해야 합니다. (순서: 이 SQL 실행 -> 배포) --- --- 실행 대상: layer_dev, layer_prod 각각에 동일하게 실행 - -CREATE TABLE notice ( - id BIGINT NOT NULL AUTO_INCREMENT, - title VARCHAR(255) NOT NULL, - content LONGTEXT NOT NULL, - category VARCHAR(20) NOT NULL, -- GENERAL / EVENT / UPDATE / MAINTENANCE - is_pinned TINYINT(1) NOT NULL DEFAULT 0, -- 상단 고정 여부 - is_active TINYINT(1) NOT NULL DEFAULT 1, -- 노출 On/Off - start_at DATETIME NULL, -- 노출 시작일시 (NULL이면 즉시 노출) - end_at DATETIME NULL, -- 노출 종료일시 (NULL이면 무기한 노출) - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - PRIMARY KEY (id), - KEY idx_notice_is_active_is_pinned (is_active, is_pinned) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;