From 33c913ece682a9ba30efd8bc44ac6816c8adeee6 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Thu, 20 Aug 2026 14:57:26 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(admin):=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B3=B5=ED=86=B5=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EA=B3=BC=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 로그 조회에 사용할 페이지 응답 DTO와 닉네임·이메일 검색 조건 정규화 및 최신순 정렬 규칙을 추가함. report_generation_logs, crystal_logs, ask_chat_wallet_logs의 전체 최신순 조회를 지원하도록 created_at·id 복합 인덱스를 Flyway migration으로 추가함 --- .../dto/response/AdminLogPageResponse.java | 46 ++++++++++++++++ .../application/AdminLogSearchCondition.java | 54 +++++++++++++++++++ ...0__IS_add_admin_log_created_at_indexes.sql | 8 +++ .../response/AdminLogPageResponseTest.java | 44 +++++++++++++++ .../AdminLogSearchConditionTest.java | 47 ++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java create mode 100644 src/main/resources/db/migration/V20260820_1300__IS_add_admin_log_created_at_indexes.sql create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponseTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponse.java new file mode 100644 index 00000000..f4118096 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponse.java @@ -0,0 +1,46 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Schema(description = "관리자 로그 페이지 응답") +public record AdminLogPageResponse( + @Schema(description = "현재 페이지의 로그 목록") + List items, + + @Schema(description = "전체 로그 개수", example = "43") + long totalCount, + + @Schema(description = "현재 페이지 번호(1부터 시작)", example = "1") + int currentPage, + + @Schema(description = "요청한 페이지 크기", example = "20") + int pageSize, + + @Schema(description = "전체 페이지 수", example = "3") + int totalPages, + + @Schema(description = "이전 페이지 존재 여부", example = "false") + boolean hasPrevious, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext +) { + + public static AdminLogPageResponse from(Page page) { + int currentPage = page.getNumber() + 1; + int totalPages = page.getTotalPages(); + + return new AdminLogPageResponse<>( + page.getContent(), + page.getTotalElements(), + currentPage, + page.getSize(), + totalPages, + currentPage > 1 && totalPages > 0, + totalPages > 0 && currentPage < totalPages + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java new file mode 100644 index 00000000..088bf7f2 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java @@ -0,0 +1,54 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +public record AdminLogSearchCondition( + int page, + int size, + String nickname, + String email +) { + + public static final int DEFAULT_PAGE = 1; + public static final int DEFAULT_SIZE = 20; + public static final int MAX_PAGE_SIZE = 50; + + public AdminLogSearchCondition { + if (page < 1 || size < 1 || size > MAX_PAGE_SIZE) { + throw new BadRequestException(ErrorCode.VALIDATION_FAILED); + } + + nickname = normalize(nickname); + email = normalize(email); + } + + public static AdminLogSearchCondition of( + int page, + int size, + String nickname, + String email + ) { + return new AdminLogSearchCondition(page, size, nickname, email); + } + + public Pageable toPageable() { + Sort sort = Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ); + return PageRequest.of(page - 1, size, sort); + } + + private static String normalize(String value) { + if (value == null) { + return null; + } + + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } +} diff --git a/src/main/resources/db/migration/V20260820_1300__IS_add_admin_log_created_at_indexes.sql b/src/main/resources/db/migration/V20260820_1300__IS_add_admin_log_created_at_indexes.sql new file mode 100644 index 00000000..84b71bc2 --- /dev/null +++ b/src/main/resources/db/migration/V20260820_1300__IS_add_admin_log_created_at_indexes.sql @@ -0,0 +1,8 @@ +CREATE INDEX idx_report_generation_logs_created_id + ON report_generation_logs(created_at DESC, id DESC); + +CREATE INDEX idx_crystal_logs_created_id + ON crystal_logs(created_at DESC, id DESC); + +CREATE INDEX idx_ask_chat_wallet_logs_created_id + ON ask_chat_wallet_logs(created_at DESC, id DESC); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponseTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponseTest.java new file mode 100644 index 00000000..9c826f1d --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponseTest.java @@ -0,0 +1,44 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdminLogPageResponseTest { + + @Test + void maps_spring_page_to_admin_log_page_response() { + AdminLogPageResponse response = AdminLogPageResponse.from( + new PageImpl<>( + List.of("log-2"), + PageRequest.of(1, 1), + 3 + ) + ); + + assertThat(response.items()).containsExactly("log-2"); + assertThat(response.totalCount()).isEqualTo(3); + assertThat(response.currentPage()).isEqualTo(2); + assertThat(response.pageSize()).isEqualTo(1); + assertThat(response.totalPages()).isEqualTo(3); + assertThat(response.hasPrevious()).isTrue(); + assertThat(response.hasNext()).isTrue(); + } + + @Test + void reports_no_navigation_for_empty_page() { + AdminLogPageResponse response = AdminLogPageResponse.from( + new PageImpl<>(List.of(), PageRequest.of(0, 20), 0) + ); + + assertThat(response.items()).isEmpty(); + assertThat(response.totalCount()).isZero(); + assertThat(response.totalPages()).isZero(); + assertThat(response.hasPrevious()).isFalse(); + assertThat(response.hasNext()).isFalse(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java new file mode 100644 index 00000000..c88c328a --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java @@ -0,0 +1,47 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; + +class AdminLogSearchConditionTest { + + @Test + void normalizes_search_values_and_builds_latest_first_pageable() { + AdminLogSearchCondition condition = AdminLogSearchCondition.of( + 2, + 30, + " nadab ", + " " + ); + + assertThat(condition.page()).isEqualTo(2); + assertThat(condition.size()).isEqualTo(30); + assertThat(condition.nickname()).isEqualTo("nadab"); + assertThat(condition.email()).isNull(); + assertThat(condition.toPageable().getPageNumber()).isEqualTo(1); + assertThat(condition.toPageable().getPageSize()).isEqualTo(30); + assertThat(condition.toPageable().getSort().toList()) + .extracting(Sort.Order::getProperty, Sort.Order::getDirection) + .containsExactly( + tuple("createdAt", Sort.Direction.DESC), + tuple("id", Sort.Direction.DESC) + ); + } + + @Test + void rejects_invalid_page_request() { + assertThatThrownBy(() -> AdminLogSearchCondition.of(0, 20, null, null)) + .isInstanceOfSatisfying(BadRequestException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.VALIDATION_FAILED)); + assertThatThrownBy(() -> AdminLogSearchCondition.of(1, 0, null, null)) + .isInstanceOf(BadRequestException.class); + assertThatThrownBy(() -> AdminLogSearchCondition.of(1, 51, null, null)) + .isInstanceOf(BadRequestException.class); + } +} From eb1aa7da77bbf9e3f7ce508e4669e79275e4ebe6 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Thu, 20 Aug 2026 15:14:48 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(admin):=20=EB=A6=AC=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EA=B7=B8=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit report_generation_logs를 사용자 닉네임·이메일로 검색하고 최신순 페이지네이션으로 조회하는 관리자 API를 추가함 --- .../AdminReportGenerationLogController.java | 35 ++++++ .../dto/response/AdminLogUserResponse.java | 29 +++++ .../AdminReportGenerationLogResponse.java | 99 +++++++++++++++++ .../application/AdminLogSearchCondition.java | 4 +- .../AdminReportGenerationLogQueryService.java | 29 +++++ .../ReportGenerationLogRepository.java | 29 +++++ ...dminReportGenerationLogControllerTest.java | 53 +++++++++ .../AdminLogSearchConditionTest.java | 2 +- ...inReportGenerationLogQueryServiceTest.java | 75 +++++++++++++ .../ReportGenerationLogRepositoryTest.java | 105 ++++++++++++++++++ 10 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogController.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogUserResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminReportGenerationLogResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryService.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogControllerTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryServiceTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogController.java new file mode 100644 index 00000000..db18aaf7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogController.java @@ -0,0 +1,35 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminReportGenerationLogResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.domain.admin.application.AdminReportGenerationLogQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import io.swagger.v3.oas.annotations.Hidden; +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; + +@Hidden +@RestController +@RequestMapping("/admin/api/logs/report-generation") +@RequiredArgsConstructor +public class AdminReportGenerationLogController { + + private final AdminReportGenerationLogQueryService adminReportGenerationLogQueryService; + + @GetMapping + public ResponseEntity>> getLogs( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String email + ) { + AdminLogSearchCondition condition = AdminLogSearchCondition.of(page, size, nickname, email); + return ApiResponseEntity.ok(adminReportGenerationLogQueryService.getLogs(condition)); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogUserResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogUserResponse.java new file mode 100644 index 00000000..1b6c3861 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogUserResponse.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "관리자 로그 사용자 정보") +public record AdminLogUserResponse( + @Schema(description = "사용자 ID", example = "101") + Long id, + + @Schema(description = "닉네임", example = "nadab_user") + String nickname, + + @Schema(description = "이메일", example = "user@example.com") + String email +) { + + public static AdminLogUserResponse from(User user) { + if (user == null) { + return null; + } + + return new AdminLogUserResponse( + user.getId(), + user.getNickname(), + user.getEmail() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminReportGenerationLogResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminReportGenerationLogResponse.java new file mode 100644 index 00000000..8d1b8862 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminReportGenerationLogResponse.java @@ -0,0 +1,99 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLogStatus; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +@Schema(description = "관리자 리포트 생성 로그") +public record AdminReportGenerationLogResponse( + @Schema(description = "로그 ID", example = "1001") + Long id, + + @Schema(description = "로그 사용자") + AdminLogUserResponse user, + + @Schema(description = "리포트 유형", example = "MONTHLY_V2") + ReportGenerationType reportType, + + @Schema(description = "리포트 ID", example = "2001") + Long reportId, + + @Schema(description = "생성 단계", example = "MONTHLY_V2_TEXT_CONFIRM") + ReportGenerationStep step, + + @Schema(description = "로그 상태", example = "SUCCEEDED") + ReportGenerationLogStatus status, + + @Schema(description = "LLM 제공자", example = "OPENAI") + LlmProvider llmProvider, + + @Schema(description = "LLM 모델", example = "GPT_4_O_MINI") + String llmModel, + + @Schema(description = "내부 오류 코드", example = "AI_NO_RESPONSE") + String errorCode, + + @Schema(description = "예외 클래스", example = "com.example.AiException") + String exceptionClass, + + @Schema(description = "외부 응답 HTTP 상태", example = "503") + Integer httpStatus, + + @Schema(description = "외부 오류 코드", example = "HTTP_503") + String externalErrorCode, + + @Schema(description = "처리 시간(ms)", example = "1250") + Long elapsedMs, + + @Schema(description = "입력 토큰 수", example = "1000") + Long inputTokens, + + @Schema(description = "출력 토큰 수", example = "500") + Long outputTokens, + + @Schema(description = "전체 토큰 수", example = "1500") + Long totalTokens, + + @Schema(description = "추론 토큰 수", example = "300") + Long thinkingTokens, + + @Schema(description = "생성 시작 시각") + OffsetDateTime startedAt, + + @Schema(description = "생성 종료 시각") + OffsetDateTime endedAt, + + @Schema(description = "로그 생성 시각") + OffsetDateTime createdAt +) { + + public static AdminReportGenerationLogResponse from(ReportGenerationLog log) { + return new AdminReportGenerationLogResponse( + log.getId(), + AdminLogUserResponse.from(log.getUser()), + log.getReportType(), + log.getReportId(), + log.getStep(), + log.getStatus(), + log.getLlmProvider(), + log.getLlmModel(), + log.getErrorCode(), + log.getExceptionClass(), + log.getHttpStatus(), + log.getExternalErrorCode(), + log.getElapsedMs(), + log.getInputTokens(), + log.getOutputTokens(), + log.getTotalTokens(), + log.getThinkingTokens(), + log.getStartedAt(), + log.getEndedAt(), + log.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java index 088bf7f2..50214e60 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java @@ -6,6 +6,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import java.util.Locale; + public record AdminLogSearchCondition( int page, int size, @@ -49,6 +51,6 @@ private static String normalize(String value) { } String normalized = value.trim(); - return normalized.isEmpty() ? null : normalized; + return normalized.isEmpty() ? null : normalized.toLowerCase(Locale.ROOT); } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryService.java new file mode 100644 index 00000000..c0dd4e4d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryService.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminReportGenerationLogResponse; +import com.devkor.ifive.nadab.domain.reportlog.core.repository.ReportGenerationLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminReportGenerationLogQueryService { + + private final ReportGenerationLogRepository reportGenerationLogRepository; + + public AdminLogPageResponse getLogs( + AdminLogSearchCondition condition + ) { + return AdminLogPageResponse.from( + reportGenerationLogRepository.findAllForAdmin( + condition.nickname(), + condition.email(), + condition.toPageable() + ) + .map(AdminReportGenerationLogResponse::from) + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java index 8609da43..5a76faef 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java @@ -3,7 +3,11 @@ import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLogStatus; import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -17,4 +21,29 @@ List findAllByReportTypeAndReportIdOrderByCreatedAtDesc( List findAllByUserIdOrderByCreatedAtDesc(Long userId); List findAllByStatusOrderByCreatedAtDesc(ReportGenerationLogStatus status); + + @Query(value = """ + SELECT l + FROM ReportGenerationLog l + LEFT JOIN FETCH l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + ORDER BY l.createdAt DESC, l.id DESC + """, + countQuery = """ + SELECT COUNT(l) + FROM ReportGenerationLog l + LEFT JOIN l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + """) + Page findAllForAdmin( + @Param("nickname") String nickname, + @Param("email") String email, + Pageable pageable + ); } diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogControllerTest.java new file mode 100644 index 00000000..78fd0d6b --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogControllerTest.java @@ -0,0 +1,53 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminReportGenerationLogResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.domain.admin.application.AdminReportGenerationLogQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminReportGenerationLogControllerTest { + + @Mock + private AdminReportGenerationLogQueryService adminReportGenerationLogQueryService; + + @InjectMocks + private AdminReportGenerationLogController controller; + + @Test + void get_logs_passes_page_and_user_filters_to_service() { + AdminLogPageResponse expected = + new AdminLogPageResponse<>(List.of(), 0, 1, 20, 0, false, false); + when(adminReportGenerationLogQueryService.getLogs(org.mockito.ArgumentMatchers.any())) + .thenReturn(expected); + + ResponseEntity>> response = + controller.getLogs(1, 20, " alice ", "alice@example.com"); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData()).isSameAs(expected); + + ArgumentCaptor conditionCaptor = + ArgumentCaptor.forClass(AdminLogSearchCondition.class); + verify(adminReportGenerationLogQueryService).getLogs(conditionCaptor.capture()); + assertThat(conditionCaptor.getValue().nickname()).isEqualTo("alice"); + assertThat(conditionCaptor.getValue().email()).isEqualTo("alice@example.com"); + assertThat(conditionCaptor.getValue().page()).isEqualTo(1); + assertThat(conditionCaptor.getValue().size()).isEqualTo(20); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java index c88c328a..23c75a9f 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchConditionTest.java @@ -16,7 +16,7 @@ void normalizes_search_values_and_builds_latest_first_pageable() { AdminLogSearchCondition condition = AdminLogSearchCondition.of( 2, 30, - " nadab ", + " NaDaB ", " " ); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryServiceTest.java new file mode 100644 index 00000000..710a7499 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryServiceTest.java @@ -0,0 +1,75 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminReportGenerationLogResponse; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import com.devkor.ifive.nadab.domain.reportlog.core.repository.ReportGenerationLogRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminReportGenerationLogQueryServiceTest { + + @Mock + private ReportGenerationLogRepository reportGenerationLogRepository; + + @Test + void maps_report_generation_logs_to_admin_page_response() { + AdminReportGenerationLogQueryService service = + new AdminReportGenerationLogQueryService(reportGenerationLogRepository); + User user = User.createUser("alice@example.com", "hashed_password"); + user.updateNickname("alice"); + ReportGenerationLog log = ReportGenerationLog.start( + user, + ReportGenerationType.MONTHLY_V2, + 501L, + ReportGenerationStep.MONTHLY_V2_TEXT_CONFIRM, + LlmProvider.OPENAI, + "GPT_4_O_MINI" + ); + AdminLogSearchCondition condition = AdminLogSearchCondition.of(2, 10, "alice", null); + when(reportGenerationLogRepository.findAllForAdmin(any(), any(), any())) + .thenReturn(new PageImpl<>(List.of(log), condition.toPageable(), 11)); + + AdminLogPageResponse response = service.getLogs(condition); + + assertThat(response.currentPage()).isEqualTo(2); + assertThat(response.pageSize()).isEqualTo(10); + assertThat(response.totalCount()).isEqualTo(11); + assertThat(response.items()).singleElement().satisfies(item -> { + assertThat(item.user().nickname()).isEqualTo("alice"); + assertThat(item.user().email()).isEqualTo("alice@example.com"); + assertThat(item.reportType()).isEqualTo(ReportGenerationType.MONTHLY_V2); + assertThat(item.reportId()).isEqualTo(501L); + assertThat(item.step()).isEqualTo(ReportGenerationStep.MONTHLY_V2_TEXT_CONFIRM); + }); + + ArgumentCaptor nicknameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor emailCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(reportGenerationLogRepository).findAllForAdmin( + nicknameCaptor.capture(), + emailCaptor.capture(), + pageableCaptor.capture() + ); + assertThat(nicknameCaptor.getValue()).isEqualTo("alice"); + assertThat(emailCaptor.getValue()).isNull(); + assertThat(pageableCaptor.getValue()).isEqualTo(condition.toPageable()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java index a8428f0b..89273be7 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java @@ -13,8 +13,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.test.context.ActiveProfiles; +import java.util.Comparator; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -102,6 +106,100 @@ void save_token_usage() { assertThat(found.getThinkingTokens()).isEqualTo(30L); } + @Test + void find_all_for_admin_filters_by_nickname_and_email() { + // given + User matchingUser = user("alice@example.com", "alice"); + User sameNicknameUser = user("bob@example.com", "alice-other"); + User otherUser = user("carol@example.com", "carol"); + + ReportGenerationLog matchingLog = startLog( + matchingUser, + ReportGenerationType.MONTHLY_V2, + 301L, + ReportGenerationStep.MONTHLY_V2_TEXT_CONFIRM + ); + reportGenerationLogRepository.save(matchingLog); + reportGenerationLogRepository.save( + startLog( + sameNicknameUser, + ReportGenerationType.MONTHLY_V2, + 302L, + ReportGenerationStep.MONTHLY_V2_TEXT_CONFIRM + ) + ); + reportGenerationLogRepository.save( + startLog( + otherUser, + ReportGenerationType.DAILY, + 303L, + ReportGenerationStep.DAILY_GENERATE + ) + ); + + em.flush(); + em.clear(); + + // when + Page logs = reportGenerationLogRepository.findAllForAdmin( + "ali", + "alice@example.com", + PageRequest.of(0, 20) + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(1); + assertThat(logs.getContent()).extracting(ReportGenerationLog::getReportId) + .containsExactly(301L); + assertThat(logs.getContent().get(0).getUser().getEmail()) + .isEqualTo("alice@example.com"); + } + + @Test + void find_all_for_admin_keeps_null_user_logs_and_orders_latest_first() { + // given + User user = user("latest@example.com", "latest"); + ReportGenerationLog first = reportGenerationLogRepository.save( + startLog(user, ReportGenerationType.DAILY, 401L, ReportGenerationStep.DAILY_GENERATE) + ); + ReportGenerationLog second = reportGenerationLogRepository.save( + startLog(user, ReportGenerationType.WEEKLY, 402L, ReportGenerationStep.WEEKLY_GENERATE) + ); + ReportGenerationLog orphan = reportGenerationLogRepository.save( + startLog(null, ReportGenerationType.TYPE, 403L, ReportGenerationStep.TYPE_SELECTION) + ); + + em.flush(); + em.clear(); + + // when + Page logs = reportGenerationLogRepository.findAllForAdmin( + null, + null, + PageRequest.of( + 0, + 20, + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")) + ) + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(3); + assertThat(logs.getContent()) + .isSortedAccordingTo( + Comparator.comparing(ReportGenerationLog::getCreatedAt) + .thenComparing(ReportGenerationLog::getId) + .reversed() + ); + assertThat(logs.getContent()) + .extracting(ReportGenerationLog::getId) + .contains(first.getId(), second.getId(), orphan.getId()); + assertThat(logs.getContent()) + .filteredOn(log -> log.getId().equals(orphan.getId())) + .singleElement() + .satisfies(log -> assertThat(log.getUser()).isNull()); + } + private ReportGenerationLog startLog( User user, ReportGenerationType reportType, @@ -117,4 +215,11 @@ private ReportGenerationLog startLog( "GPT_4_O_MINI" ); } + + private User user(String email, String nickname) { + User user = User.createUser(email, "hashed_password"); + user.updateNickname(nickname); + em.persist(user); + return user; + } } From 21dbb00c29c011e7967b3a650c4286054fba86ed Mon Sep 17 00:00:00 2001 From: 1Seob Date: Thu, 20 Aug 2026 15:24:42 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(admin):=20=ED=81=AC=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=83=88=20=EB=A1=9C=EA=B7=B8=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crystal_logs를 사용자 닉네임·이메일로 검색하고 최신순 페이지네이션으로 조회하는 관리자 API를 추가함 --- .../admin/api/AdminCrystalLogController.java | 35 +++++ .../dto/response/AdminCrystalLogResponse.java | 53 +++++++ .../AdminCrystalLogQueryService.java | 29 ++++ .../core/repository/CrystalLogRepository.java | 27 ++++ .../api/AdminCrystalLogControllerTest.java | 53 +++++++ .../AdminCrystalLogQueryServiceTest.java | 76 +++++++++ .../wallet/CrystalLogRepositoryTest.java | 146 ++++++++++++++++++ 7 files changed, 419 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogController.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminCrystalLogResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryService.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogControllerTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryServiceTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/wallet/CrystalLogRepositoryTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogController.java new file mode 100644 index 00000000..7e39b2a5 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogController.java @@ -0,0 +1,35 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminCrystalLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminCrystalLogQueryService; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import io.swagger.v3.oas.annotations.Hidden; +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; + +@Hidden +@RestController +@RequestMapping("/admin/api/logs/crystal") +@RequiredArgsConstructor +public class AdminCrystalLogController { + + private final AdminCrystalLogQueryService adminCrystalLogQueryService; + + @GetMapping + public ResponseEntity>> getLogs( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String email + ) { + AdminLogSearchCondition condition = AdminLogSearchCondition.of(page, size, nickname, email); + return ApiResponseEntity.ok(adminCrystalLogQueryService.getLogs(condition)); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminCrystalLogResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminCrystalLogResponse.java new file mode 100644 index 00000000..a0f69871 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminCrystalLogResponse.java @@ -0,0 +1,53 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +@Schema(description = "관리자 크리스탈 로그") +public record AdminCrystalLogResponse( + @Schema(description = "로그 ID", example = "1001") + Long id, + + @Schema(description = "로그 사용자") + AdminLogUserResponse user, + + @Schema(description = "크리스탈 변동량", example = "-100") + long delta, + + @Schema(description = "변동 후 크리스탈 잔액", example = "900") + long balanceAfter, + + @Schema(description = "변동 사유", example = "REPORT_GENERATE_MONTHLY") + CrystalLogReason reason, + + @Schema(description = "로그 상태", example = "CONFIRMED") + CrystalLogStatus status, + + @Schema(description = "참조 유형", example = "MONTHLY_REPORT") + String refType, + + @Schema(description = "참조 ID", example = "2001") + Long refId, + + @Schema(description = "로그 생성 시각") + OffsetDateTime createdAt +) { + + public static AdminCrystalLogResponse from(CrystalLog log) { + return new AdminCrystalLogResponse( + log.getId(), + AdminLogUserResponse.from(log.getUser()), + log.getDelta(), + log.getBalanceAfter(), + log.getReason(), + log.getStatus(), + log.getRefType(), + log.getRefId(), + log.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryService.java new file mode 100644 index 00000000..06c0c2cc --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryService.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminCrystalLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminCrystalLogQueryService { + + private final CrystalLogRepository crystalLogRepository; + + public AdminLogPageResponse getLogs( + AdminLogSearchCondition condition + ) { + return AdminLogPageResponse.from( + crystalLogRepository.findAllForAdmin( + condition.nickname(), + condition.email(), + condition.toPageable() + ) + .map(AdminCrystalLogResponse::from) + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/repository/CrystalLogRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/repository/CrystalLogRepository.java index a127fe39..ca3e9d30 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/repository/CrystalLogRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/repository/CrystalLogRepository.java @@ -1,6 +1,8 @@ package com.devkor.ifive.nadab.domain.wallet.core.repository; import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -8,6 +10,31 @@ public interface CrystalLogRepository extends JpaRepository { + @Query(value = """ + SELECT l + FROM CrystalLog l + LEFT JOIN FETCH l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + ORDER BY l.createdAt DESC, l.id DESC + """, + countQuery = """ + SELECT COUNT(l) + FROM CrystalLog l + LEFT JOIN l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + """) + Page findAllForAdmin( + @Param("nickname") String nickname, + @Param("email") String email, + Pageable pageable + ); + @Modifying @Query("UPDATE CrystalLog l SET l.status = com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogStatus.CONFIRMED WHERE l.id = :logId") int markConfirmed(@Param("logId") Long logId); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogControllerTest.java new file mode 100644 index 00000000..05968d7f --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogControllerTest.java @@ -0,0 +1,53 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminCrystalLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.domain.admin.application.AdminCrystalLogQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminCrystalLogControllerTest { + + @Mock + private AdminCrystalLogQueryService adminCrystalLogQueryService; + + @InjectMocks + private AdminCrystalLogController controller; + + @Test + void get_logs_passes_page_and_user_filters_to_service() { + AdminLogPageResponse expected = + new AdminLogPageResponse<>(List.of(), 0, 1, 20, 0, false, false); + when(adminCrystalLogQueryService.getLogs(org.mockito.ArgumentMatchers.any())) + .thenReturn(expected); + + ResponseEntity>> response = + controller.getLogs(1, 20, " Alice ", " alice@example.com "); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData()).isSameAs(expected); + + ArgumentCaptor conditionCaptor = + ArgumentCaptor.forClass(AdminLogSearchCondition.class); + verify(adminCrystalLogQueryService).getLogs(conditionCaptor.capture()); + assertThat(conditionCaptor.getValue().nickname()).isEqualTo("alice"); + assertThat(conditionCaptor.getValue().email()).isEqualTo("alice@example.com"); + assertThat(conditionCaptor.getValue().page()).isEqualTo(1); + assertThat(conditionCaptor.getValue().size()).isEqualTo(20); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryServiceTest.java new file mode 100644 index 00000000..e1384df8 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryServiceTest.java @@ -0,0 +1,76 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminCrystalLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogStatus; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminCrystalLogQueryServiceTest { + + @Mock + private CrystalLogRepository crystalLogRepository; + + @Test + void maps_crystal_logs_to_admin_page_response() { + AdminCrystalLogQueryService service = new AdminCrystalLogQueryService(crystalLogRepository); + User user = User.createUser("alice@example.com", "hashed_password"); + user.updateNickname("alice"); + CrystalLog log = CrystalLog.createPending( + user, + -100L, + 900L, + CrystalLogReason.REPORT_GENERATE_MONTHLY, + "MONTHLY_REPORT", + 501L + ); + AdminLogSearchCondition condition = AdminLogSearchCondition.of(2, 10, "alice", null); + when(crystalLogRepository.findAllForAdmin(any(), any(), any())) + .thenReturn(new PageImpl<>(List.of(log), condition.toPageable(), 11)); + + AdminLogPageResponse response = service.getLogs(condition); + + assertThat(response.currentPage()).isEqualTo(2); + assertThat(response.pageSize()).isEqualTo(10); + assertThat(response.totalCount()).isEqualTo(11); + assertThat(response.items()).singleElement().satisfies(item -> { + assertThat(item.user().nickname()).isEqualTo("alice"); + assertThat(item.user().email()).isEqualTo("alice@example.com"); + assertThat(item.delta()).isEqualTo(-100L); + assertThat(item.balanceAfter()).isEqualTo(900L); + assertThat(item.reason()).isEqualTo(CrystalLogReason.REPORT_GENERATE_MONTHLY); + assertThat(item.status()).isEqualTo(CrystalLogStatus.PENDING); + assertThat(item.refType()).isEqualTo("MONTHLY_REPORT"); + assertThat(item.refId()).isEqualTo(501L); + }); + + ArgumentCaptor nicknameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor emailCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(crystalLogRepository).findAllForAdmin( + nicknameCaptor.capture(), + emailCaptor.capture(), + pageableCaptor.capture() + ); + assertThat(nicknameCaptor.getValue()).isEqualTo("alice"); + assertThat(emailCaptor.getValue()).isNull(); + assertThat(pageableCaptor.getValue()).isEqualTo(condition.toPageable()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/wallet/CrystalLogRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/wallet/CrystalLogRepositoryTest.java new file mode 100644 index 00000000..57245de4 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/wallet/CrystalLogRepositoryTest.java @@ -0,0 +1,146 @@ +package com.devkor.ifive.nadab.domain.wallet; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogStatus; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Comparator; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class CrystalLogRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + CrystalLogRepository crystalLogRepository; + + @Autowired + TestEntityManager em; + + @Test + void find_all_for_admin_filters_by_nickname_and_email() { + // given + User matchingUser = user("alice@example.com", "alice"); + User sameNicknameUser = user("bob@example.com", "alice-other"); + User otherUser = user("carol@example.com", "carol"); + + CrystalLog matchingLog = crystalLogRepository.save( + confirmedLog(matchingUser, -100L, 900L, "MONTHLY_REPORT", 301L) + ); + crystalLogRepository.save( + confirmedLog(sameNicknameUser, -200L, 800L, "MONTHLY_REPORT", 302L) + ); + crystalLogRepository.save( + confirmedLog(otherUser, 100L, 1000L, "DAILY_REWARD", 303L) + ); + + em.flush(); + em.clear(); + + // when + Page logs = crystalLogRepository.findAllForAdmin( + "ali", + "alice@example.com", + latestFirstPage() + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(1); + assertThat(logs.getContent()).extracting(CrystalLog::getRefId) + .containsExactly(301L); + assertThat(logs.getContent().get(0).getUser().getEmail()) + .isEqualTo("alice@example.com"); + } + + @Test + void find_all_for_admin_orders_latest_first() { + // given + User user = user("latest@example.com", "latest"); + CrystalLog first = crystalLogRepository.save( + confirmedLog(user, 100L, 100L, "DAILY_REWARD", 401L) + ); + CrystalLog second = crystalLogRepository.save( + crystalLog(user, -50L, 50L, CrystalLogStatus.PENDING, "MONTHLY_REPORT", 402L) + ); + + em.flush(); + em.clear(); + + // when + Page logs = crystalLogRepository.findAllForAdmin( + null, + null, + latestFirstPage() + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(2); + assertThat(logs.getContent()) + .isSortedAccordingTo( + Comparator.comparing(CrystalLog::getCreatedAt) + .thenComparing(CrystalLog::getId) + .reversed() + ); + assertThat(logs.getContent()).extracting(CrystalLog::getId) + .containsExactly(second.getId(), first.getId()); + } + + private CrystalLog confirmedLog(User user, long delta, long balanceAfter, String refType, Long refId) { + return crystalLog(user, delta, balanceAfter, CrystalLogStatus.CONFIRMED, refType, refId); + } + + private CrystalLog crystalLog( + User user, + long delta, + long balanceAfter, + CrystalLogStatus status, + String refType, + Long refId + ) { + if (status == CrystalLogStatus.PENDING) { + return CrystalLog.createPending( + user, + delta, + balanceAfter, + CrystalLogReason.REPORT_GENERATE_MONTHLY, + refType, + refId + ); + } + return CrystalLog.createConfirmed( + user, + delta, + balanceAfter, + CrystalLogReason.DAILY_ANSWER_REWARD, + refType, + refId + ); + } + + private User user(String email, String nickname) { + User user = User.createUser(email, "hashed_password"); + user.updateNickname(nickname); + em.persist(user); + return user; + } + + private PageRequest latestFirstPage() { + return PageRequest.of( + 0, + 20, + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")) + ); + } +} From 0da3eb74fa00bc999b5ce3b23b3a69d5e9611a3c Mon Sep 17 00:00:00 2001 From: 1Seob Date: Thu, 20 Aug 2026 15:35:42 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(admin):=20Ask=20Chat=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=EA=B6=8C=20=EB=A1=9C=EA=B7=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ask_chat_wallet_logs를 사용자 닉네임·이메일로 검색하고 최신순 페이지네이션으로 조회하는 관리자 API를 추가함 --- .../api/AdminAskChatWalletLogController.java | 35 +++++ .../AdminAskChatWalletLogResponse.java | 73 +++++++++ .../AdminAskChatWalletLogQueryService.java | 29 ++++ .../AskChatWalletLogRepository.java | 27 ++++ .../AdminAskChatWalletLogControllerTest.java | 53 +++++++ ...AdminAskChatWalletLogQueryServiceTest.java | 87 +++++++++++ .../AskChatWalletLogRepositoryTest.java | 139 ++++++++++++++++++ 7 files changed, 443 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogController.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatWalletLogResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryService.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogControllerTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryServiceTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepositoryTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogController.java new file mode 100644 index 00000000..c48139de --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogController.java @@ -0,0 +1,35 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatWalletLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminAskChatWalletLogQueryService; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import io.swagger.v3.oas.annotations.Hidden; +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; + +@Hidden +@RestController +@RequestMapping("/admin/api/logs/ask-chat-wallet") +@RequiredArgsConstructor +public class AdminAskChatWalletLogController { + + private final AdminAskChatWalletLogQueryService adminAskChatWalletLogQueryService; + + @GetMapping + public ResponseEntity>> getLogs( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String email + ) { + AdminLogSearchCondition condition = AdminLogSearchCondition.of(page, size, nickname, email); + return ApiResponseEntity.ok(adminAskChatWalletLogQueryService.getLogs(condition)); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatWalletLogResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatWalletLogResponse.java new file mode 100644 index 00000000..533a343d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatWalletLogResponse.java @@ -0,0 +1,73 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLog; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLogReason; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLogStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +@Schema(description = "관리자 Ask Chat 대화권 로그") +public record AdminAskChatWalletLogResponse( + @Schema(description = "로그 ID", example = "1001") + Long id, + + @Schema(description = "로그 사용자") + AdminLogUserResponse user, + + @Schema(description = "Ask Chat 세션 ID", example = "3001") + Long sessionId, + + @Schema(description = "Ask Chat 메시지 ID", example = "4001") + Long messageId, + + @Schema(description = "무료 대화권 변동량", example = "-1") + int freeTurnDelta, + + @Schema(description = "유료 대화권 변동량", example = "0") + int paidTurnDelta, + + @Schema(description = "변동 후 무료 대화권 잔액", example = "2") + int freeTurnBalanceAfter, + + @Schema(description = "변동 후 유료 대화권 잔액", example = "10") + int paidTurnBalanceAfter, + + @Schema(description = "변동 사유", example = "ANSWER_SUCCESS_CONSUME") + AskChatWalletLogReason reason, + + @Schema(description = "로그 상태", example = "CONFIRMED") + AskChatWalletLogStatus status, + + @Schema(description = "참조 유형", example = "ASK_CHAT_MESSAGE") + String refType, + + @Schema(description = "참조 ID", example = "4001") + Long refId, + + @Schema(description = "멱등성 키", example = "ask-chat-message-4001") + String idempotencyKey, + + @Schema(description = "로그 생성 시각") + OffsetDateTime createdAt +) { + + public static AdminAskChatWalletLogResponse from(AskChatWalletLog log) { + return new AdminAskChatWalletLogResponse( + log.getId(), + AdminLogUserResponse.from(log.getUser()), + log.getSession() == null ? null : log.getSession().getId(), + log.getMessage() == null ? null : log.getMessage().getId(), + log.getFreeTurnDelta(), + log.getPaidTurnDelta(), + log.getFreeTurnBalanceAfter(), + log.getPaidTurnBalanceAfter(), + log.getReason(), + log.getStatus(), + log.getRefType(), + log.getRefId(), + log.getIdempotencyKey(), + log.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryService.java new file mode 100644 index 00000000..c8093d24 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryService.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatWalletLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminAskChatWalletLogQueryService { + + private final AskChatWalletLogRepository askChatWalletLogRepository; + + public AdminLogPageResponse getLogs( + AdminLogSearchCondition condition + ) { + return AdminLogPageResponse.from( + askChatWalletLogRepository.findAllForAdmin( + condition.nickname(), + condition.email(), + condition.toPageable() + ) + .map(AdminAskChatWalletLogResponse::from) + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepository.java index b418fe44..79f66de5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepository.java @@ -1,6 +1,8 @@ package com.devkor.ifive.nadab.domain.askchat.core.repository; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -12,6 +14,31 @@ public interface AskChatWalletLogRepository extends JpaRepository findAllByUserIdOrderByCreatedAtDesc(Long userId); + @Query(value = """ + SELECT l + FROM AskChatWalletLog l + LEFT JOIN FETCH l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + ORDER BY l.createdAt DESC, l.id DESC + """, + countQuery = """ + SELECT COUNT(l) + FROM AskChatWalletLog l + LEFT JOIN l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + """) + Page findAllForAdmin( + @Param("nickname") String nickname, + @Param("email") String email, + Pageable pageable + ); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" UPDATE AskChatWalletLog l diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogControllerTest.java new file mode 100644 index 00000000..349376e9 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogControllerTest.java @@ -0,0 +1,53 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatWalletLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminAskChatWalletLogQueryService; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminAskChatWalletLogControllerTest { + + @Mock + private AdminAskChatWalletLogQueryService adminAskChatWalletLogQueryService; + + @InjectMocks + private AdminAskChatWalletLogController controller; + + @Test + void get_logs_passes_page_and_user_filters_to_service() { + AdminLogPageResponse expected = + new AdminLogPageResponse<>(List.of(), 0, 1, 20, 0, false, false); + when(adminAskChatWalletLogQueryService.getLogs(org.mockito.ArgumentMatchers.any())) + .thenReturn(expected); + + ResponseEntity>> response = + controller.getLogs(1, 20, " Alice ", " alice@example.com "); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData()).isSameAs(expected); + + ArgumentCaptor conditionCaptor = + ArgumentCaptor.forClass(AdminLogSearchCondition.class); + verify(adminAskChatWalletLogQueryService).getLogs(conditionCaptor.capture()); + assertThat(conditionCaptor.getValue().nickname()).isEqualTo("alice"); + assertThat(conditionCaptor.getValue().email()).isEqualTo("alice@example.com"); + assertThat(conditionCaptor.getValue().page()).isEqualTo(1); + assertThat(conditionCaptor.getValue().size()).isEqualTo(20); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryServiceTest.java new file mode 100644 index 00000000..76bd8823 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryServiceTest.java @@ -0,0 +1,87 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatWalletLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLog; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLogReason; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLogStatus; +import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletLogRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminAskChatWalletLogQueryServiceTest { + + @Mock + private AskChatWalletLogRepository askChatWalletLogRepository; + + @Test + void maps_ask_chat_wallet_logs_to_admin_page_response() { + AdminAskChatWalletLogQueryService service = + new AdminAskChatWalletLogQueryService(askChatWalletLogRepository); + User user = User.createUser("alice@example.com", "hashed_password"); + user.updateNickname("alice"); + AskChatWalletLog log = AskChatWalletLog.createPending( + user, + null, + null, + 0, + -1, + 2, + 9, + AskChatWalletLogReason.ANSWER_SUCCESS_CONSUME, + "ASK_CHAT_MESSAGE", + 501L, + "ask-chat-message-501" + ); + AdminLogSearchCondition condition = AdminLogSearchCondition.of(2, 10, "alice", null); + when(askChatWalletLogRepository.findAllForAdmin(any(), any(), any())) + .thenReturn(new PageImpl<>(List.of(log), condition.toPageable(), 11)); + + AdminLogPageResponse response = service.getLogs(condition); + + assertThat(response.currentPage()).isEqualTo(2); + assertThat(response.pageSize()).isEqualTo(10); + assertThat(response.totalCount()).isEqualTo(11); + assertThat(response.items()).singleElement().satisfies(item -> { + assertThat(item.user().nickname()).isEqualTo("alice"); + assertThat(item.user().email()).isEqualTo("alice@example.com"); + assertThat(item.sessionId()).isNull(); + assertThat(item.messageId()).isNull(); + assertThat(item.freeTurnDelta()).isZero(); + assertThat(item.paidTurnDelta()).isEqualTo(-1); + assertThat(item.freeTurnBalanceAfter()).isEqualTo(2); + assertThat(item.paidTurnBalanceAfter()).isEqualTo(9); + assertThat(item.reason()).isEqualTo(AskChatWalletLogReason.ANSWER_SUCCESS_CONSUME); + assertThat(item.status()).isEqualTo(AskChatWalletLogStatus.PENDING); + assertThat(item.refType()).isEqualTo("ASK_CHAT_MESSAGE"); + assertThat(item.refId()).isEqualTo(501L); + assertThat(item.idempotencyKey()).isEqualTo("ask-chat-message-501"); + }); + + ArgumentCaptor nicknameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor emailCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(askChatWalletLogRepository).findAllForAdmin( + nicknameCaptor.capture(), + emailCaptor.capture(), + pageableCaptor.capture() + ); + assertThat(nicknameCaptor.getValue()).isEqualTo("alice"); + assertThat(emailCaptor.getValue()).isNull(); + assertThat(pageableCaptor.getValue()).isEqualTo(condition.toPageable()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepositoryTest.java new file mode 100644 index 00000000..9ed2cdd3 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepositoryTest.java @@ -0,0 +1,139 @@ +package com.devkor.ifive.nadab.domain.askchat.core.repository; + +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLog; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLogReason; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Comparator; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class AskChatWalletLogRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + AskChatWalletLogRepository askChatWalletLogRepository; + + @Autowired + TestEntityManager em; + + @Test + void find_all_for_admin_filters_by_nickname_and_email() { + // given + User matchingUser = user("alice@example.com", "alice"); + User sameNicknameUser = user("bob@example.com", "alice-other"); + User otherUser = user("carol@example.com", "carol"); + + AskChatWalletLog matchingLog = askChatWalletLogRepository.save( + confirmedLog(matchingUser, -1, 0, 2, 9, 301L, "ask-filter-301") + ); + askChatWalletLogRepository.save( + confirmedLog(sameNicknameUser, 0, 10, 3, 10, 302L, "ask-filter-302") + ); + askChatWalletLogRepository.save( + confirmedLog(otherUser, 3, 0, 3, 0, 303L, "ask-filter-303") + ); + + em.flush(); + em.clear(); + + // when + Page logs = askChatWalletLogRepository.findAllForAdmin( + "ali", + "alice@example.com", + latestFirstPage() + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(1); + assertThat(logs.getContent()).extracting(AskChatWalletLog::getRefId) + .containsExactly(301L); + assertThat(logs.getContent().get(0).getUser().getEmail()) + .isEqualTo("alice@example.com"); + assertThat(logs.getContent().get(0).getFreeTurnDelta()).isEqualTo(-1); + assertThat(logs.getContent().get(0).getPaidTurnDelta()).isZero(); + assertThat(matchingLog.getId()).isNotNull(); + } + + @Test + void find_all_for_admin_orders_latest_first() { + // given + User user = user("latest@example.com", "latest"); + AskChatWalletLog first = askChatWalletLogRepository.save( + confirmedLog(user, 3, 0, 3, 0, 401L, "ask-order-401") + ); + AskChatWalletLog second = askChatWalletLogRepository.save( + confirmedLog(user, 0, -1, 3, 9, 402L, "ask-order-402") + ); + + em.flush(); + em.clear(); + + // when + Page logs = askChatWalletLogRepository.findAllForAdmin( + null, + null, + latestFirstPage() + ); + + // then + assertThat(logs.getTotalElements()).isEqualTo(2); + assertThat(logs.getContent()) + .isSortedAccordingTo( + Comparator.comparing(AskChatWalletLog::getCreatedAt) + .thenComparing(AskChatWalletLog::getId) + .reversed() + ); + assertThat(logs.getContent()).extracting(AskChatWalletLog::getId) + .containsExactly(second.getId(), first.getId()); + } + + private AskChatWalletLog confirmedLog( + User user, + int freeTurnDelta, + int paidTurnDelta, + int freeTurnBalanceAfter, + int paidTurnBalanceAfter, + Long refId, + String idempotencyKey + ) { + return AskChatWalletLog.createConfirmed( + user, + null, + null, + freeTurnDelta, + paidTurnDelta, + freeTurnBalanceAfter, + paidTurnBalanceAfter, + AskChatWalletLogReason.ANSWER_SUCCESS_CONSUME, + "ASK_CHAT_MESSAGE", + refId, + idempotencyKey + ); + } + + private User user(String email, String nickname) { + User user = User.createUser(email, "hashed_password"); + user.updateNickname(nickname); + em.persist(user); + return user; + } + + private PageRequest latestFirstPage() { + return PageRequest.of( + 0, + 20, + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")) + ); + } +} From f95bbb9b926090ecc1077c3bfe87f470caf1ddd8 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Thu, 20 Aug 2026 19:34:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(admin):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=20=ED=83=AD=EA=B3=BC=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /admin/tabs/logs에 주요 로그 하위 탭과 닉네임·이메일 필터, 페이지네이션을 연결하고 Ask Chat Wallet 표는 sessionId만 표시하도록 단순화함 --- .../domain/admin/api/AdminPageController.java | 5 + .../templates/admin/ask-chat-rag.html | 1 + src/main/resources/templates/admin/logs.html | 662 ++++++++++++++++++ .../templates/admin/version-history.html | 1 + .../resources/templates/admin/version.html | 1 + .../admin/api/AdminPageControllerTest.java | 15 + 6 files changed, 685 insertions(+) create mode 100644 src/main/resources/templates/admin/logs.html create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageControllerTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java index 3a7b596d..7d59e51b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java @@ -30,4 +30,9 @@ public String adminVersionHistoryPage() { public String adminAskChatRagPage() { return "admin/ask-chat-rag"; } + + @GetMapping("/admin/tabs/logs") + public String adminLogsPage() { + return "admin/logs"; + } } diff --git a/src/main/resources/templates/admin/ask-chat-rag.html b/src/main/resources/templates/admin/ask-chat-rag.html index 3482db00..bc5d436a 100644 --- a/src/main/resources/templates/admin/ask-chat-rag.html +++ b/src/main/resources/templates/admin/ask-chat-rag.html @@ -224,6 +224,7 @@

Admin Console

diff --git a/src/main/resources/templates/admin/logs.html b/src/main/resources/templates/admin/logs.html new file mode 100644 index 00000000..b64ce3b6 --- /dev/null +++ b/src/main/resources/templates/admin/logs.html @@ -0,0 +1,662 @@ + + + + + + NADAB Admin Logs + + + +
+
+

Admin Console

+ +
+ + + +
+
+
+

Main Logs

+

Browse the latest records and filter them by nickname or email.

+
+
+ +
+ + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Loading...

+
+ + +
+
+ + + + diff --git a/src/main/resources/templates/admin/version-history.html b/src/main/resources/templates/admin/version-history.html index 39bbe632..93b845e7 100644 --- a/src/main/resources/templates/admin/version-history.html +++ b/src/main/resources/templates/admin/version-history.html @@ -279,6 +279,7 @@

Admin Console

diff --git a/src/main/resources/templates/admin/version.html b/src/main/resources/templates/admin/version.html index c534a66a..3733398a 100644 --- a/src/main/resources/templates/admin/version.html +++ b/src/main/resources/templates/admin/version.html @@ -305,6 +305,7 @@

Admin Console

diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageControllerTest.java new file mode 100644 index 00000000..f4b3c6e8 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageControllerTest.java @@ -0,0 +1,15 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdminPageControllerTest { + + @Test + void returns_admin_logs_template_for_logs_tab() { + AdminPageController controller = new AdminPageController(); + + assertThat(controller.adminLogsPage()).isEqualTo("admin/logs"); + } +}