From 5f3d8e01f6297eddac88e25f455543dc7d04e0e9 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Tue, 16 Dec 2025 18:25:53 +0900 Subject: [PATCH 01/11] feat(admin): add endpoint to retrieve Redot app info list with filtering and pagination --- .../controller/AdminRedotAppController.java | 15 ++++ .../docs/AdminRedotAppControllerDocs.java | 11 +++ .../request/RedotAppInfoSearchCondition.java | 10 +++ .../admin/service/AdminRedotAppService.java | 55 +++++++++++++ .../app/repository/RedotAppRepository.java | 2 +- .../repository/RedotAppRepositoryCustom.java | 12 +++ .../repository/RedotAppRepositoryImpl.java | 79 +++++++++++++++++++ 7 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppInfoSearchCondition.java create mode 100644 src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java create mode 100644 src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java diff --git a/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java b/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java index 42dca48..8db4c71 100644 --- a/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java +++ b/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java @@ -2,15 +2,22 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import redot.redot_server.domain.admin.controller.docs.AdminRedotAppControllerDocs; +import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; import redot.redot_server.domain.admin.service.AdminRedotAppService; import redot.redot_server.domain.redot.app.dto.request.RedotAppCreateRequest; import redot.redot_server.domain.redot.app.dto.response.RedotAppInfoResponse; +import redot.redot_server.global.util.dto.response.PageResponse; @RestController @RequestMapping("/api/v1/redot/admin/app") @@ -24,4 +31,12 @@ public class AdminRedotAppController implements AdminRedotAppControllerDocs { public ResponseEntity createRedotApp(@Valid @RequestBody RedotAppCreateRequest request) { return ResponseEntity.ok(redotAppService.createRedotApp(request)); } + + @GetMapping + @Override + public ResponseEntity> getRedotAppInfoList( + @ParameterObject RedotAppInfoSearchCondition searchCondition, + @ParameterObject @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + return ResponseEntity.ok(redotAppService.getRedotAppInfoList(searchCondition, pageable)); + } } diff --git a/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java index 722cc4b..7b221ab 100644 --- a/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java @@ -6,9 +6,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; +import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; import redot.redot_server.domain.redot.app.dto.request.RedotAppCreateRequest; import redot.redot_server.domain.redot.app.dto.response.RedotAppInfoResponse; +import redot.redot_server.global.util.dto.response.PageResponse; @Tag(name = "Admin Redot App", description = "관리자 앱 관리 API") public interface AdminRedotAppControllerDocs { @@ -17,4 +21,11 @@ public interface AdminRedotAppControllerDocs { @ApiResponse(responseCode = "200", description = "생성 성공", content = @Content(schema = @Schema(implementation = RedotAppInfoResponse.class))) ResponseEntity createRedotApp(@Valid RedotAppCreateRequest request); + + @Operation(summary = "앱 목록 조회", description = "status, name, redot_member_id 파라미터로 필터링하며 createdAt 기준 최신순 정렬을 기본으로 제공합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = PageResponse.class))) + ResponseEntity> getRedotAppInfoList( + @ParameterObject RedotAppInfoSearchCondition searchCondition, + @ParameterObject Pageable pageable); } diff --git a/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppInfoSearchCondition.java b/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppInfoSearchCondition.java new file mode 100644 index 0000000..4cd60a8 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppInfoSearchCondition.java @@ -0,0 +1,10 @@ +package redot.redot_server.domain.admin.dto.request; + +import redot.redot_server.domain.redot.app.entity.RedotAppStatus; + +public record RedotAppInfoSearchCondition( + String name, + Long redotMemberId, + RedotAppStatus status +) { +} diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java index 03f3398..55f72ce 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java @@ -1,20 +1,75 @@ package redot.redot_server.domain.admin.service; 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.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.cms.site.setting.dto.response.SiteSettingResponse; +import redot.redot_server.domain.cms.site.style.dto.response.StyleInfoResponse; import redot.redot_server.domain.redot.app.dto.request.RedotAppCreateRequest; import redot.redot_server.domain.redot.app.dto.response.RedotAppInfoResponse; +import redot.redot_server.domain.redot.app.dto.response.RedotAppResponse; +import redot.redot_server.domain.redot.app.entity.RedotApp; +import redot.redot_server.domain.redot.app.repository.RedotAppRepository; import redot.redot_server.domain.redot.app.service.RedotAppCreationService; +import redot.redot_server.domain.redot.member.dto.response.RedotMemberResponse; +import redot.redot_server.domain.site.domain.entity.Domain; +import redot.redot_server.domain.site.domain.exception.DomainErrorCode; +import redot.redot_server.domain.site.domain.exception.DomainException; +import redot.redot_server.domain.site.domain.repository.DomainRepository; +import redot.redot_server.domain.site.setting.entity.SiteSetting; +import redot.redot_server.domain.site.setting.exception.SiteSettingErrorCode; +import redot.redot_server.domain.site.setting.exception.SiteSettingException; +import redot.redot_server.domain.site.setting.repository.SiteSettingRepository; +import redot.redot_server.domain.site.style.entity.StyleInfo; +import redot.redot_server.domain.site.style.exception.StyleInfoErrorCode; +import redot.redot_server.domain.site.style.exception.StyleInfoException; +import redot.redot_server.domain.site.style.repository.StyleInfoRepository; +import redot.redot_server.global.s3.util.ImageUrlResolver; +import redot.redot_server.global.util.dto.response.PageResponse; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AdminRedotAppService { private final RedotAppCreationService redotAppCreationService; + private final RedotAppRepository redotAppRepository; + private final DomainRepository domainRepository; + private final SiteSettingRepository siteSettingRepository; + private final StyleInfoRepository styleInfoRepository; + private final ImageUrlResolver imageUrlResolver; @Transactional public RedotAppInfoResponse createRedotApp(RedotAppCreateRequest request) { return redotAppCreationService.createWithoutOwner(request); } + + public PageResponse getRedotAppInfoList(RedotAppInfoSearchCondition searchCondition, + Pageable pageable) { + Page redotApps = redotAppRepository.findAllBySearchCondition(searchCondition, pageable); + + Page responsePage = redotApps.map(this::toRedotAppInfoResponse); + + return PageResponse.from(responsePage); + } + + private RedotAppInfoResponse toRedotAppInfoResponse(RedotApp redotApp) { + Domain domain = domainRepository.findByRedotAppId(redotApp.getId()) + .orElseThrow(() -> new DomainException(DomainErrorCode.DOMAIN_NOT_FOUND)); + + StyleInfo styleInfo = styleInfoRepository.findByRedotApp_Id(redotApp.getId()) + .orElseThrow(() -> new StyleInfoException(StyleInfoErrorCode.STYLE_INFO_NOT_FOUND)); + + SiteSetting siteSetting = siteSettingRepository.findByRedotAppId(redotApp.getId()) + .orElseThrow(() -> new SiteSettingException(SiteSettingErrorCode.SITE_SETTING_NOT_FOUND)); + + return new RedotAppInfoResponse( + RedotAppResponse.fromEntity(redotApp), + SiteSettingResponse.fromEntity(siteSetting, domain, imageUrlResolver), + StyleInfoResponse.fromEntity(styleInfo), + RedotMemberResponse.fromNullable(redotApp.getOwner(), imageUrlResolver) + ); + } } diff --git a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepository.java b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepository.java index b2bd245..5248ac1 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepository.java +++ b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepository.java @@ -5,7 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import redot.redot_server.domain.redot.app.entity.RedotApp; -public interface RedotAppRepository extends JpaRepository { +public interface RedotAppRepository extends JpaRepository, RedotAppRepositoryCustom { Page findByOwnerId(Long ownerId, Pageable pageable); long countByOwnerId(Long ownerId); diff --git a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java new file mode 100644 index 0000000..bfc5c99 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java @@ -0,0 +1,12 @@ +package redot.redot_server.domain.redot.app.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.redot.app.entity.RedotApp; + +public interface RedotAppRepositoryCustom { + + Page findAllBySearchCondition(RedotAppInfoSearchCondition searchCondition, + Pageable pageable); +} diff --git a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java new file mode 100644 index 0000000..ec48c23 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java @@ -0,0 +1,79 @@ +package redot.redot_server.domain.redot.app.repository; + +import static redot.redot_server.domain.redot.app.entity.QRedotApp.redotApp; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.redot.app.entity.RedotApp; +import redot.redot_server.domain.redot.app.entity.RedotAppStatus; + +@RequiredArgsConstructor +public class RedotAppRepositoryImpl implements RedotAppRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findAllBySearchCondition(RedotAppInfoSearchCondition searchCondition, + Pageable pageable) { + String name = searchCondition == null ? null : searchCondition.name(); + Long ownerId = searchCondition == null ? null : searchCondition.redotMemberId(); + var status = searchCondition == null ? null : searchCondition.status(); + + BooleanExpression[] predicates = new BooleanExpression[]{ + nameContains(name), + ownerIdEq(ownerId), + statusEq(status) + }; + + List content = queryFactory + .selectFrom(redotApp) + .where(predicates) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(resolveOrder(pageable)) + .fetch(); + + Long totalCount = queryFactory + .select(redotApp.count()) + .from(redotApp) + .where(predicates) + .fetchOne(); + + long total = totalCount == null ? 0L : totalCount; + + return new PageImpl<>(content, pageable, total); + } + + private OrderSpecifier resolveOrder(Pageable pageable) { + Sort.Order order = pageable.getSort().stream() + .filter(it -> it.getProperty().equals("createdAt")) + .findFirst() + .orElseGet(() -> new Sort.Order(Sort.Direction.DESC, "createdAt")); + + return order.isAscending() ? redotApp.createdAt.asc() : redotApp.createdAt.desc(); + } + + private BooleanExpression nameContains(String name) { + return hasText(name) ? redotApp.name.containsIgnoreCase(name) : null; + } + + private BooleanExpression ownerIdEq(Long ownerId) { + return ownerId == null ? null : redotApp.owner.id.eq(ownerId); + } + + private BooleanExpression statusEq(RedotAppStatus status) { + return status == null ? null : redotApp.status.eq(status); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} From e7ac593b6d75efae252d1dd8f97264efceae4d61 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Tue, 16 Dec 2025 18:36:26 +0900 Subject: [PATCH 02/11] feat(admin): add endpoint to retrieve specific Redot app info by ID --- .../domain/admin/controller/AdminRedotAppController.java | 8 ++++++++ .../controller/docs/AdminRedotAppControllerDocs.java | 6 ++++++ .../domain/admin/service/AdminRedotAppService.java | 9 +++++++++ 3 files changed, 23 insertions(+) diff --git a/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java b/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java index 8db4c71..7a405dd 100644 --- a/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java +++ b/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java @@ -8,6 +8,7 @@ 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; @@ -39,4 +40,11 @@ public ResponseEntity> getRedotAppInfoList( @ParameterObject @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { return ResponseEntity.ok(redotAppService.getRedotAppInfoList(searchCondition, pageable)); } + + @GetMapping("/{redotAppId}") + @Override + public ResponseEntity getRedotAppInfo( + @PathVariable("redotAppId") Long redotAppId) { + return ResponseEntity.ok(redotAppService.getRedotAppInfo(redotAppId)); + } } diff --git a/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java index 7b221ab..342b50c 100644 --- a/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java @@ -28,4 +28,10 @@ public interface AdminRedotAppControllerDocs { ResponseEntity> getRedotAppInfoList( @ParameterObject RedotAppInfoSearchCondition searchCondition, @ParameterObject Pageable pageable); + + @Operation(summary = "앱 단건 조회", description = "redotAppId로 특정 앱 정보를 조회합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = RedotAppInfoResponse.class))) + ResponseEntity getRedotAppInfo( + @io.swagger.v3.oas.annotations.Parameter(description = "Redot 앱 ID", example = "1") Long redotAppId); } diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java index 55f72ce..a6f7331 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java @@ -12,6 +12,8 @@ import redot.redot_server.domain.redot.app.dto.response.RedotAppInfoResponse; import redot.redot_server.domain.redot.app.dto.response.RedotAppResponse; 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.redot.app.service.RedotAppCreationService; import redot.redot_server.domain.redot.member.dto.response.RedotMemberResponse; @@ -72,4 +74,11 @@ private RedotAppInfoResponse toRedotAppInfoResponse(RedotApp redotApp) { RedotMemberResponse.fromNullable(redotApp.getOwner(), imageUrlResolver) ); } + + public RedotAppInfoResponse getRedotAppInfo(Long redotAppId) { + RedotApp redotApp = redotAppRepository.findById(redotAppId) + .orElseThrow(() -> new RedotAppException(RedotAppErrorCode.REDOT_APP_NOT_FOUND)); + + return toRedotAppInfoResponse(redotApp); + } } From 81bd50ae31d9846bea4892871f48aaa23aef5816 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Tue, 16 Dec 2025 19:06:45 +0900 Subject: [PATCH 03/11] feat(admin): add endpoint and logic to update Redot app status with remarks --- .../admin/controller/AdminRedotAppController.java | 9 +++++++++ .../controller/docs/AdminRedotAppControllerDocs.java | 8 ++++++++ .../dto/request/RedotAppStatusUpdateRequest.java | 10 ++++++++++ .../domain/admin/service/AdminRedotAppService.java | 11 +++++++++++ .../redot/app/dto/response/RedotAppResponse.java | 6 ++++-- .../domain/redot/app/entity/RedotApp.java | 8 ++++++++ .../domain/redot/app/entity/RedotAppStatus.java | 3 ++- .../db/migration/V6__add_redot_app_remark.sql | 2 ++ 8 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppStatusUpdateRequest.java create mode 100644 src/main/resources/db/migration/V6__add_redot_app_remark.sql diff --git a/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java b/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java index 7a405dd..084ea20 100644 --- a/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java +++ b/src/main/java/redot/redot_server/domain/admin/controller/AdminRedotAppController.java @@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController; import redot.redot_server.domain.admin.controller.docs.AdminRedotAppControllerDocs; import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.admin.dto.request.RedotAppStatusUpdateRequest; import redot.redot_server.domain.admin.service.AdminRedotAppService; import redot.redot_server.domain.redot.app.dto.request.RedotAppCreateRequest; import redot.redot_server.domain.redot.app.dto.response.RedotAppInfoResponse; @@ -47,4 +48,12 @@ public ResponseEntity getRedotAppInfo( @PathVariable("redotAppId") Long redotAppId) { return ResponseEntity.ok(redotAppService.getRedotAppInfo(redotAppId)); } + + @PostMapping("/{redotAppId}/status") + @Override + public ResponseEntity updateRedotAppStatus( + @PathVariable("redotAppId") Long redotAppId, + @Valid @RequestBody RedotAppStatusUpdateRequest request) { + return ResponseEntity.ok(redotAppService.updateRedotAppStatus(redotAppId, request)); + } } diff --git a/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java index 342b50c..4187efa 100644 --- a/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminRedotAppControllerDocs.java @@ -10,6 +10,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.admin.dto.request.RedotAppStatusUpdateRequest; import redot.redot_server.domain.redot.app.dto.request.RedotAppCreateRequest; import redot.redot_server.domain.redot.app.dto.response.RedotAppInfoResponse; import redot.redot_server.global.util.dto.response.PageResponse; @@ -34,4 +35,11 @@ ResponseEntity> getRedotAppInfoList( content = @Content(schema = @Schema(implementation = RedotAppInfoResponse.class))) ResponseEntity getRedotAppInfo( @io.swagger.v3.oas.annotations.Parameter(description = "Redot 앱 ID", example = "1") Long redotAppId); + + @Operation(summary = "앱 상태 변경", description = "지정한 앱의 상태 및 비고(remark)를 수정합니다.") + @ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content(schema = @Schema(implementation = RedotAppInfoResponse.class))) + ResponseEntity updateRedotAppStatus( + @io.swagger.v3.oas.annotations.Parameter(description = "Redot 앱 ID", example = "1") Long redotAppId, + @Valid RedotAppStatusUpdateRequest request); } diff --git a/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppStatusUpdateRequest.java b/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppStatusUpdateRequest.java new file mode 100644 index 0000000..a5e5cb6 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/dto/request/RedotAppStatusUpdateRequest.java @@ -0,0 +1,10 @@ +package redot.redot_server.domain.admin.dto.request; + +import jakarta.validation.constraints.NotNull; +import redot.redot_server.domain.redot.app.entity.RedotAppStatus; + +public record RedotAppStatusUpdateRequest( + @NotNull(message = "status 는 필수입니다.") RedotAppStatus status, + String remark +) { +} diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java index a6f7331..f5fa93c 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; +import redot.redot_server.domain.admin.dto.request.RedotAppStatusUpdateRequest; import redot.redot_server.domain.cms.site.setting.dto.response.SiteSettingResponse; import redot.redot_server.domain.cms.site.style.dto.response.StyleInfoResponse; import redot.redot_server.domain.redot.app.dto.request.RedotAppCreateRequest; @@ -81,4 +82,14 @@ public RedotAppInfoResponse getRedotAppInfo(Long redotAppId) { return toRedotAppInfoResponse(redotApp); } + + @Transactional + public RedotAppInfoResponse updateRedotAppStatus(Long redotAppId, RedotAppStatusUpdateRequest request) { + RedotApp redotApp = redotAppRepository.findById(redotAppId) + .orElseThrow(() -> new RedotAppException(RedotAppErrorCode.REDOT_APP_NOT_FOUND)); + + redotApp.updateStatus(request.status(), request.remark()); + + return toRedotAppInfoResponse(redotApp); + } } diff --git a/src/main/java/redot/redot_server/domain/redot/app/dto/response/RedotAppResponse.java b/src/main/java/redot/redot_server/domain/redot/app/dto/response/RedotAppResponse.java index 4476b0d..502965a 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/dto/response/RedotAppResponse.java +++ b/src/main/java/redot/redot_server/domain/redot/app/dto/response/RedotAppResponse.java @@ -10,7 +10,8 @@ public record RedotAppResponse( RedotAppStatus status, boolean isCreatedManager, LocalDateTime createdAt, - Long planId + Long planId, + String remark ) { public static RedotAppResponse fromEntity(RedotApp redotApp) { return new RedotAppResponse( @@ -19,7 +20,8 @@ public static RedotAppResponse fromEntity(RedotApp redotApp) { redotApp.getStatus(), redotApp.isCreatedManager(), redotApp.getCreatedAt(), - redotApp.getPlan().getId() + redotApp.getPlan().getId(), + redotApp.getRemark() ); } } diff --git a/src/main/java/redot/redot_server/domain/redot/app/entity/RedotApp.java b/src/main/java/redot/redot_server/domain/redot/app/entity/RedotApp.java index 4203986..e87cf77 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/entity/RedotApp.java +++ b/src/main/java/redot/redot_server/domain/redot/app/entity/RedotApp.java @@ -41,6 +41,9 @@ public class RedotApp extends BaseTimeEntity { @Column(nullable = false) private String name; + @Column(columnDefinition = "text") + private String remark; + // 초기 관리자 계정 생성 여부(default=false) @Column(nullable = false) @Builder.Default @@ -78,4 +81,9 @@ public boolean isOwner(Long redotMemberId) { public void updatePlan(Plan plan) { this.plan = plan; } + + public void updateStatus(RedotAppStatus status, String remark) { + this.status = status; + this.remark = remark; + } } diff --git a/src/main/java/redot/redot_server/domain/redot/app/entity/RedotAppStatus.java b/src/main/java/redot/redot_server/domain/redot/app/entity/RedotAppStatus.java index 29bf6cb..3f6ee0f 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/entity/RedotAppStatus.java +++ b/src/main/java/redot/redot_server/domain/redot/app/entity/RedotAppStatus.java @@ -5,5 +5,6 @@ public enum RedotAppStatus { INACTIVE, DELETED, RESIGNED, - PAYMENT_DELAYED + PAYMENT_DELAYED, + BANNED } diff --git a/src/main/resources/db/migration/V6__add_redot_app_remark.sql b/src/main/resources/db/migration/V6__add_redot_app_remark.sql new file mode 100644 index 0000000..cdf98a8 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_redot_app_remark.sql @@ -0,0 +1,2 @@ +ALTER TABLE redot_apps + ADD COLUMN IF NOT EXISTS remark TEXT; From bf15c3f781e348596f485c513215ffb086bbf895 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Wed, 17 Dec 2025 09:48:21 +0900 Subject: [PATCH 04/11] feat(admin): refactor CMS admin impersonation logic to remove unnecessary parameters and enhance member retrieval --- .../controller/AdminImpersonationController.java | 6 ++---- .../docs/AdminImpersonationControllerDocs.java | 4 +--- .../auth/service/AdminImpersonationService.java | 16 ++++++++++++++-- .../member/repository/CMSMemberRepository.java | 2 ++ 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/auth/controller/AdminImpersonationController.java b/src/main/java/redot/redot_server/domain/auth/controller/AdminImpersonationController.java index addb18b..7cca1f5 100644 --- a/src/main/java/redot/redot_server/domain/auth/controller/AdminImpersonationController.java +++ b/src/main/java/redot/redot_server/domain/auth/controller/AdminImpersonationController.java @@ -25,10 +25,8 @@ public class AdminImpersonationController implements AdminImpersonationControlle @PostMapping("/cms-admin") @Override - public ResponseEntity impersonateAsCMSAdmin(HttpServletRequest request, @Valid @RequestBody CMSAdminImpersonationRequest cmsAdminImpersonationRequest, @AuthenticationPrincipal JwtPrincipal jwtPrincipal) { - Long adminId = jwtPrincipal.id(); - - AuthResult authResult = adminImpersonationService.impersonateAsCMSAdmin(request, cmsAdminImpersonationRequest, adminId); + public ResponseEntity impersonateAsCMSAdmin(HttpServletRequest request, @Valid @RequestBody CMSAdminImpersonationRequest cmsAdminImpersonationRequest) { + AuthResult authResult = adminImpersonationService.impersonateAsCMSAdmin(request, cmsAdminImpersonationRequest); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, authResult.accessCookie().toString()) .header(HttpHeaders.SET_COOKIE, authResult.refreshCookie().toString()) diff --git a/src/main/java/redot/redot_server/domain/auth/controller/docs/AdminImpersonationControllerDocs.java b/src/main/java/redot/redot_server/domain/auth/controller/docs/AdminImpersonationControllerDocs.java index b568040..e49d2a5 100644 --- a/src/main/java/redot/redot_server/domain/auth/controller/docs/AdminImpersonationControllerDocs.java +++ b/src/main/java/redot/redot_server/domain/auth/controller/docs/AdminImpersonationControllerDocs.java @@ -11,7 +11,6 @@ import org.springframework.http.ResponseEntity; import redot.redot_server.domain.auth.dto.request.CMSAdminImpersonationRequest; import redot.redot_server.domain.auth.dto.response.TokenResponse; -import redot.redot_server.global.security.principal.JwtPrincipal; @Tag(name = "Admin Impersonation", description = "관리자 권한 위임 API") public interface AdminImpersonationControllerDocs { @@ -20,6 +19,5 @@ public interface AdminImpersonationControllerDocs { @ApiResponse(responseCode = "200", description = "발급 성공", content = @Content(schema = @Schema(implementation = TokenResponse.class))) ResponseEntity impersonateAsCMSAdmin(@Parameter(hidden = true) HttpServletRequest request, - @Valid CMSAdminImpersonationRequest cmsAdminImpersonationRequest, - @Parameter(hidden = true) JwtPrincipal jwtPrincipal); + @Valid CMSAdminImpersonationRequest cmsAdminImpersonationRequest); } diff --git a/src/main/java/redot/redot_server/domain/auth/service/AdminImpersonationService.java b/src/main/java/redot/redot_server/domain/auth/service/AdminImpersonationService.java index ba8e9d3..09c56c9 100644 --- a/src/main/java/redot/redot_server/domain/auth/service/AdminImpersonationService.java +++ b/src/main/java/redot/redot_server/domain/auth/service/AdminImpersonationService.java @@ -7,7 +7,11 @@ import org.springframework.transaction.annotation.Transactional; import redot.redot_server.domain.auth.dto.request.CMSAdminImpersonationRequest; import redot.redot_server.domain.auth.dto.response.AuthResult; +import redot.redot_server.domain.auth.exception.AuthErrorCode; +import redot.redot_server.domain.auth.exception.AuthException; +import redot.redot_server.domain.cms.member.entity.CMSMember; import redot.redot_server.domain.cms.member.entity.CMSMemberRole; +import redot.redot_server.domain.cms.member.repository.CMSMemberRepository; import redot.redot_server.global.jwt.token.TokenContext; import redot.redot_server.global.jwt.token.TokenType; @@ -17,10 +21,18 @@ public class AdminImpersonationService { private final AuthTokenService authTokenService; + private final CMSMemberRepository cmsMemberRepository; + + public AuthResult impersonateAsCMSAdmin(HttpServletRequest request, + CMSAdminImpersonationRequest cmsAdminImpersonationRequest) { + CMSMember owner = cmsMemberRepository + .findFirstByRedotApp_IdAndRoleOrderByIdAsc(cmsAdminImpersonationRequest.redotAppId(), + CMSMemberRole.OWNER) + .orElseThrow(() -> new AuthException(AuthErrorCode.CMS_MEMBER_NOT_FOUND)); - public AuthResult impersonateAsCMSAdmin(HttpServletRequest request, CMSAdminImpersonationRequest cmsAdminImpersonationRequest, Long adminId) { return authTokenService.issueTokens(request, - new TokenContext(adminId, TokenType.CMS, List.of(CMSMemberRole.ADMIN.name()), cmsAdminImpersonationRequest.redotAppId()) + new TokenContext(owner.getId(), TokenType.CMS, List.of(owner.getRole().name()), + cmsAdminImpersonationRequest.redotAppId()) ); } } diff --git a/src/main/java/redot/redot_server/domain/cms/member/repository/CMSMemberRepository.java b/src/main/java/redot/redot_server/domain/cms/member/repository/CMSMemberRepository.java index 9ec7af2..bb7d53d 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/repository/CMSMemberRepository.java +++ b/src/main/java/redot/redot_server/domain/cms/member/repository/CMSMemberRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import redot.redot_server.domain.cms.member.entity.CMSMember; +import redot.redot_server.domain.cms.member.entity.CMSMemberRole; public interface CMSMemberRepository extends JpaRepository, CMSMemberRepositoryCustom { Optional findByEmailAndRedotApp_Id(String email, Long redotAppId); @@ -21,4 +22,5 @@ Optional findByIdAndRedotApp_IdIncludingDeleted( @Param("id") Long id, @Param("redotAppId") Long redotAppId); + Optional findFirstByRedotApp_IdAndRoleOrderByIdAsc(Long redotAppId, CMSMemberRole role); } From 0ea440f01357c7f6ac45fd73c71d75e44768119a Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Wed, 17 Dec 2025 10:13:18 +0900 Subject: [PATCH 05/11] feat(admin): enhance Redot app retrieval by adding site info and refactoring response mapping --- .../admin/service/AdminRedotAppService.java | 63 +++++++++++++---- .../repository/RedotAppRepositoryCustom.java | 6 ++ .../repository/RedotAppRepositoryImpl.java | 70 +++++++++++++++++-- .../app/repository/RedotAppWithSiteInfo.java | 14 ++++ .../domain/repository/DomainRepository.java | 4 ++ .../repository/SiteSettingRepository.java | 4 ++ .../style/repository/StyleInfoRepository.java | 4 ++ 7 files changed, 145 insertions(+), 20 deletions(-) create mode 100644 src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppWithSiteInfo.java diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java index f5fa93c..c50ab4d 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminRedotAppService.java @@ -1,5 +1,9 @@ package redot.redot_server.domain.admin.service; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -19,16 +23,10 @@ import redot.redot_server.domain.redot.app.service.RedotAppCreationService; import redot.redot_server.domain.redot.member.dto.response.RedotMemberResponse; import redot.redot_server.domain.site.domain.entity.Domain; -import redot.redot_server.domain.site.domain.exception.DomainErrorCode; -import redot.redot_server.domain.site.domain.exception.DomainException; import redot.redot_server.domain.site.domain.repository.DomainRepository; import redot.redot_server.domain.site.setting.entity.SiteSetting; -import redot.redot_server.domain.site.setting.exception.SiteSettingErrorCode; -import redot.redot_server.domain.site.setting.exception.SiteSettingException; import redot.redot_server.domain.site.setting.repository.SiteSettingRepository; import redot.redot_server.domain.site.style.entity.StyleInfo; -import redot.redot_server.domain.site.style.exception.StyleInfoErrorCode; -import redot.redot_server.domain.site.style.exception.StyleInfoException; import redot.redot_server.domain.site.style.repository.StyleInfoRepository; import redot.redot_server.global.s3.util.ImageUrlResolver; import redot.redot_server.global.util.dto.response.PageResponse; @@ -53,25 +51,60 @@ public PageResponse getRedotAppInfoList(RedotAppInfoSearch Pageable pageable) { Page redotApps = redotAppRepository.findAllBySearchCondition(searchCondition, pageable); - Page responsePage = redotApps.map(this::toRedotAppInfoResponse); + List redotAppIds = redotApps.stream() + .map(RedotApp::getId) + .toList(); + + Map domainMap = redotAppIds.isEmpty() ? Map.of() + : domainRepository.findByRedotAppIdIn(redotAppIds) + .stream() + .collect(Collectors.toMap(domain -> domain.getRedotApp().getId(), Function.identity())); + + Map styleInfoMap = redotAppIds.isEmpty() ? Map.of() + : styleInfoRepository.findByRedotApp_IdIn(redotAppIds) + .stream() + .collect(Collectors.toMap(style -> style.getRedotApp().getId(), Function.identity())); + + Map siteSettingMap = redotAppIds.isEmpty() ? Map.of() + : siteSettingRepository.findByRedotAppIdIn(redotAppIds) + .stream() + .collect(Collectors.toMap(setting -> setting.getRedotApp().getId(), Function.identity())); + + Page responsePage = redotApps.map(redotApp -> + toRedotAppInfoResponse( + redotApp, + domainMap.get(redotApp.getId()), + styleInfoMap.get(redotApp.getId()), + siteSettingMap.get(redotApp.getId()) + )); return PageResponse.from(responsePage); } private RedotAppInfoResponse toRedotAppInfoResponse(RedotApp redotApp) { - Domain domain = domainRepository.findByRedotAppId(redotApp.getId()) - .orElseThrow(() -> new DomainException(DomainErrorCode.DOMAIN_NOT_FOUND)); + Domain domain = domainRepository.findByRedotAppId(redotApp.getId()).orElse(null); + StyleInfo styleInfo = styleInfoRepository.findByRedotApp_Id(redotApp.getId()).orElse(null); + SiteSetting siteSetting = siteSettingRepository.findByRedotAppId(redotApp.getId()).orElse(null); + + return toRedotAppInfoResponse(redotApp, domain, styleInfo, siteSetting); + } - StyleInfo styleInfo = styleInfoRepository.findByRedotApp_Id(redotApp.getId()) - .orElseThrow(() -> new StyleInfoException(StyleInfoErrorCode.STYLE_INFO_NOT_FOUND)); + private RedotAppInfoResponse toRedotAppInfoResponse(RedotApp redotApp, + Domain domain, + StyleInfo styleInfo, + SiteSetting siteSetting) { + SiteSettingResponse siteSettingResponse = (siteSetting != null && domain != null) + ? SiteSettingResponse.fromEntity(siteSetting, domain, imageUrlResolver) + : null; - SiteSetting siteSetting = siteSettingRepository.findByRedotAppId(redotApp.getId()) - .orElseThrow(() -> new SiteSettingException(SiteSettingErrorCode.SITE_SETTING_NOT_FOUND)); + StyleInfoResponse styleInfoResponse = styleInfo != null + ? StyleInfoResponse.fromEntity(styleInfo) + : null; return new RedotAppInfoResponse( RedotAppResponse.fromEntity(redotApp), - SiteSettingResponse.fromEntity(siteSetting, domain, imageUrlResolver), - StyleInfoResponse.fromEntity(styleInfo), + siteSettingResponse, + styleInfoResponse, RedotMemberResponse.fromNullable(redotApp.getOwner(), imageUrlResolver) ); } diff --git a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java index bfc5c99..711ad31 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java +++ b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryCustom.java @@ -1,5 +1,6 @@ package redot.redot_server.domain.redot.app.repository; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import redot.redot_server.domain.admin.dto.request.RedotAppInfoSearchCondition; @@ -9,4 +10,9 @@ public interface RedotAppRepositoryCustom { Page findAllBySearchCondition(RedotAppInfoSearchCondition searchCondition, Pageable pageable); + + List findAllWithSiteInfo(RedotAppInfoSearchCondition searchCondition, + Pageable pageable); + + long countBySearchCondition(RedotAppInfoSearchCondition searchCondition); } diff --git a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java index ec48c23..3cb4d9f 100644 --- a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java +++ b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppRepositoryImpl.java @@ -1,11 +1,16 @@ package redot.redot_server.domain.redot.app.repository; import static redot.redot_server.domain.redot.app.entity.QRedotApp.redotApp; +import static redot.redot_server.domain.site.domain.entity.QDomain.domain; +import static redot.redot_server.domain.site.setting.entity.QSiteSetting.siteSetting; +import static redot.redot_server.domain.site.style.entity.QStyleInfo.styleInfo; +import com.querydsl.core.Tuple; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; +import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -25,7 +30,7 @@ public Page findAllBySearchCondition(RedotAppInfoSearchCondition searc Pageable pageable) { String name = searchCondition == null ? null : searchCondition.name(); Long ownerId = searchCondition == null ? null : searchCondition.redotMemberId(); - var status = searchCondition == null ? null : searchCondition.status(); + RedotAppStatus status = searchCondition == null ? null : searchCondition.status(); BooleanExpression[] predicates = new BooleanExpression[]{ nameContains(name), @@ -41,20 +46,75 @@ public Page findAllBySearchCondition(RedotAppInfoSearchCondition searc .orderBy(resolveOrder(pageable)) .fetch(); + long total = countByPredicates(predicates); + + return new PageImpl<>(content, pageable, total); + } + + @Override + public List findAllWithSiteInfo(RedotAppInfoSearchCondition searchCondition, + Pageable pageable) { + String name = searchCondition == null ? null : searchCondition.name(); + Long ownerId = searchCondition == null ? null : searchCondition.redotMemberId(); + RedotAppStatus status = searchCondition == null ? null : searchCondition.status(); + + BooleanExpression[] predicates = new BooleanExpression[]{ + nameContains(name), + ownerIdEq(ownerId), + statusEq(status) + }; + + List tuples = queryFactory + .select(redotApp, domain, siteSetting, styleInfo) + .from(redotApp) + .leftJoin(domain).on(domain.redotApp.eq(redotApp)) + .leftJoin(siteSetting).on(siteSetting.redotApp.eq(redotApp)) + .leftJoin(styleInfo).on(styleInfo.redotApp.eq(redotApp)) + .where(predicates) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(resolveOrder(pageable)) + .fetch(); + + return tuples.stream() + .map(tuple -> new RedotAppWithSiteInfo( + tuple.get(redotApp), + tuple.get(domain), + tuple.get(siteSetting), + tuple.get(styleInfo) + )) + .filter(appWithInfo -> appWithInfo.redotApp() != null) + .toList(); + } + + @Override + public long countBySearchCondition(RedotAppInfoSearchCondition searchCondition) { + String name = searchCondition == null ? null : searchCondition.name(); + Long ownerId = searchCondition == null ? null : searchCondition.redotMemberId(); + RedotAppStatus status = searchCondition == null ? null : searchCondition.status(); + + BooleanExpression[] predicates = new BooleanExpression[]{ + nameContains(name), + ownerIdEq(ownerId), + statusEq(status) + }; + + return countByPredicates(predicates); + } + + private long countByPredicates(BooleanExpression[] predicates) { Long totalCount = queryFactory .select(redotApp.count()) .from(redotApp) .where(predicates) .fetchOne(); - long total = totalCount == null ? 0L : totalCount; - - return new PageImpl<>(content, pageable, total); + return totalCount == null ? 0L : totalCount; } private OrderSpecifier resolveOrder(Pageable pageable) { Sort.Order order = pageable.getSort().stream() - .filter(it -> it.getProperty().equals("createdAt")) + .filter(it -> Objects.equals(it.getProperty(), "createdAt")) .findFirst() .orElseGet(() -> new Sort.Order(Sort.Direction.DESC, "createdAt")); diff --git a/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppWithSiteInfo.java b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppWithSiteInfo.java new file mode 100644 index 0000000..c354665 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/redot/app/repository/RedotAppWithSiteInfo.java @@ -0,0 +1,14 @@ +package redot.redot_server.domain.redot.app.repository; + +import redot.redot_server.domain.redot.app.entity.RedotApp; +import redot.redot_server.domain.site.domain.entity.Domain; +import redot.redot_server.domain.site.setting.entity.SiteSetting; +import redot.redot_server.domain.site.style.entity.StyleInfo; + +public record RedotAppWithSiteInfo( + RedotApp redotApp, + Domain domain, + SiteSetting siteSetting, + StyleInfo styleInfo +) { +} diff --git a/src/main/java/redot/redot_server/domain/site/domain/repository/DomainRepository.java b/src/main/java/redot/redot_server/domain/site/domain/repository/DomainRepository.java index 80f4967..044ca9f 100644 --- a/src/main/java/redot/redot_server/domain/site/domain/repository/DomainRepository.java +++ b/src/main/java/redot/redot_server/domain/site/domain/repository/DomainRepository.java @@ -1,5 +1,7 @@ package redot.redot_server.domain.site.domain.repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; @@ -15,5 +17,7 @@ public interface DomainRepository extends JpaRepository { Optional findByRedotAppId(Long redotAppId); + List findByRedotAppIdIn(Collection redotAppIds); + boolean existsByCustomDomain(String customDomain); } diff --git a/src/main/java/redot/redot_server/domain/site/setting/repository/SiteSettingRepository.java b/src/main/java/redot/redot_server/domain/site/setting/repository/SiteSettingRepository.java index 309cc4d..38653fe 100644 --- a/src/main/java/redot/redot_server/domain/site/setting/repository/SiteSettingRepository.java +++ b/src/main/java/redot/redot_server/domain/site/setting/repository/SiteSettingRepository.java @@ -1,9 +1,13 @@ package redot.redot_server.domain.site.setting.repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import redot.redot_server.domain.site.setting.entity.SiteSetting; public interface SiteSettingRepository extends JpaRepository { Optional findByRedotAppId(Long redotAppId); + + List findByRedotAppIdIn(Collection redotAppIds); } diff --git a/src/main/java/redot/redot_server/domain/site/style/repository/StyleInfoRepository.java b/src/main/java/redot/redot_server/domain/site/style/repository/StyleInfoRepository.java index 56d4998..a3afe6b 100644 --- a/src/main/java/redot/redot_server/domain/site/style/repository/StyleInfoRepository.java +++ b/src/main/java/redot/redot_server/domain/site/style/repository/StyleInfoRepository.java @@ -1,9 +1,13 @@ package redot.redot_server.domain.site.style.repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import redot.redot_server.domain.site.style.entity.StyleInfo; public interface StyleInfoRepository extends JpaRepository { Optional findByRedotApp_Id(Long redotAppId); + + List findByRedotApp_IdIn(Collection redotAppIds); } From ef8780d4ad3be5789c58fb96a0614d0a6b3ac47c Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Wed, 17 Dec 2025 11:17:43 +0900 Subject: [PATCH 06/11] feat(admin): implement admin dashboard stats endpoint with member and consultation metrics --- .../controller/AdminDashboardController.java | 24 +++++++++++ .../docs/AdminDashboardControllerDocs.java | 18 ++++++++ .../response/AdminDashboardStatsResponse.java | 12 ++++++ .../admin/service/AdminDashboardService.java | 41 +++++++++++++++++++ .../repository/RedotMemberRepository.java | 3 ++ 5 files changed, 98 insertions(+) create mode 100644 src/main/java/redot/redot_server/domain/admin/controller/AdminDashboardController.java create mode 100644 src/main/java/redot/redot_server/domain/admin/controller/docs/AdminDashboardControllerDocs.java create mode 100644 src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java create mode 100644 src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java diff --git a/src/main/java/redot/redot_server/domain/admin/controller/AdminDashboardController.java b/src/main/java/redot/redot_server/domain/admin/controller/AdminDashboardController.java new file mode 100644 index 0000000..56e8fb9 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/controller/AdminDashboardController.java @@ -0,0 +1,24 @@ +package redot.redot_server.domain.admin.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.RestController; +import redot.redot_server.domain.admin.controller.docs.AdminDashboardControllerDocs; +import redot.redot_server.domain.admin.dto.response.AdminDashboardStatsResponse; +import redot.redot_server.domain.admin.service.AdminDashboardService; + +@RestController +@RequestMapping("/api/v1/redot/admin/dashboard") +@RequiredArgsConstructor +public class AdminDashboardController implements AdminDashboardControllerDocs { + + private final AdminDashboardService adminDashboardService; + + @GetMapping("/stats") + @Override + public ResponseEntity getDashboardStats() { + return ResponseEntity.ok(adminDashboardService.getDashboardStats()); + } +} diff --git a/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminDashboardControllerDocs.java b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminDashboardControllerDocs.java new file mode 100644 index 0000000..f061972 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/controller/docs/AdminDashboardControllerDocs.java @@ -0,0 +1,18 @@ +package redot.redot_server.domain.admin.controller.docs; + +import io.swagger.v3.oas.annotations.Operation; +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.admin.dto.response.AdminDashboardStatsResponse; + +@Tag(name = "Admin Dashboard", description = "관리자 대시보드 통계 API") +public interface AdminDashboardControllerDocs { + + @Operation(summary = "대시보드 통계 조회", description = "관리자 대시보드에 필요한 요약 통계를 제공합니다.") + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = AdminDashboardStatsResponse.class))) + ResponseEntity getDashboardStats(); +} diff --git a/src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java b/src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java new file mode 100644 index 0000000..070b632 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java @@ -0,0 +1,12 @@ +package redot.redot_server.domain.admin.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record AdminDashboardStatsResponse( + @Schema(description = "전체 Redot 회원 수") long totalRedotMembers, + @Schema(description = "전일까지 누적된 Redot 회원 수") long redotMembersUntilYesterday, + @Schema(description = "전일 이후 가입한 Redot 회원 수") long newRedotMembersSinceYesterday, + @Schema(description = "누적 상담 요청 수") long consultationRequestCount, + @Schema(description = "등록된 관리자 수") long adminCount +) { +} diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java new file mode 100644 index 0000000..312ce6a --- /dev/null +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java @@ -0,0 +1,41 @@ +package redot.redot_server.domain.admin.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import redot.redot_server.domain.admin.dto.response.AdminDashboardStatsResponse; +import redot.redot_server.domain.admin.repository.AdminRepository; +import redot.redot_server.domain.redot.consultation.repository.ConsultationRepository; +import redot.redot_server.domain.redot.member.repository.RedotMemberRepository; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminDashboardService { + + private final RedotMemberRepository redotMemberRepository; + private final ConsultationRepository consultationRepository; + private final AdminRepository adminRepository; + + public AdminDashboardStatsResponse getDashboardStats() { + LocalDateTime startOfToday = LocalDate.now(ZoneId.systemDefault()).atStartOfDay(); + + long totalRedotMembers = redotMemberRepository.count(); + long redotMembersUntilYesterday = redotMemberRepository.countByCreatedAtBefore(startOfToday); + long newRedotMembersSinceYesterday = Math.max(0L, totalRedotMembers - redotMembersUntilYesterday); + + long consultationRequestCount = consultationRepository.count(); + long adminCount = adminRepository.count(); + + return new AdminDashboardStatsResponse( + totalRedotMembers, + redotMembersUntilYesterday, + newRedotMembersSinceYesterday, + consultationRequestCount, + adminCount + ); + } +} diff --git a/src/main/java/redot/redot_server/domain/redot/member/repository/RedotMemberRepository.java b/src/main/java/redot/redot_server/domain/redot/member/repository/RedotMemberRepository.java index 3e0d197..6b96043 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/repository/RedotMemberRepository.java +++ b/src/main/java/redot/redot_server/domain/redot/member/repository/RedotMemberRepository.java @@ -1,5 +1,6 @@ package redot.redot_server.domain.redot.member.repository; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import redot.redot_server.domain.redot.member.entity.RedotMember; @@ -11,4 +12,6 @@ public interface RedotMemberRepository extends JpaRepository, Optional findByEmail(String email); Optional findBySocialProviderAndSocialProviderId(SocialProvider provider, String socialProviderId); + + long countByCreatedAtBefore(LocalDateTime before); } From 32d0421062b36b332b50c9c051891b9452853a1b Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Wed, 17 Dec 2025 18:08:54 +0900 Subject: [PATCH 07/11] feat(admin): update admin dashboard stats to include pending consultation count --- .../admin/dto/response/AdminDashboardStatsResponse.java | 3 +-- .../domain/admin/service/AdminDashboardService.java | 7 +++---- .../consultation/repository/ConsultationRepository.java | 3 +++ 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java b/src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java index 070b632..6073470 100644 --- a/src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java +++ b/src/main/java/redot/redot_server/domain/admin/dto/response/AdminDashboardStatsResponse.java @@ -5,8 +5,7 @@ public record AdminDashboardStatsResponse( @Schema(description = "전체 Redot 회원 수") long totalRedotMembers, @Schema(description = "전일까지 누적된 Redot 회원 수") long redotMembersUntilYesterday, - @Schema(description = "전일 이후 가입한 Redot 회원 수") long newRedotMembersSinceYesterday, - @Schema(description = "누적 상담 요청 수") long consultationRequestCount, + @Schema(description = "상태가 PENDING 인 상담 요청 수") long pendingConsultationCount, @Schema(description = "등록된 관리자 수") long adminCount ) { } diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java index 312ce6a..6245557 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java @@ -8,6 +8,7 @@ import org.springframework.transaction.annotation.Transactional; import redot.redot_server.domain.admin.dto.response.AdminDashboardStatsResponse; import redot.redot_server.domain.admin.repository.AdminRepository; +import redot.redot_server.domain.redot.consultation.entity.ConsultationStatus; import redot.redot_server.domain.redot.consultation.repository.ConsultationRepository; import redot.redot_server.domain.redot.member.repository.RedotMemberRepository; @@ -25,16 +26,14 @@ public AdminDashboardStatsResponse getDashboardStats() { long totalRedotMembers = redotMemberRepository.count(); long redotMembersUntilYesterday = redotMemberRepository.countByCreatedAtBefore(startOfToday); - long newRedotMembersSinceYesterday = Math.max(0L, totalRedotMembers - redotMembersUntilYesterday); - long consultationRequestCount = consultationRepository.count(); + long pendingConsultationCount = consultationRepository.countByStatus(ConsultationStatus.PENDING); long adminCount = adminRepository.count(); return new AdminDashboardStatsResponse( totalRedotMembers, redotMembersUntilYesterday, - newRedotMembersSinceYesterday, - consultationRequestCount, + pendingConsultationCount, adminCount ); } diff --git a/src/main/java/redot/redot_server/domain/redot/consultation/repository/ConsultationRepository.java b/src/main/java/redot/redot_server/domain/redot/consultation/repository/ConsultationRepository.java index d8f6500..ff7d556 100644 --- a/src/main/java/redot/redot_server/domain/redot/consultation/repository/ConsultationRepository.java +++ b/src/main/java/redot/redot_server/domain/redot/consultation/repository/ConsultationRepository.java @@ -2,6 +2,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import redot.redot_server.domain.redot.consultation.entity.Consultation; +import redot.redot_server.domain.redot.consultation.entity.ConsultationStatus; public interface ConsultationRepository extends JpaRepository, ConsultationRepositoryCustom { + + long countByStatus(ConsultationStatus status); } From 02bfe61ec1a0888cd60241b412099f1db5e85063 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Wed, 17 Dec 2025 18:11:20 +0900 Subject: [PATCH 08/11] feat(admin): adjust timezone for admin dashboard stats to Asia/Seoul --- .../domain/admin/service/AdminDashboardService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java index 6245557..2bec8d7 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java @@ -22,7 +22,7 @@ public class AdminDashboardService { private final AdminRepository adminRepository; public AdminDashboardStatsResponse getDashboardStats() { - LocalDateTime startOfToday = LocalDate.now(ZoneId.systemDefault()).atStartOfDay(); + LocalDateTime startOfToday = LocalDate.now(ZoneId.of("Asia/Seoul")).atStartOfDay(); long totalRedotMembers = redotMemberRepository.count(); long redotMembersUntilYesterday = redotMemberRepository.countByCreatedAtBefore(startOfToday); From 37871a4b392e729143d57054eb79fbb46bf6805a Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Wed, 17 Dec 2025 18:11:26 +0900 Subject: [PATCH 09/11] feat(admin): add index on created_at column for redot_members table --- .../db/migration/V7__add_index_to_redot_members_created_at.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/main/resources/db/migration/V7__add_index_to_redot_members_created_at.sql diff --git a/src/main/resources/db/migration/V7__add_index_to_redot_members_created_at.sql b/src/main/resources/db/migration/V7__add_index_to_redot_members_created_at.sql new file mode 100644 index 0000000..e8a5e28 --- /dev/null +++ b/src/main/resources/db/migration/V7__add_index_to_redot_members_created_at.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_redot_members_created_at ON redot_members(created_at); From 4f18f67953fa4537ff2fdc5389ed1231dd45638d Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Wed, 17 Dec 2025 18:20:29 +0900 Subject: [PATCH 10/11] feat(admin): refactor dashboard stats to use ZonedDateTime for accurate time handling --- .../domain/admin/service/AdminDashboardService.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java index 2bec8d7..3bd0a00 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java @@ -1,8 +1,8 @@ package redot.redot_server.domain.admin.service; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.ZonedDateTime; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,10 +22,12 @@ public class AdminDashboardService { private final AdminRepository adminRepository; public AdminDashboardStatsResponse getDashboardStats() { - LocalDateTime startOfToday = LocalDate.now(ZoneId.of("Asia/Seoul")).atStartOfDay(); + ZoneId kstZone = ZoneId.of("Asia/Seoul"); + ZonedDateTime startOfTodayKst = LocalDate.now(kstZone).atStartOfDay(kstZone); + ZonedDateTime startOfTodayUtc = startOfTodayKst.withZoneSameInstant(ZoneId.of("UTC")); long totalRedotMembers = redotMemberRepository.count(); - long redotMembersUntilYesterday = redotMemberRepository.countByCreatedAtBefore(startOfToday); + long redotMembersUntilYesterday = redotMemberRepository.countByCreatedAtBefore(startOfTodayUtc.toLocalDateTime()); long pendingConsultationCount = consultationRepository.countByStatus(ConsultationStatus.PENDING); long adminCount = adminRepository.count(); From d2d89ab612d1f298402e8bc44ca055db6dee971e Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Wed, 17 Dec 2025 18:26:10 +0900 Subject: [PATCH 11/11] feat(admin): refactor dashboard stats to use LocalDateTime for improved time handling --- .../domain/admin/service/AdminDashboardService.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java index 3bd0a00..97b6b62 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminDashboardService.java @@ -1,8 +1,9 @@ package redot.redot_server.domain.admin.service; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.ZonedDateTime; +import java.time.ZoneOffset; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,11 +24,13 @@ public class AdminDashboardService { public AdminDashboardStatsResponse getDashboardStats() { ZoneId kstZone = ZoneId.of("Asia/Seoul"); - ZonedDateTime startOfTodayKst = LocalDate.now(kstZone).atStartOfDay(kstZone); - ZonedDateTime startOfTodayUtc = startOfTodayKst.withZoneSameInstant(ZoneId.of("UTC")); + LocalDateTime startOfTodayKst = LocalDate.now(kstZone).atStartOfDay(); + LocalDateTime startOfTodayUtc = startOfTodayKst.atZone(kstZone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime(); long totalRedotMembers = redotMemberRepository.count(); - long redotMembersUntilYesterday = redotMemberRepository.countByCreatedAtBefore(startOfTodayUtc.toLocalDateTime()); + long redotMembersUntilYesterday = redotMemberRepository.countByCreatedAtBefore(startOfTodayUtc); long pendingConsultationCount = consultationRepository.countByStatus(ConsultationStatus.PENDING); long adminCount = adminRepository.count();