From 40fd7a893ab2e514535d5e18736660a5331e4bf0 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Mon, 8 Dec 2025 22:43:56 +0900 Subject: [PATCH 01/10] feat(page): add AppPage, AppVersion, and related repositories for site management --- .../page/dto/response/AppPageResponse.java | 26 +++++++ .../domain/site/page/entity/AppPage.java | 64 +++++++++++++++++ .../domain/site/page/entity/AppVersion.java | 69 +++++++++++++++++++ .../site/page/entity/AppVersionPage.java | 42 +++++++++++ .../site/page/entity/AppVersionStatus.java | 7 ++ .../page/repository/AppPageRepository.java | 7 ++ .../repository/AppVersionPageRepository.java | 33 +++++++++ .../page/repository/AppVersionRepository.java | 23 +++++++ 8 files changed, 271 insertions(+) create mode 100644 src/main/java/redot/redot_server/domain/site/page/dto/response/AppPageResponse.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/entity/AppVersionStatus.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/repository/AppPageRepository.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/repository/AppVersionPageRepository.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/repository/AppVersionRepository.java diff --git a/src/main/java/redot/redot_server/domain/site/page/dto/response/AppPageResponse.java b/src/main/java/redot/redot_server/domain/site/page/dto/response/AppPageResponse.java new file mode 100644 index 0000000..3c876ff --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/dto/response/AppPageResponse.java @@ -0,0 +1,26 @@ +package redot.redot_server.domain.site.page.dto.response; + +import java.time.LocalDateTime; +import redot.redot_server.domain.site.page.entity.AppPage; + +public record AppPageResponse( + Long id, + String content, + String path, + String description, + boolean isProtected, + String title, + LocalDateTime createdAt +) { + public static AppPageResponse from(AppPage page) { + return new AppPageResponse( + page.getId(), + page.getContent(), + page.getPath(), + page.getDescription(), + page.getIsProtected(), + page.getTitle(), + page.getCreatedAt() + ); + } +} diff --git a/src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java b/src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java new file mode 100644 index 0000000..4f31e45 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/entity/AppPage.java @@ -0,0 +1,64 @@ +package redot.redot_server.domain.site.page.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import redot.redot_server.domain.redot.app.entity.RedotApp; +import redot.redot_server.global.common.entity.BaseTimeEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder(access = AccessLevel.PRIVATE) +@Table(name = "app_pages") +public class AppPage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "redot_app_id", nullable = false) + private RedotApp redotApp; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false, length = 255) + private String path; + + @Column(name = "is_protected", nullable = false) + private Boolean isProtected; + + private String description; + + @Column(nullable = false, length = 100) + private String title; + + public static AppPage create(RedotApp redotApp, + String content, + String path, + Boolean isProtected, + String description, + String title) { + return AppPage.builder() + .redotApp(redotApp) + .content(content) + .path(path) + .isProtected(isProtected) + .description(description) + .title(title) + .build(); + } +} diff --git a/src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java b/src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java new file mode 100644 index 0000000..2f78622 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java @@ -0,0 +1,69 @@ +package redot.redot_server.domain.site.page.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.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import redot.redot_server.domain.redot.app.entity.RedotApp; +import redot.redot_server.global.common.entity.BaseTimeEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder(access = AccessLevel.PRIVATE) +@Table(name = "app_versions") +public class AppVersion extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "redot_app_id", nullable = false) + private RedotApp redotApp; + + private String remark; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AppVersionStatus status; + + @Column(name = "published_app_id", unique = true) + private Long publishedAppId; + + @PrePersist + @PreUpdate + private void syncPublishedAppId() { + if (status == AppVersionStatus.PUBLISHED && redotApp != null) { + this.publishedAppId = redotApp.getId(); + } else { + this.publishedAppId = null; + } + } + + public static AppVersion create(RedotApp redotApp, AppVersionStatus status, String remark) { + return AppVersion.builder() + .redotApp(redotApp) + .status(status) + .remark(remark) + .build(); + } + + public void changeStatus(AppVersionStatus status) { + this.status = status; + syncPublishedAppId(); + } +} diff --git a/src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java b/src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java new file mode 100644 index 0000000..91b3896 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java @@ -0,0 +1,42 @@ +package redot.redot_server.domain.site.page.entity; + +import jakarta.persistence.Entity; +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.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import redot.redot_server.global.common.entity.BaseTimeEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder(access = AccessLevel.PRIVATE) +@Table(name = "app_version_pages", uniqueConstraints = { + @UniqueConstraint(columnNames = {"app_version_id", "app_page_id"}) +}) +public class AppVersionPage extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "app_version_id", nullable = false) + private AppVersion appVersion; + + @ManyToOne + @JoinColumn(name = "app_page_id", nullable = false) + private AppPage appPage; + + public static AppVersionPage create(AppVersion appVersion, AppPage appPage) { + return new AppVersionPage(null, appVersion, appPage); + } +} diff --git a/src/main/java/redot/redot_server/domain/site/page/entity/AppVersionStatus.java b/src/main/java/redot/redot_server/domain/site/page/entity/AppVersionStatus.java new file mode 100644 index 0000000..caf1b7a --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/entity/AppVersionStatus.java @@ -0,0 +1,7 @@ +package redot.redot_server.domain.site.page.entity; + +public enum AppVersionStatus { + PUBLISHED, + DRAFT, + PREVIOUS +} diff --git a/src/main/java/redot/redot_server/domain/site/page/repository/AppPageRepository.java b/src/main/java/redot/redot_server/domain/site/page/repository/AppPageRepository.java new file mode 100644 index 0000000..5f36b75 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/repository/AppPageRepository.java @@ -0,0 +1,7 @@ +package redot.redot_server.domain.site.page.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import redot.redot_server.domain.site.page.entity.AppPage; + +public interface AppPageRepository extends JpaRepository { +} diff --git a/src/main/java/redot/redot_server/domain/site/page/repository/AppVersionPageRepository.java b/src/main/java/redot/redot_server/domain/site/page/repository/AppVersionPageRepository.java new file mode 100644 index 0000000..dcdbc01 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/repository/AppVersionPageRepository.java @@ -0,0 +1,33 @@ +package redot.redot_server.domain.site.page.repository; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import redot.redot_server.domain.cms.site.page.dto.response.AppVersionPageSummaryResponse; +import redot.redot_server.domain.cms.site.page.service.dto.AppVersionPageSummaryWithVersionResponse; +import redot.redot_server.domain.site.page.entity.AppVersionPage; + +public interface AppVersionPageRepository extends JpaRepository { + + Optional findByAppVersionIdAndAppPage_Path(Long appVersionId, String path); + + List findAllByAppVersionId(Long appVersionId); + + @Query("select new redot.redot_server.domain.cms.site.page.dto.response.AppVersionPageSummaryResponse(" + + " ap.id, ap.path, ap.title, ap.description)" + + " from AppVersionPage avp" + + " join avp.appPage ap" + + " where avp.appVersion.id = :appVersionId") + List findSummariesByAppVersionId(@Param("appVersionId") Long appVersionId); + + @Query("select new redot.redot_server.domain.cms.site.page.service.dto.AppVersionPageSummaryWithVersionResponse(" + + " avp.appVersion.id, ap.id, ap.path, ap.title, ap.description)" + + " from AppVersionPage avp" + + " join avp.appPage ap" + + " where avp.appVersion.id in :appVersionIds") + List findSummariesByAppVersionIds(@Param("appVersionIds") List appVersionIds); + + boolean existsByAppVersion_RedotApp_IdAndAppPage_Id(Long redotAppId, Long appPageId); +} diff --git a/src/main/java/redot/redot_server/domain/site/page/repository/AppVersionRepository.java b/src/main/java/redot/redot_server/domain/site/page/repository/AppVersionRepository.java new file mode 100644 index 0000000..2209196 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/repository/AppVersionRepository.java @@ -0,0 +1,23 @@ +package redot.redot_server.domain.site.page.repository; + +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; +import redot.redot_server.domain.site.page.entity.AppVersion; +import redot.redot_server.domain.site.page.entity.AppVersionStatus; + +public interface AppVersionRepository extends JpaRepository { + + + Optional findByRedotAppIdAndStatus(Long redotAppId, AppVersionStatus status); + + @org.springframework.data.jpa.repository.Query("select av from AppVersion av where av.redotApp.id = :redotAppId and av.status = :status order by av.id desc") + Optional findFirstByRedotAppIdAndStatusOrderByIdDesc(@Param("redotAppId") Long redotAppId, + @Param("status") AppVersionStatus status); + + Page findByRedotAppIdAndStatus(Long redotAppId, AppVersionStatus status, Pageable pageable); + + Page findByRedotAppId(Long redotAppId, Pageable pageable); +} From 2e811cc5574384a8db2a78fc24e5e9e5d1992f5f Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Mon, 8 Dec 2025 22:44:13 +0900 Subject: [PATCH 02/10] feat(page): implement SitePageController and service for retrieving site pages --- .../page/controller/SitePageController.java | 26 ++++++++++++++ .../docs/SitePageControllerDocs.java | 23 +++++++++++++ .../site/page/service/SitePageService.java | 34 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 src/main/java/redot/redot_server/domain/site/page/controller/SitePageController.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/controller/docs/SitePageControllerDocs.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/service/SitePageService.java diff --git a/src/main/java/redot/redot_server/domain/site/page/controller/SitePageController.java b/src/main/java/redot/redot_server/domain/site/page/controller/SitePageController.java new file mode 100644 index 0000000..b809c06 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/controller/SitePageController.java @@ -0,0 +1,26 @@ +package redot.redot_server.domain.site.page.controller; + +import lombok.RequiredArgsConstructor; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import redot.redot_server.domain.site.page.controller.docs.SitePageControllerDocs; +import redot.redot_server.domain.site.page.dto.response.AppPageResponse; +import redot.redot_server.domain.site.page.service.SitePageService; +import redot.redot_server.global.redotapp.resolver.annotation.CurrentRedotApp; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/app/site/pages") +public class SitePageController implements SitePageControllerDocs { + + private final SitePageService sitePageService; + + @GetMapping + @Override + public ResponseEntity getSitePages(@CurrentRedotApp Long appId, @RequestParam(name = "path") String path) { + return ResponseEntity.ok(sitePageService.getSitePages(appId, path)); + } +} diff --git a/src/main/java/redot/redot_server/domain/site/page/controller/docs/SitePageControllerDocs.java b/src/main/java/redot/redot_server/domain/site/page/controller/docs/SitePageControllerDocs.java new file mode 100644 index 0000000..1b74d4a --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/controller/docs/SitePageControllerDocs.java @@ -0,0 +1,23 @@ +package redot.redot_server.domain.site.page.controller.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import redot.redot_server.domain.site.page.dto.response.AppPageResponse; + +@Tag(name = "Site Page", description = "사이트 공개 페이지 API") +public interface SitePageControllerDocs { + + @Operation(summary = "사이트 페이지 조회", description = "배포된 버전에서 path에 해당하는 페이지를 제공합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = AppPageResponse.class))) + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 사이트 앱의 서브도메인") + ResponseEntity getSitePages(@Parameter(hidden = true) Long appId, + @Parameter(name = "path", description = "페이지 경로", example = "/") String path); +} diff --git a/src/main/java/redot/redot_server/domain/site/page/service/SitePageService.java b/src/main/java/redot/redot_server/domain/site/page/service/SitePageService.java new file mode 100644 index 0000000..636d8b9 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/service/SitePageService.java @@ -0,0 +1,34 @@ +package redot.redot_server.domain.site.page.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import redot.redot_server.domain.site.page.dto.response.AppPageResponse; +import redot.redot_server.domain.site.page.entity.AppPage; +import redot.redot_server.domain.site.page.entity.AppVersion; +import redot.redot_server.domain.site.page.entity.AppVersionPage; +import redot.redot_server.domain.site.page.entity.AppVersionStatus; +import redot.redot_server.domain.site.page.exception.SitePageErrorCode; +import redot.redot_server.domain.site.page.exception.SitePageException; +import redot.redot_server.domain.site.page.repository.AppVersionPageRepository; +import redot.redot_server.domain.site.page.repository.AppVersionRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SitePageService { + + private final AppVersionRepository appVersionRepository; + private final AppVersionPageRepository appVersionPageRepository; + + public AppPageResponse getSitePages(Long appId, String path) { + AppVersion publishedVersion = appVersionRepository.findByRedotAppIdAndStatus(appId, AppVersionStatus.PUBLISHED) + .orElseThrow(() -> new SitePageException(SitePageErrorCode.PUBLISHED_VERSION_NOT_FOUND)); + + AppPage page = appVersionPageRepository.findByAppVersionIdAndAppPage_Path(publishedVersion.getId(), path) + .map(AppVersionPage::getAppPage) + .orElseThrow(() -> new SitePageException(SitePageErrorCode.PAGE_NOT_FOUND)); + + return AppPageResponse.from(page); + } +} From 0c0dc3675daa8f26425bf6aad90000a98bad386a Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Mon, 8 Dec 2025 22:45:04 +0900 Subject: [PATCH 03/10] feat(page): add DTOs and controller for managing site pages and versions --- .../controller/CMSSitePageController.java | 54 +++++ .../docs/CMSSitePageControllerDocs.java | 48 ++++ .../dto/request/AppPageCreateRequest.java | 13 ++ .../dto/request/AppPageRetainRequest.java | 8 + .../dto/request/AppVersionCreateRequest.java | 14 ++ .../AppVersionPageSummaryResponse.java | 9 + .../response/AppVersionSummaryResponse.java | 26 +++ .../site/page/service/CMSSitePageService.java | 215 ++++++++++++++++++ ...VersionPageSummaryWithVersionResponse.java | 16 ++ .../page/exception/SitePageErrorCode.java | 16 ++ .../page/exception/SitePageException.java | 9 + 11 files changed, 428 insertions(+) create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/controller/CMSSitePageController.java create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/controller/docs/CMSSitePageControllerDocs.java create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageCreateRequest.java create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageRetainRequest.java create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppVersionCreateRequest.java create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionPageSummaryResponse.java create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionSummaryResponse.java create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/service/dto/AppVersionPageSummaryWithVersionResponse.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/exception/SitePageErrorCode.java create mode 100644 src/main/java/redot/redot_server/domain/site/page/exception/SitePageException.java diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/controller/CMSSitePageController.java b/src/main/java/redot/redot_server/domain/cms/site/page/controller/CMSSitePageController.java new file mode 100644 index 0000000..bd0f958 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/controller/CMSSitePageController.java @@ -0,0 +1,54 @@ +package redot.redot_server.domain.cms.site.page.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import redot.redot_server.domain.cms.site.page.controller.docs.CMSSitePageControllerDocs; +import redot.redot_server.domain.cms.site.page.dto.request.AppVersionCreateRequest; +import redot.redot_server.domain.cms.site.page.dto.response.AppVersionSummaryResponse; +import redot.redot_server.domain.cms.site.page.service.CMSSitePageService; +import redot.redot_server.domain.site.page.dto.response.AppPageResponse; +import redot.redot_server.domain.site.page.entity.AppVersionStatus; +import redot.redot_server.global.redotapp.resolver.annotation.CurrentRedotApp; +import redot.redot_server.global.util.dto.response.PageResponse; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/app/cms/pages") +public class CMSSitePageController implements CMSSitePageControllerDocs { + + private final CMSSitePageService cmsSitePageService; + + @GetMapping("/versions") + @Override + public ResponseEntity> getAppVersions(@CurrentRedotApp Long redotAppId, + @RequestParam(name = "status", required = false) AppVersionStatus status, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) + Pageable pageable) { + return ResponseEntity.ok(cmsSitePageService.getAppVersions(redotAppId, status, pageable)); + } + + @GetMapping("/{pageId}") + @Override + public ResponseEntity getPage(@CurrentRedotApp Long redotAppId, + @PathVariable(name = "pageId") Long pageId) { + return ResponseEntity.ok(cmsSitePageService.getPage(redotAppId, pageId)); + } + + @PostMapping("/versions") + @Override + public ResponseEntity createVersion(@CurrentRedotApp Long redotAppId, + @Valid @RequestBody AppVersionCreateRequest request) { + return ResponseEntity.ok(cmsSitePageService.createAppVersion(redotAppId, request)); + } +} diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/controller/docs/CMSSitePageControllerDocs.java b/src/main/java/redot/redot_server/domain/cms/site/page/controller/docs/CMSSitePageControllerDocs.java new file mode 100644 index 0000000..d0798ed --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/controller/docs/CMSSitePageControllerDocs.java @@ -0,0 +1,48 @@ +package redot.redot_server.domain.cms.site.page.controller.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import redot.redot_server.domain.cms.site.page.dto.request.AppVersionCreateRequest; +import redot.redot_server.domain.cms.site.page.dto.response.AppVersionSummaryResponse; +import redot.redot_server.domain.site.page.dto.response.AppPageResponse; +import redot.redot_server.domain.site.page.entity.AppVersionStatus; +import redot.redot_server.global.util.dto.response.PageResponse; + +@Tag(name = "CMS Site Page", description = "CMS 페이지/버전 관리 API") +public interface CMSSitePageControllerDocs { + + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") + @Operation(summary = "페이지 버전 목록 조회", + description = "최신순으로 CMS 페이지 버전을 조회하며 status 파라미터로 PUBLISHED/DRAFT만 필터링할 수 있습니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = PageResponse.class))) + ResponseEntity> getAppVersions(@Parameter(hidden = true) Long redotAppId, + @Parameter(description = "필터링할 버전 상태", example = "PUBLISHED") AppVersionStatus status, + @ParameterObject Pageable pageable); + + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") + @Operation(summary = "특정 페이지 조회", description = "앱 내 분리된 페이지 정보를 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = AppPageResponse.class))) + ResponseEntity getPage(@Parameter(hidden = true) Long redotAppId, + @Parameter(name = "pageId", in = ParameterIn.PATH, description = "페이지 ID", example = "1", required = true) + Long pageId); + + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") + @Operation(summary = "페이지 버전 생성", description = "기존 페이지 유지 + 신규 페이지 추가로 새로운 DRAFT/PUBLISHED 버전을 생성합니다.") + @ApiResponse(responseCode = "200", description = "생성 성공", + content = @Content(schema = @Schema(implementation = AppVersionSummaryResponse.class))) + ResponseEntity createVersion(@Parameter(hidden = true) Long redotAppId, + AppVersionCreateRequest request); +} diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageCreateRequest.java b/src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageCreateRequest.java new file mode 100644 index 0000000..8edce74 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageCreateRequest.java @@ -0,0 +1,13 @@ +package redot.redot_server.domain.cms.site.page.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AppPageCreateRequest( + @NotBlank String content, + @NotBlank String path, + String description, + @NotNull Boolean isProtected, + @NotBlank String title +) { +} diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageRetainRequest.java b/src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageRetainRequest.java new file mode 100644 index 0000000..b2f88ba --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppPageRetainRequest.java @@ -0,0 +1,8 @@ +package redot.redot_server.domain.cms.site.page.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record AppPageRetainRequest( + @NotNull Long id +) { +} diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppVersionCreateRequest.java b/src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppVersionCreateRequest.java new file mode 100644 index 0000000..08ae789 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/dto/request/AppVersionCreateRequest.java @@ -0,0 +1,14 @@ +package redot.redot_server.domain.cms.site.page.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import redot.redot_server.domain.site.page.entity.AppVersionStatus; + +public record AppVersionCreateRequest( + @NotNull AppVersionStatus status, + String remark, + @Valid List retained, + @Valid List added +) { +} diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionPageSummaryResponse.java b/src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionPageSummaryResponse.java new file mode 100644 index 0000000..cb74528 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionPageSummaryResponse.java @@ -0,0 +1,9 @@ +package redot.redot_server.domain.cms.site.page.dto.response; + +public record AppVersionPageSummaryResponse( + Long id, + String path, + String title, + String description +) { +} diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionSummaryResponse.java b/src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionSummaryResponse.java new file mode 100644 index 0000000..0d41d1b --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/dto/response/AppVersionSummaryResponse.java @@ -0,0 +1,26 @@ +package redot.redot_server.domain.cms.site.page.dto.response; + +import java.time.LocalDateTime; +import java.util.List; +import redot.redot_server.domain.site.page.entity.AppVersion; +import redot.redot_server.domain.site.page.entity.AppVersionStatus; + +public record AppVersionSummaryResponse( + Long id, + AppVersionStatus status, + LocalDateTime createdAt, + String remark, + List pages +) { + + public static AppVersionSummaryResponse from(AppVersion version, + List pages) { + return new AppVersionSummaryResponse( + version.getId(), + version.getStatus(), + version.getCreatedAt(), + version.getRemark(), + pages + ); + } +} diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java b/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java new file mode 100644 index 0000000..b912c0e --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java @@ -0,0 +1,215 @@ +package redot.redot_server.domain.cms.site.page.service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import redot.redot_server.domain.cms.site.page.dto.request.AppPageCreateRequest; +import redot.redot_server.domain.cms.site.page.dto.request.AppPageRetainRequest; +import redot.redot_server.domain.cms.site.page.dto.request.AppVersionCreateRequest; +import redot.redot_server.domain.cms.site.page.dto.response.AppVersionPageSummaryResponse; +import redot.redot_server.domain.cms.site.page.dto.response.AppVersionSummaryResponse; +import redot.redot_server.domain.cms.site.page.service.dto.AppVersionPageSummaryWithVersionResponse; +import redot.redot_server.domain.cms.site.page.exception.CMSSitePageErrorCode; +import redot.redot_server.domain.cms.site.page.exception.CMSSitePageException; +import redot.redot_server.domain.redot.app.entity.RedotApp; +import redot.redot_server.domain.redot.app.exception.RedotAppErrorCode; +import redot.redot_server.domain.redot.app.exception.RedotAppException; +import redot.redot_server.domain.redot.app.repository.RedotAppRepository; +import redot.redot_server.domain.site.page.dto.response.AppPageResponse; +import redot.redot_server.domain.site.page.entity.AppPage; +import redot.redot_server.domain.site.page.entity.AppVersion; +import redot.redot_server.domain.site.page.entity.AppVersionPage; +import redot.redot_server.domain.site.page.entity.AppVersionStatus; +import redot.redot_server.domain.site.page.repository.AppPageRepository; +import redot.redot_server.domain.site.page.repository.AppVersionPageRepository; +import redot.redot_server.domain.site.page.repository.AppVersionRepository; +import redot.redot_server.global.util.dto.response.PageResponse; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CMSSitePageService { + + private final AppVersionRepository appVersionRepository; + private final AppVersionPageRepository appVersionPageRepository; + private final AppPageRepository appPageRepository; + private final RedotAppRepository redotAppRepository; + + public PageResponse getAppVersions(Long redotAppId, AppVersionStatus status, + Pageable pageable) { + Page versions = findVersions(redotAppId, status, pageable); + Map> pagesByVersionId = fetchPageSummaries(versions.getContent()); + return PageResponse.from(versions.map(version -> + AppVersionSummaryResponse.from( + version, + pagesByVersionId.getOrDefault(version.getId(), Collections.emptyList()) + ))); + } + + public AppPageResponse getPage(Long redotAppId, Long pageId) { + if (!appVersionPageRepository.existsByAppVersion_RedotApp_IdAndAppPage_Id(redotAppId, pageId)) { + throw new CMSSitePageException(CMSSitePageErrorCode.PAGE_NOT_BELONG_TO_APP); + } + AppPage page = appPageRepository.findById(pageId) + .orElseThrow(() -> new CMSSitePageException(CMSSitePageErrorCode.PAGE_NOT_FOUND)); + return AppPageResponse.from(page); + } + + @Transactional + public AppVersionSummaryResponse createAppVersion(Long redotAppId, AppVersionCreateRequest request) { + RedotApp redotApp = loadRedotApp(redotAppId); + validateStatus(request.status()); + + AppVersion savedVersion = createVersion(redotAppId, redotApp, request); + List versionPages = buildVersionPages(redotAppId, savedVersion, request); + if (!versionPages.isEmpty()) { + appVersionPageRepository.saveAll(versionPages); + } + + return AppVersionSummaryResponse.from(savedVersion, fetchPagesForVersion(savedVersion.getId())); + } + + private RedotApp loadRedotApp(Long redotAppId) { + return redotAppRepository.findById(redotAppId) + .orElseThrow(() -> new RedotAppException(RedotAppErrorCode.REDOT_APP_NOT_FOUND)); + } + + private AppVersion createVersion(Long redotAppId, + RedotApp redotApp, + AppVersionCreateRequest request) { + boolean publishRequested = request.status() == AppVersionStatus.PUBLISHED; + if (publishRequested) { + appVersionRepository.findFirstByRedotAppIdAndStatusOrderByIdDesc(redotAppId, AppVersionStatus.PUBLISHED) + .ifPresent(existing -> existing.changeStatus(AppVersionStatus.PREVIOUS)); + } + + AppVersionStatus initialStatus = publishRequested ? AppVersionStatus.DRAFT : request.status(); + AppVersion savedVersion = appVersionRepository.save( + AppVersion.create(redotApp, initialStatus, request.remark()) + ); + if (publishRequested) { + savedVersion.changeStatus(AppVersionStatus.PUBLISHED); + } + return savedVersion; + } + + private List buildVersionPages(Long redotAppId, + AppVersion version, + AppVersionCreateRequest request) { + Set usedPaths = new HashSet<>(); + List versionPages = new ArrayList<>(); + versionPages.addAll(retainedPages(redotAppId, version, request.retained(), usedPaths)); + versionPages.addAll(addedPages(version, request.added(), usedPaths)); + return versionPages; + } + + private List retainedPages(Long redotAppId, + AppVersion version, + List retained, + Set usedPaths) { + if (retained == null || retained.isEmpty()) { + return Collections.emptyList(); + } + List ids = retained.stream() + .map(AppPageRetainRequest::id) + .filter(Objects::nonNull) + .toList(); + if (ids.isEmpty()) { + return Collections.emptyList(); + } + + Map pagesById = appPageRepository.findAllById(ids).stream() + .collect(Collectors.toMap(AppPage::getId, page -> page)); + + List result = new ArrayList<>(); + for (Long id : ids) { + AppPage page = pagesById.get(id); + if (page == null) { + throw new CMSSitePageException(CMSSitePageErrorCode.RETAINED_PAGE_NOT_FOUND); + } + Long ownerAppId = page.getRedotApp() != null ? page.getRedotApp().getId() : null; + if (ownerAppId == null || !ownerAppId.equals(redotAppId)) { + throw new CMSSitePageException(CMSSitePageErrorCode.PAGE_NOT_BELONG_TO_APP); + } + result.add(AppVersionPage.create(version, page)); + if (!usedPaths.add(page.getPath())) { + throw new CMSSitePageException(CMSSitePageErrorCode.PAGE_PATH_DUPLICATED); + } + } + return result; + } + + private List addedPages(AppVersion version, + List added, + Set usedPaths) { + if (added == null || added.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + RedotApp redotApp = version.getRedotApp(); + for (AppPageCreateRequest request : added) { + if (!usedPaths.add(request.path())) { + throw new CMSSitePageException(CMSSitePageErrorCode.PAGE_PATH_DUPLICATED); + } + AppPage page = AppPage.create( + redotApp, + request.content(), + request.path(), + request.isProtected(), + request.description(), + request.title() + ); + AppPage savedPage = appPageRepository.save(page); + result.add(AppVersionPage.create(version, savedPage)); + } + return result; + } + + private void validateStatus(AppVersionStatus status) { + if (status == null || status == AppVersionStatus.PREVIOUS) { + throw new CMSSitePageException(CMSSitePageErrorCode.INVALID_VERSION_STATUS); + } + } + + private Map> fetchPageSummaries(List versions) { + if (versions == null || versions.isEmpty()) { + return Collections.emptyMap(); + } + List ids = versions.stream() + .map(AppVersion::getId) + .filter(Objects::nonNull) + .toList(); + if (ids.isEmpty()) { + return Collections.emptyMap(); + } + return appVersionPageRepository.findSummariesByAppVersionIds(ids).stream() + .collect(Collectors.groupingBy( + AppVersionPageSummaryWithVersionResponse::appVersionId, + Collectors.mapping(AppVersionPageSummaryWithVersionResponse::toSummary, Collectors.toList()) + )); + } + + private List fetchPagesForVersion(Long versionId) { + return appVersionPageRepository.findSummariesByAppVersionId(versionId); + } + + private Page findVersions(Long redotAppId, + AppVersionStatus status, + Pageable pageable) { + if (status == null) { + return appVersionRepository.findByRedotAppId(redotAppId, pageable); + } + return appVersionRepository.findByRedotAppIdAndStatus(redotAppId, status, pageable); + } + +} diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/service/dto/AppVersionPageSummaryWithVersionResponse.java b/src/main/java/redot/redot_server/domain/cms/site/page/service/dto/AppVersionPageSummaryWithVersionResponse.java new file mode 100644 index 0000000..baae05e --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/service/dto/AppVersionPageSummaryWithVersionResponse.java @@ -0,0 +1,16 @@ +package redot.redot_server.domain.cms.site.page.service.dto; + +import redot.redot_server.domain.cms.site.page.dto.response.AppVersionPageSummaryResponse; + +public record AppVersionPageSummaryWithVersionResponse( + Long appVersionId, + Long id, + String path, + String title, + String description +) { + + public AppVersionPageSummaryResponse toSummary() { + return new AppVersionPageSummaryResponse(id, path, title, description); + } +} diff --git a/src/main/java/redot/redot_server/domain/site/page/exception/SitePageErrorCode.java b/src/main/java/redot/redot_server/domain/site/page/exception/SitePageErrorCode.java new file mode 100644 index 0000000..e05e5e3 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/exception/SitePageErrorCode.java @@ -0,0 +1,16 @@ +package redot.redot_server.domain.site.page.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import redot.redot_server.global.exception.ErrorCode; + +@Getter +@AllArgsConstructor +public enum SitePageErrorCode implements ErrorCode { + PUBLISHED_VERSION_NOT_FOUND(404, 3100, "배포된 페이지 버전을 찾을 수 없습니다."), + PAGE_NOT_FOUND(404, 3101, "요청한 경로의 페이지를 찾을 수 없습니다."); + + private final int statusCode; + private final int exceptionCode; + private final String message; +} diff --git a/src/main/java/redot/redot_server/domain/site/page/exception/SitePageException.java b/src/main/java/redot/redot_server/domain/site/page/exception/SitePageException.java new file mode 100644 index 0000000..c8929ec --- /dev/null +++ b/src/main/java/redot/redot_server/domain/site/page/exception/SitePageException.java @@ -0,0 +1,9 @@ +package redot.redot_server.domain.site.page.exception; + +import redot.redot_server.global.exception.BaseException; + +public class SitePageException extends BaseException { + public SitePageException(SitePageErrorCode errorCode) { + super(errorCode); + } +} From 430b919a279192200641ed397486f2c2d0439432 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Mon, 8 Dec 2025 22:45:21 +0900 Subject: [PATCH 04/10] docs(swagger): add X-App-Subdomain parameter to CMS API endpoints --- .../controller/docs/CMSAuthControllerDocs.java | 9 +++++++++ .../docs/RedotAppInquiryControllerDocs.java | 11 +++++++++++ .../docs/CMSMemberControllerDocs.java | 17 +++++++++++++++++ .../controller/docs/CMSMenuControllerDocs.java | 3 +++ .../docs/SiteSettingControllerDocs.java | 7 +++++++ .../docs/StyleInfoControllerDocs.java | 5 +++++ .../controller/docs/RedotAppControllerDocs.java | 3 +++ 7 files changed, 55 insertions(+) diff --git a/src/main/java/redot/redot_server/domain/auth/controller/docs/CMSAuthControllerDocs.java b/src/main/java/redot/redot_server/domain/auth/controller/docs/CMSAuthControllerDocs.java index 8ecfa5a..5d46c43 100644 --- a/src/main/java/redot/redot_server/domain/auth/controller/docs/CMSAuthControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/auth/controller/docs/CMSAuthControllerDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -17,6 +18,8 @@ @Tag(name = "CMS Auth", description = "CMS 인증 API") public interface CMSAuthControllerDocs { + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 로그인", description = "CMS 계정으로 로그인하여 액세스/리프레시 토큰을 발급합니다.") @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = TokenResponse.class))) @@ -24,12 +27,16 @@ ResponseEntity signIn(@Parameter(hidden = true) HttpServletReques SignInRequest signInRequest, @Parameter(hidden = true) Long redotAppId); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 토큰 재발급", description = "요청 쿠키에 포함된 리프레시 토큰으로 새 토큰을 발급합니다.") @ApiResponse(responseCode = "200", description = "재발급 성공", content = @Content(schema = @Schema(implementation = TokenResponse.class))) ResponseEntity reissueToken(@Parameter(hidden = true) Long redotAppId, @Parameter(hidden = true) HttpServletRequest request); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "현재 CMS 멤버 정보 조회", description = "인증된 CMS 멤버의 상세 정보를 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = CMSMemberResponse.class))) @@ -40,6 +47,8 @@ ResponseEntity getCurrentCMSMemberInfo(@Parameter(hidden = tr @ApiResponse(responseCode = "204", description = "로그아웃 성공") ResponseEntity signOut(@Parameter(hidden = true) HttpServletRequest request); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 비밀번호 재설정 확정", description = "발급받은 인증 코드로 비밀번호 재설정을 확정합니다.") @ApiResponse(responseCode = "204", description = "재설정 완료") ResponseEntity confirmPasswordReset(@Parameter(hidden = true) Long redotAppId, diff --git a/src/main/java/redot/redot_server/domain/cms/inquiry/controller/docs/RedotAppInquiryControllerDocs.java b/src/main/java/redot/redot_server/domain/cms/inquiry/controller/docs/RedotAppInquiryControllerDocs.java index d18e23c..7295e1a 100644 --- a/src/main/java/redot/redot_server/domain/cms/inquiry/controller/docs/RedotAppInquiryControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/cms/inquiry/controller/docs/RedotAppInquiryControllerDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -18,18 +19,24 @@ @Tag(name = "CMS Inquiry", description = "CMS 문의 관리 API") public interface RedotAppInquiryControllerDocs { + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "문의 생성", description = "CMS 앱에 새 문의를 등록합니다.") @ApiResponse(responseCode = "200", description = "생성 성공", content = @Content(schema = @Schema(implementation = RedotAppInquiryResponse.class))) ResponseEntity createInquiry(@Parameter(hidden = true) Long redotAppId, RedotAppInquiryCreateRequest request); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "문의 단건 조회", description = "문의 ID로 상세 정보를 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = RedotAppInquiryResponse.class))) ResponseEntity getInquiry(@Parameter(hidden = true) Long redotAppId, @Parameter(description = "문의 ID", example = "1") Long inquiryId); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "문의 목록 조회", description = "`status`, `inquiryNumber`, `title`, `inquirerName`, `startDate`, `endDate` 검색 조건과 `page`/`size`/`sort=createdAt,desc` 쿼리 파라미터로 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = PageResponse.class))) @@ -38,12 +45,16 @@ ResponseEntity> getAllInquiries(@Parameter @Parameter(description = "기본 정렬은 createdAt DESC 입니다.") @ParameterObject Pageable pageable); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "문의 처리 완료", description = "문의 상태를 완료로 변경합니다.") @ApiResponse(responseCode = "200", description = "상태 변경 완료") ResponseEntity markInquiryAsCompleted(@Parameter(hidden = true) Long redotAppId, @Parameter(description = "문의 ID", example = "1") Long inquiryId, @Parameter(hidden = true) JwtPrincipal jwtPrincipal); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "문의 재오픈", description = "완료된 문의를 다시 열어 진행합니다.") @ApiResponse(responseCode = "200", description = "재오픈 완료") ResponseEntity reopenInquiry(@Parameter(hidden = true) Long redotAppId, diff --git a/src/main/java/redot/redot_server/domain/cms/member/controller/docs/CMSMemberControllerDocs.java b/src/main/java/redot/redot_server/domain/cms/member/controller/docs/CMSMemberControllerDocs.java index b898753..6d60422 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/controller/docs/CMSMemberControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/cms/member/controller/docs/CMSMemberControllerDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -23,18 +24,24 @@ @Tag(name = "CMS Member", description = "CMS 멤버 관리 API") public interface CMSMemberControllerDocs { + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 멤버 생성", description = "CMS 앱에 새로운 멤버를 추가합니다.") @ApiResponse(responseCode = "200", description = "생성 성공", content = @Content(schema = @Schema(implementation = CMSMemberResponse.class))) ResponseEntity createCMSMember(@Parameter(hidden = true) Long redotAppId, CMSMemberCreateRequest request); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 멤버 단건 조회", description = "멤버 ID 기준으로 정보를 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = CMSMemberResponse.class))) ResponseEntity getCMSMemberInfo(@Parameter(hidden = true) Long redotAppId, @Parameter(description = "멤버 ID", example = "1") Long memberId); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 멤버 목록 조회", description = "`name`, `email`, `role` 검색 조건과 `page`/`size`/`sort=createdAt,desc` 파라미터로 멤버를 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = PageResponse.class))) @@ -43,6 +50,8 @@ ResponseEntity> getCMSMemberList(@Parameter(hidd @Parameter(description = "기본 정렬 createdAt DESC") @ParameterObject Pageable pageable); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 멤버 권한 변경", description = "멤버 권한을 관리자/멤버로 변경합니다.") @ApiResponse(responseCode = "200", description = "변경 성공", content = @Content(schema = @Schema(implementation = CMSMemberResponse.class))) @@ -50,6 +59,8 @@ ResponseEntity changeCMSMemberRole(@Parameter(hidden = true) @Parameter(description = "멤버 ID", example = "1") Long memberId, CMSMemberRoleRequest request); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 멤버 정보 수정", description = "본인 정보를 수정합니다.") @ApiResponse(responseCode = "200", description = "수정 성공", content = @Content(schema = @Schema(implementation = CMSMemberResponse.class))) @@ -57,18 +68,24 @@ ResponseEntity updateCMSMember(@Parameter(hidden = true) Long @Parameter(hidden = true) JwtPrincipal jwtPrincipal, CMSMemberUpdateRequest request); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 멤버 삭제", description = "특정 멤버를 삭제합니다.") @ApiResponse(responseCode = "204", description = "삭제 완료") ResponseEntity deleteCMSMember(@Parameter(hidden = true) Long redotAppId, @Parameter(hidden = true) JwtPrincipal jwtPrincipal, @Parameter(description = "멤버 ID", example = "1") Long memberId); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "현재 CMS 멤버 탈퇴", description = "본인 계정을 삭제하고 토큰 쿠키를 제거합니다.") @ApiResponse(responseCode = "204", description = "탈퇴 완료") ResponseEntity deleteCurrentCMSMember(@Parameter(hidden = true) HttpServletRequest request, @Parameter(hidden = true) Long redotAppId, @Parameter(hidden = true) JwtPrincipal jwtPrincipal); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 멤버 프로필 이미지 업로드", description = "CMS 멤버 이미지 파일을 업로드합니다.") @ApiResponse(responseCode = "200", description = "업로드 성공", content = @Content(schema = @Schema(implementation = UploadedImageUrlResponse.class))) diff --git a/src/main/java/redot/redot_server/domain/cms/site/menu/controller/docs/CMSMenuControllerDocs.java b/src/main/java/redot/redot_server/domain/cms/site/menu/controller/docs/CMSMenuControllerDocs.java index 5ec4a20..2f1909e 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/menu/controller/docs/CMSMenuControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/cms/site/menu/controller/docs/CMSMenuControllerDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -13,6 +14,8 @@ @Tag(name = "CMS Menu", description = "CMS 메뉴 관리 API") public interface CMSMenuControllerDocs { + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "CMS 메뉴 목록 조회", description = "선택한 앱의 메뉴 구성을 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = CMSMenuResponse.class)))) diff --git a/src/main/java/redot/redot_server/domain/cms/site/setting/controller/docs/SiteSettingControllerDocs.java b/src/main/java/redot/redot_server/domain/cms/site/setting/controller/docs/SiteSettingControllerDocs.java index dd1480e..3ce10fa 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/setting/controller/docs/SiteSettingControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/cms/site/setting/controller/docs/SiteSettingControllerDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -15,18 +16,24 @@ @Tag(name = "Site Setting", description = "사이트 설정 API") public interface SiteSettingControllerDocs { + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "사이트 설정 수정", description = "CMS 앱의 사이트 기본 정보를 수정합니다.") @ApiResponse(responseCode = "200", description = "수정 성공", content = @Content(schema = @Schema(implementation = SiteSettingResponse.class))) ResponseEntity updateSiteSetting(@Parameter(hidden = true) Long redotAppId, SiteSettingUpdateRequest request); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "사이트 로고 업로드", description = "사이트 로고 이미지를 업로드합니다.") @ApiResponse(responseCode = "200", description = "업로드 성공", content = @Content(schema = @Schema(implementation = UploadedImageUrlResponse.class))) ResponseEntity uploadLogoImage(@Parameter(hidden = true) Long redotAppId, MultipartFile logoFile); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "사이트 설정 조회", description = "현재 CMS 앱의 사이트 설정을 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = SiteSettingResponse.class))) diff --git a/src/main/java/redot/redot_server/domain/cms/site/style/controller/docs/StyleInfoControllerDocs.java b/src/main/java/redot/redot_server/domain/cms/site/style/controller/docs/StyleInfoControllerDocs.java index 5d7ef60..365d3cc 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/style/controller/docs/StyleInfoControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/cms/site/style/controller/docs/StyleInfoControllerDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -13,11 +14,15 @@ @Tag(name = "Style Info", description = "스타일 정보 API") public interface StyleInfoControllerDocs { + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "스타일 정보 조회", description = "CMS 앱의 스타일 테마 정보를 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = StyleInfoResponse.class))) ResponseEntity getStyleInfo(@Parameter(hidden = true) Long redotAppId); + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 CMS 앱의 서브도메인") @Operation(summary = "스타일 정보 수정", description = "스타일 정보를 수정합니다.") @ApiResponse(responseCode = "200", description = "수정 성공", content = @Content(schema = @Schema(implementation = StyleInfoResponse.class))) diff --git a/src/main/java/redot/redot_server/domain/redot/app/controller/docs/RedotAppControllerDocs.java b/src/main/java/redot/redot_server/domain/redot/app/controller/docs/RedotAppControllerDocs.java index 601271f..7887fae 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/controller/docs/RedotAppControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/redot/app/controller/docs/RedotAppControllerDocs.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -18,6 +19,8 @@ @Tag(name = "Redot App", description = "Redot 앱 API") public interface RedotAppControllerDocs { + @Parameter(name = "X-App-Subdomain", in = ParameterIn.HEADER, required = true, + description = "요청 대상 앱의 서브도메인") @Operation(summary = "앱 기본 정보 조회", description = "현재 도메인에 매핑된 Redot 앱 정보를 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = RedotAppInfoResponse.class))) From 48eefc71f48550e52fc4e3530429f1f431f6cd53 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Mon, 8 Dec 2025 22:45:29 +0900 Subject: [PATCH 05/10] feat(security): add public security filter chain for site API endpoints --- .../global/security/config/SecurityConfig.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/redot/redot_server/global/security/config/SecurityConfig.java b/src/main/java/redot/redot_server/global/security/config/SecurityConfig.java index 8f05bcc..61e8f90 100644 --- a/src/main/java/redot/redot_server/global/security/config/SecurityConfig.java +++ b/src/main/java/redot/redot_server/global/security/config/SecurityConfig.java @@ -221,4 +221,15 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + @Order(-3) + public SecurityFilterChain sitePublicChain(HttpSecurity http, + RedotAppFilter redotAppFilter) throws Exception { + applyCommonSecurity(http); + http.securityMatcher("/api/v1/app/site/**") + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .addFilterBefore(redotAppFilter, SecurityContextHolderFilter.class); + return http.build(); + } + } From a9e4b73d4e2efeb6db99ff77b26e83e0eabf7436 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Mon, 8 Dec 2025 22:45:40 +0900 Subject: [PATCH 06/10] feat(exception): add custom exceptions and error codes for CMS site pages --- .../page/exception/CMSSitePageErrorCode.java | 19 +++++++++++++++++++ .../page/exception/CMSSitePageException.java | 9 +++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java create mode 100644 src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageException.java diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java b/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java new file mode 100644 index 0000000..7500334 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java @@ -0,0 +1,19 @@ +package redot.redot_server.domain.cms.site.page.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import redot.redot_server.global.exception.ErrorCode; + +@Getter +@AllArgsConstructor +public enum CMSSitePageErrorCode implements ErrorCode { + PAGE_NOT_FOUND(404, 3200, "페이지를 찾을 수 없습니다."), + PAGE_NOT_BELONG_TO_APP(403, 3201, "요청한 페이지가 해당 앱에 속하지 않습니다."), + INVALID_VERSION_STATUS(400, 3202, "허용되지 않은 버전 상태입니다."), + RETAINED_PAGE_NOT_FOUND(404, 3203, "유지하려는 페이지를 찾을 수 없습니다."), + PAGE_PATH_DUPLICATED(400, 3204, "해당 경로의 페이지가 이미 존재합니다."); + + private final int statusCode; + private final int exceptionCode; + private final String message; +} diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageException.java b/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageException.java new file mode 100644 index 0000000..8129060 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageException.java @@ -0,0 +1,9 @@ +package redot.redot_server.domain.cms.site.page.exception; + +import redot.redot_server.global.exception.BaseException; + +public class CMSSitePageException extends BaseException { + public CMSSitePageException(CMSSitePageErrorCode errorCode) { + super(errorCode); + } +} From 583261882151f1cac208751d0ed628b1748dc09c Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Mon, 8 Dec 2025 22:48:21 +0900 Subject: [PATCH 07/10] feat(database): create tables for managing site pages and versions --- .../db/migration/V3__create_site_pages.sql | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/resources/db/migration/V3__create_site_pages.sql diff --git a/src/main/resources/db/migration/V3__create_site_pages.sql b/src/main/resources/db/migration/V3__create_site_pages.sql new file mode 100644 index 0000000..ec6d26b --- /dev/null +++ b/src/main/resources/db/migration/V3__create_site_pages.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS app_pages +( + id BIGSERIAL PRIMARY KEY, + redot_app_id BIGINT NOT NULL REFERENCES redot_apps (id), + content TEXT NOT NULL, + path VARCHAR(255) NOT NULL, + is_protected BOOLEAN NOT NULL, + description TEXT, + title VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS app_versions +( + id BIGSERIAL PRIMARY KEY, + redot_app_id BIGINT NOT NULL REFERENCES redot_apps (id), + remark TEXT, + status VARCHAR(50) NOT NULL, + published_app_id BIGINT UNIQUE REFERENCES redot_apps (id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE app_versions + ADD CONSTRAINT ck_app_versions_status CHECK (status IN ('PUBLISHED', 'DRAFT', 'PREVIOUS')); + +CREATE TABLE IF NOT EXISTS app_version_pages +( + id BIGSERIAL PRIMARY KEY, + app_version_id BIGINT NOT NULL REFERENCES app_versions (id) ON DELETE CASCADE, + app_page_id BIGINT NOT NULL REFERENCES app_pages (id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_app_version_page UNIQUE (app_version_id, app_page_id) +); From 74d68c80df3b2c8108cc35a547f23314b1102bef Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Mon, 8 Dec 2025 23:11:15 +0900 Subject: [PATCH 08/10] feat(version): refactor AppVersion handling and add unique constraint for published versions --- .../page/exception/CMSSitePageErrorCode.java | 3 ++- .../site/page/service/CMSSitePageService.java | 15 ++++++++++++--- .../domain/site/page/entity/AppVersion.java | 16 ---------------- .../db/migration/V3__create_site_pages.sql | 7 +++++-- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java b/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java index 7500334..052fa62 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java +++ b/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java @@ -11,7 +11,8 @@ public enum CMSSitePageErrorCode implements ErrorCode { PAGE_NOT_BELONG_TO_APP(403, 3201, "요청한 페이지가 해당 앱에 속하지 않습니다."), INVALID_VERSION_STATUS(400, 3202, "허용되지 않은 버전 상태입니다."), RETAINED_PAGE_NOT_FOUND(404, 3203, "유지하려는 페이지를 찾을 수 없습니다."), - PAGE_PATH_DUPLICATED(400, 3204, "해당 경로의 페이지가 이미 존재합니다."); + PAGE_PATH_DUPLICATED(400, 3204, "해당 경로의 페이지가 이미 존재합니다."), + PUBLISHED_VERSION_ALREADY_EXISTS(400, 3205, "이미 배포된 버전이 존재합니다."); private final int statusCode; private final int exceptionCode; diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java b/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java index b912c0e..940b2ab 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java +++ b/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java @@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import redot.redot_server.domain.cms.site.page.dto.request.AppPageCreateRequest; @@ -94,15 +95,23 @@ private AppVersion createVersion(Long redotAppId, } AppVersionStatus initialStatus = publishRequested ? AppVersionStatus.DRAFT : request.status(); - AppVersion savedVersion = appVersionRepository.save( - AppVersion.create(redotApp, initialStatus, request.remark()) - ); + AppVersion newVersion = AppVersion.create(redotApp, initialStatus, request.remark()); + AppVersion savedVersion = saveVersion(newVersion); if (publishRequested) { savedVersion.changeStatus(AppVersionStatus.PUBLISHED); + saveVersion(savedVersion); } return savedVersion; } + private AppVersion saveVersion(AppVersion version) { + try { + return appVersionRepository.save(version); + } catch (DataIntegrityViolationException e) { + throw new CMSSitePageException(CMSSitePageErrorCode.PUBLISHED_VERSION_ALREADY_EXISTS); + } + } + private List buildVersionPages(Long redotAppId, AppVersion version, AppVersionCreateRequest request) { diff --git a/src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java b/src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java index 2f78622..8db6196 100644 --- a/src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java +++ b/src/main/java/redot/redot_server/domain/site/page/entity/AppVersion.java @@ -9,8 +9,6 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -41,19 +39,6 @@ public class AppVersion extends BaseTimeEntity { @Enumerated(EnumType.STRING) private AppVersionStatus status; - @Column(name = "published_app_id", unique = true) - private Long publishedAppId; - - @PrePersist - @PreUpdate - private void syncPublishedAppId() { - if (status == AppVersionStatus.PUBLISHED && redotApp != null) { - this.publishedAppId = redotApp.getId(); - } else { - this.publishedAppId = null; - } - } - public static AppVersion create(RedotApp redotApp, AppVersionStatus status, String remark) { return AppVersion.builder() .redotApp(redotApp) @@ -64,6 +49,5 @@ public static AppVersion create(RedotApp redotApp, AppVersionStatus status, Stri public void changeStatus(AppVersionStatus status) { this.status = status; - syncPublishedAppId(); } } diff --git a/src/main/resources/db/migration/V3__create_site_pages.sql b/src/main/resources/db/migration/V3__create_site_pages.sql index ec6d26b..e13de45 100644 --- a/src/main/resources/db/migration/V3__create_site_pages.sql +++ b/src/main/resources/db/migration/V3__create_site_pages.sql @@ -17,7 +17,6 @@ CREATE TABLE IF NOT EXISTS app_versions redot_app_id BIGINT NOT NULL REFERENCES redot_apps (id), remark TEXT, status VARCHAR(50) NOT NULL, - published_app_id BIGINT UNIQUE REFERENCES redot_apps (id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -28,9 +27,13 @@ ALTER TABLE app_versions CREATE TABLE IF NOT EXISTS app_version_pages ( id BIGSERIAL PRIMARY KEY, - app_version_id BIGINT NOT NULL REFERENCES app_versions (id) ON DELETE CASCADE, + app_version_id BIGINT NOT NULL REFERENCES app_versions (id) ON DELETE RESTRICT, app_page_id BIGINT NOT NULL REFERENCES app_pages (id) ON DELETE CASCADE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uk_app_version_page UNIQUE (app_version_id, app_page_id) ); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_app_versions_published_per_app + ON app_versions (redot_app_id) + WHERE status = 'PUBLISHED'; From 717e36ebbb5ddab38a04b8853d7cf72c3968eaf0 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Mon, 8 Dec 2025 23:14:47 +0900 Subject: [PATCH 09/10] feat(app-version): add path attribute and enforce unique constraints for version pages --- .../page/exception/CMSSitePageErrorCode.java | 2 +- .../site/page/service/CMSSitePageService.java | 36 +++++++++++++------ .../site/page/entity/AppVersionPage.java | 9 +++-- .../db/migration/V3__create_site_pages.sql | 4 ++- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java b/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java index 052fa62..ae4f883 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java +++ b/src/main/java/redot/redot_server/domain/cms/site/page/exception/CMSSitePageErrorCode.java @@ -11,7 +11,7 @@ public enum CMSSitePageErrorCode implements ErrorCode { PAGE_NOT_BELONG_TO_APP(403, 3201, "요청한 페이지가 해당 앱에 속하지 않습니다."), INVALID_VERSION_STATUS(400, 3202, "허용되지 않은 버전 상태입니다."), RETAINED_PAGE_NOT_FOUND(404, 3203, "유지하려는 페이지를 찾을 수 없습니다."), - PAGE_PATH_DUPLICATED(400, 3204, "해당 경로의 페이지가 이미 존재합니다."), + PAGE_PATH_DUPLICATED(400, 3204, "현재 버전에 중복되는 경로의 페이지가 존재합니다."), PUBLISHED_VERSION_ALREADY_EXISTS(400, 3205, "이미 배포된 버전이 존재합니다."); private final int statusCode; diff --git a/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java b/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java index 940b2ab..2659ee9 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java +++ b/src/main/java/redot/redot_server/domain/cms/site/page/service/CMSSitePageService.java @@ -73,9 +73,7 @@ public AppVersionSummaryResponse createAppVersion(Long redotAppId, AppVersionCre AppVersion savedVersion = createVersion(redotAppId, redotApp, request); List versionPages = buildVersionPages(redotAppId, savedVersion, request); - if (!versionPages.isEmpty()) { - appVersionPageRepository.saveAll(versionPages); - } + saveVersionPages(versionPages); return AppVersionSummaryResponse.from(savedVersion, fetchPagesForVersion(savedVersion.getId())); } @@ -150,10 +148,7 @@ private List retainedPages(Long redotAppId, if (ownerAppId == null || !ownerAppId.equals(redotAppId)) { throw new CMSSitePageException(CMSSitePageErrorCode.PAGE_NOT_BELONG_TO_APP); } - result.add(AppVersionPage.create(version, page)); - if (!usedPaths.add(page.getPath())) { - throw new CMSSitePageException(CMSSitePageErrorCode.PAGE_PATH_DUPLICATED); - } + addPageMapping(result, version, page, page.getPath(), usedPaths); } return result; } @@ -167,9 +162,6 @@ private List addedPages(AppVersion version, List result = new ArrayList<>(); RedotApp redotApp = version.getRedotApp(); for (AppPageCreateRequest request : added) { - if (!usedPaths.add(request.path())) { - throw new CMSSitePageException(CMSSitePageErrorCode.PAGE_PATH_DUPLICATED); - } AppPage page = AppPage.create( redotApp, request.content(), @@ -179,11 +171,33 @@ private List addedPages(AppVersion version, request.title() ); AppPage savedPage = appPageRepository.save(page); - result.add(AppVersionPage.create(version, savedPage)); + addPageMapping(result, version, savedPage, savedPage.getPath(), usedPaths); } return result; } + private void addPageMapping(List accumulator, + AppVersion version, + AppPage page, + String path, + Set usedPaths) { + if (!usedPaths.add(path)) { + throw new CMSSitePageException(CMSSitePageErrorCode.PAGE_PATH_DUPLICATED); + } + accumulator.add(AppVersionPage.create(version, page)); + } + + private void saveVersionPages(List versionPages) { + if (versionPages.isEmpty()) { + return; + } + try { + appVersionPageRepository.saveAll(versionPages); + } catch (DataIntegrityViolationException e) { + throw new CMSSitePageException(CMSSitePageErrorCode.PAGE_PATH_DUPLICATED); + } + } + private void validateStatus(AppVersionStatus status) { if (status == null || status == AppVersionStatus.PREVIOUS) { throw new CMSSitePageException(CMSSitePageErrorCode.INVALID_VERSION_STATUS); diff --git a/src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java b/src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java index 91b3896..bcb1828 100644 --- a/src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java +++ b/src/main/java/redot/redot_server/domain/site/page/entity/AppVersionPage.java @@ -1,5 +1,6 @@ package redot.redot_server.domain.site.page.entity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -21,7 +22,8 @@ @AllArgsConstructor(access = AccessLevel.PROTECTED) @Builder(access = AccessLevel.PRIVATE) @Table(name = "app_version_pages", uniqueConstraints = { - @UniqueConstraint(columnNames = {"app_version_id", "app_page_id"}) + @UniqueConstraint(columnNames = {"app_version_id", "app_page_id"}), + @UniqueConstraint(columnNames = {"app_version_id", "path"}) }) public class AppVersionPage extends BaseTimeEntity { @Id @@ -36,7 +38,10 @@ public class AppVersionPage extends BaseTimeEntity { @JoinColumn(name = "app_page_id", nullable = false) private AppPage appPage; + @Column(nullable = false, length = 255) + private String path; + public static AppVersionPage create(AppVersion appVersion, AppPage appPage) { - return new AppVersionPage(null, appVersion, appPage); + return new AppVersionPage(null, appVersion, appPage, appPage.getPath()); } } diff --git a/src/main/resources/db/migration/V3__create_site_pages.sql b/src/main/resources/db/migration/V3__create_site_pages.sql index e13de45..6d15502 100644 --- a/src/main/resources/db/migration/V3__create_site_pages.sql +++ b/src/main/resources/db/migration/V3__create_site_pages.sql @@ -29,9 +29,11 @@ CREATE TABLE IF NOT EXISTS app_version_pages id BIGSERIAL PRIMARY KEY, app_version_id BIGINT NOT NULL REFERENCES app_versions (id) ON DELETE RESTRICT, app_page_id BIGINT NOT NULL REFERENCES app_pages (id) ON DELETE CASCADE, + path VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uk_app_version_page UNIQUE (app_version_id, app_page_id) + CONSTRAINT uk_app_version_page UNIQUE (app_version_id, app_page_id), + CONSTRAINT uk_app_version_page_path UNIQUE (app_version_id, path) ); CREATE UNIQUE INDEX IF NOT EXISTS uk_app_versions_published_per_app From 6f14bd29743585f9cef51ee78b45091ed9fad96b Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Mon, 8 Dec 2025 23:19:49 +0900 Subject: [PATCH 10/10] feat(database): add indexes for app_pages and app_version_pages tables --- src/main/resources/db/migration/V3__create_site_pages.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/resources/db/migration/V3__create_site_pages.sql b/src/main/resources/db/migration/V3__create_site_pages.sql index 6d15502..353ec7a 100644 --- a/src/main/resources/db/migration/V3__create_site_pages.sql +++ b/src/main/resources/db/migration/V3__create_site_pages.sql @@ -11,6 +11,9 @@ CREATE TABLE IF NOT EXISTS app_pages updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +CREATE INDEX IF NOT EXISTS ix_app_pages_redot_app_id ON app_pages (redot_app_id); +CREATE INDEX IF NOT EXISTS ix_app_pages_path ON app_pages (path); + CREATE TABLE IF NOT EXISTS app_versions ( id BIGSERIAL PRIMARY KEY, @@ -36,6 +39,9 @@ CREATE TABLE IF NOT EXISTS app_version_pages CONSTRAINT uk_app_version_page_path UNIQUE (app_version_id, path) ); +CREATE INDEX IF NOT EXISTS ix_app_version_pages_app_version_id ON app_version_pages (app_version_id); +CREATE INDEX IF NOT EXISTS ix_app_version_pages_path ON app_version_pages (path); + CREATE UNIQUE INDEX IF NOT EXISTS uk_app_versions_published_per_app ON app_versions (redot_app_id) WHERE status = 'PUBLISHED';