From 68e029c02c09dca532f8c43aae68fb8c6c16728a Mon Sep 17 00:00:00 2001 From: SD-gif Date: Tue, 25 Aug 2026 16:48:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=84=A4=20=EA=B0=80=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신고 처리, 사용자 관리, 장소 데이터 관리, 통계다. 모두 /api/v1/admin 아래에 있고 SecurityConfig 가 hasRole('ADMIN') 으로 막는다. 사용자 API 인가는 아직 켜지 않았다. 정지가 아무것도 막지 않고 있었다 V9 에서 suspend() 와 isWriteBlockedAt() 을 만들어 두었지만 확인하는 곳이 없었다. 관리자가 정지시켜도 그 사용자는 계속 글을 썼다. SuspendedUserWriteFilter 로 막는다. 쓰기 요청에서만 사용자를 조회한다. 액세스 토큰에 정지 여부가 없어 매 요청 확인하면 모든 조회에 질의가 하나씩 늘어난다. 읽기를 막지 않는 것은 의도이기도 하다. 정지된 사용자가 자기 상태를 확인할 수는 있어야 한다. /api/v1/auth 도 열어 둔다. 토큰 폐기 실패는 정지를 되돌리지 않는다. Redis 가 죽었다고 악성 사용자를 정지시키지 못하면 곤란하다. 게다가 Redis 가 없으면 갱신 자체가 실패하므로 폐기하지 못한 토큰으로 세션을 이어갈 수도 없다. 신고 처리 상세에 대상 원본을 함께 준다. 신고 사유만 보고는 조치할 수 없다. 이미 지워진 대상도 삭제 표시와 함께 보여준다. 감추면 관리자가 사라진 대상을 계속 찾는다. 오래된 신고부터 반환한다. 최신순이면 방치된 신고가 계속 뒤로 밀린다. 종결한 신고를 되돌릴 수 있다. 못 되돌리면 잘못 종결한 건을 다루려고 같은 내용의 새 신고를 기다려야 한다. ReportStatus 에 REVIEWING 과 REJECTED 를 더한다. 확인만 하고 조치하지 않은 것과 아직 아무도 보지 않은 것을 구분하지 못하면 대기 목록이 같은 항목으로 계속 채워진다. 관리자 삭제도 소프트 삭제다. 작성자 확인만 건너뛰고 해시태그 정리와 댓글 수 감소는 본인 삭제와 같다. 다르게 처리하면 관리자가 지운 것만 다른 상태로 남아 복구·정리 경로가 갈라진다. 사용자 관리 관리자는 정지할 수 없다. 서로 정지시키면 아무도 풀 수 없는 상태가 되고, 그때 남는 수단은 DB 를 직접 고치는 것뿐이다. 받은 신고 수는 사용자 신고뿐 아니라 그가 쓴 게시물·댓글 신고도 함께 센다. 사용자 신고만 세면 문제 글을 반복해 올리는 계정이 깨끗해 보인다. 장소 데이터 관리 지우지 않고 가린다. 행을 지우면 TourAPI 증분 동기화가 다음 실행에서 같은 장소를 다시 만든다. 검색과 상세 조회 양쪽에서 뺀다. 검색에서만 빼고 상세를 열어 두면 예전 링크로 그대로 들어온다. 예산이 없으면 수동 적재를 시작하지 않는다. 시작해 봐야 예약 단계에서 막혀 아무것도 하지 못하고, 관리자는 왜 안 되는지 알 수 없다. 적재 트리거에는 트랜잭션을 걸지 않는다. TourAPI 호출이 길게 이어진다. 통계 실시간 집계다. 집계 테이블을 두면 그것을 채우는 배치와 어긋남을 관리해야 한다. 지표 이름을 SQL 에 그대로 넣지 않는다. StatsMetric 이 허용된 테이블만 담는다. 값이 0 인 날도 채운다. DB 는 행이 없는 날을 돌려주지 않아, 그대로 두면 화면이 빈 날을 건너뛰어 그래프가 실제보다 완만해 보인다. 인기 장소는 게시물 태그 횟수로 센다. 일정에 담긴 횟수를 쓰면 Planner 가 고른 것이 섞여 사용자가 고른 것과 구분되지 않는다. 인기 해시태그는 hashtags.post_count 대신 실제 연결을 센다. 집계 컬럼은 어긋날 수 있고 삭제된 게시물의 몫이 남아 있을 수 있다. 정리 스케줄러를 배포에서 켠다 COMMUNITY_POST_PURGE_ENABLED 가 기본 false 인데 배포 워크플로에 없어, 지운 게시물이 영원히 쌓이고 본문과 사진 URL 이 DB 에 남는 상태였다. 스키마 V12 는 reports 에 handled_by 와 handled_at 을 더한다. V13 은 places 에 hidden_at 과 hidden_reason 을 더한다. 일정 후보에서 빼는 것은 FastAPI 가 places 를 직접 읽으므로 data 저장소에서 함께 처리한다(SMBDHTeam/data#38). 전체 267건 통과. Testcontainers 로 실제 PostgreSQL 에 V12·V13 적용을 확인했다. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy-dev.yml | 5 + docs/ERD.md | 9 + .../controller/AdminPlaceController.java | 71 +++++++ .../controller/AdminReportController.java | 115 +++++++++++ .../controller/AdminStatsController.java | 71 +++++++ .../admin/controller/AdminUserController.java | 75 ++++++++ .../dto/AdminIngestionStatusResponse.java | 29 +++ .../server/admin/dto/AdminPlaceResponse.java | 28 +++ .../admin/dto/AdminReportDetailResponse.java | 28 +++ .../admin/dto/AdminReportListResponse.java | 12 ++ .../server/admin/dto/AdminReportResponse.java | 43 +++++ .../admin/dto/AdminStatsPopularResponse.java | 19 ++ .../admin/dto/AdminStatsSummaryResponse.java | 22 +++ .../admin/dto/AdminStatsTrendResponse.java | 25 +++ .../admin/dto/AdminUserDetailResponse.java | 13 ++ .../admin/dto/AdminUserListResponse.java | 11 ++ .../server/admin/dto/AdminUserResponse.java | 37 ++++ .../admin/dto/PlaceHiddenUpdateRequest.java | 19 ++ .../admin/dto/ReportStatusUpdateRequest.java | 12 ++ .../com/server/admin/dto/StatsMetric.java | 18 ++ .../admin/dto/UserStatusUpdateRequest.java | 27 +++ .../admin/service/AdminPlaceService.java | 126 ++++++++++++ .../admin/service/AdminReportService.java | 142 ++++++++++++++ .../admin/service/AdminStatsService.java | 160 +++++++++++++++ .../admin/service/AdminUserService.java | 132 +++++++++++++ .../auth/web/SuspendedUserWriteFilter.java | 79 ++++++++ .../server/common/config/SecurityConfig.java | 10 +- .../com/server/common/error/ErrorCode.java | 4 + .../java/com/server/place/domain/Place.java | 32 +++ .../place/repository/PlaceRepository.java | 5 + .../server/place/service/PlaceService.java | 8 + .../com/server/post/domain/ReportStatus.java | 20 +- .../server/post/service/CommentService.java | 14 ++ .../com/server/post/service/PostService.java | 15 ++ .../java/com/server/report/domain/Report.java | 26 +++ .../report/repository/ReportRepository.java | 53 +++++ .../user/repository/UserRepository.java | 29 +++ .../db/migration/V12__add_report_handling.sql | 9 + .../db/migration/V13__add_place_hidden.sql | 9 + .../AdminReportAuthorizationTest.java | 121 ++++++++++++ .../AdminPlaceQuotaIntegrationTest.java | 75 ++++++++ .../admin/service/AdminPlaceServiceTest.java | 116 +++++++++++ .../admin/service/AdminReportServiceTest.java | 182 ++++++++++++++++++ .../admin/service/AdminStatsServiceTest.java | 157 +++++++++++++++ .../admin/service/AdminUserServiceTest.java | 145 ++++++++++++++ .../service/AdminUserSuspensionTokenTest.java | 92 +++++++++ .../service/InMemoryStringRedisTemplate.java | 2 +- .../web/SuspendedUserWriteFilterTest.java | 118 ++++++++++++ .../PostgresMigrationIntegrationTest.java | 7 + .../controller/QuestionControllerTest.java | 12 +- 50 files changed, 2579 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/server/admin/controller/AdminPlaceController.java create mode 100644 src/main/java/com/server/admin/controller/AdminReportController.java create mode 100644 src/main/java/com/server/admin/controller/AdminStatsController.java create mode 100644 src/main/java/com/server/admin/controller/AdminUserController.java create mode 100644 src/main/java/com/server/admin/dto/AdminIngestionStatusResponse.java create mode 100644 src/main/java/com/server/admin/dto/AdminPlaceResponse.java create mode 100644 src/main/java/com/server/admin/dto/AdminReportDetailResponse.java create mode 100644 src/main/java/com/server/admin/dto/AdminReportListResponse.java create mode 100644 src/main/java/com/server/admin/dto/AdminReportResponse.java create mode 100644 src/main/java/com/server/admin/dto/AdminStatsPopularResponse.java create mode 100644 src/main/java/com/server/admin/dto/AdminStatsSummaryResponse.java create mode 100644 src/main/java/com/server/admin/dto/AdminStatsTrendResponse.java create mode 100644 src/main/java/com/server/admin/dto/AdminUserDetailResponse.java create mode 100644 src/main/java/com/server/admin/dto/AdminUserListResponse.java create mode 100644 src/main/java/com/server/admin/dto/AdminUserResponse.java create mode 100644 src/main/java/com/server/admin/dto/PlaceHiddenUpdateRequest.java create mode 100644 src/main/java/com/server/admin/dto/ReportStatusUpdateRequest.java create mode 100644 src/main/java/com/server/admin/dto/StatsMetric.java create mode 100644 src/main/java/com/server/admin/dto/UserStatusUpdateRequest.java create mode 100644 src/main/java/com/server/admin/service/AdminPlaceService.java create mode 100644 src/main/java/com/server/admin/service/AdminReportService.java create mode 100644 src/main/java/com/server/admin/service/AdminStatsService.java create mode 100644 src/main/java/com/server/admin/service/AdminUserService.java create mode 100644 src/main/java/com/server/auth/web/SuspendedUserWriteFilter.java create mode 100644 src/main/resources/db/migration/V12__add_report_handling.sql create mode 100644 src/main/resources/db/migration/V13__add_place_hidden.sql create mode 100644 src/test/java/com/server/admin/controller/AdminReportAuthorizationTest.java create mode 100644 src/test/java/com/server/admin/service/AdminPlaceQuotaIntegrationTest.java create mode 100644 src/test/java/com/server/admin/service/AdminPlaceServiceTest.java create mode 100644 src/test/java/com/server/admin/service/AdminReportServiceTest.java create mode 100644 src/test/java/com/server/admin/service/AdminStatsServiceTest.java create mode 100644 src/test/java/com/server/admin/service/AdminUserServiceTest.java create mode 100644 src/test/java/com/server/admin/service/AdminUserSuspensionTokenTest.java create mode 100644 src/test/java/com/server/auth/web/SuspendedUserWriteFilterTest.java diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index eb84e98..48bf006 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -205,6 +205,11 @@ jobs: printf 'JWT_SECRET=%s\n' "$JWT_SECRET" printf 'GOOGLE_CLIENT_ID=%s\n' "$GOOGLE_CLIENT_ID" + # 복구 기한이 지난 게시물을 실제로 지우는 스케줄러. 기본값이 false 라 + # 여기서 켜지 않으면 지운 게시물이 영원히 쌓이고, 사용자가 지웠다고 믿는 + # 본문과 사진 URL 이 DB 에 그대로 남는다. + printf 'COMMUNITY_POST_PURGE_ENABLED=true\n' + printf 'REDIS_HOST=redis\n' printf 'REDIS_PORT=6379\n' printf 'SCHEDULE_FASTAPI_ENABLED=true\n' diff --git a/docs/ERD.md b/docs/ERD.md index 8b0da8c..8c7d5af 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -539,6 +539,8 @@ DB에서 직접 증감시킨다. 동시에 들어온 요청이 같은 값을 읽 | `target_id` | bigint | O | 신고 대상 ID | | `reason` | text | O | 신고 사유 | | `status` | varchar | O | `PENDING`, `RESOLVED` | +| `handled_by` | bigint | FK, X | 처리한 관리자 `users.id`. 처리 전이면 NULL | +| `handled_at` | datetime | X | 처리 시각 | | `created_at` | datetime | O | 접수시각 | **`target_id`에는 외래키가 없다.** 대상이 게시물·댓글·사용자로 달라져 한 테이블을 가리킬 수 @@ -548,6 +550,13 @@ DB에서 직접 증감시킨다. 동시에 들어온 요청이 같은 값을 읽 `uk_reports_reporter_target(reporter_id, target_type, target_id)` 고유 제약을 추가했다. 코드로만 막으면 같은 요청이 동시에 들어올 때 중복 행이 남는다. +`status` 는 `PENDING`·`REVIEWING`·`RESOLVED`·`REJECTED` 다. `REVIEWING` 은 관리자가 여럿일 때 +같은 신고를 두 사람이 동시에 들여다보는 것을 줄이기 위한 값이다. 확인만 하고 조치하지 않은 것과 +아직 아무도 보지 않은 것을 구분하지 못하면 대기 목록이 같은 항목으로 계속 채워진다. + +`uk_reports_reporter_target(reporter_id, target_type, target_id)` 로 같은 대상을 여러 번 신고하지 +못하게 한다. `idx_reports_status_created_at` 은 관리자 화면이 대기 중인 신고부터 보기 위한 것이다. + ## 일정 생성 V2 변경 ### V2-1. 기존 테이블 변경 diff --git a/src/main/java/com/server/admin/controller/AdminPlaceController.java b/src/main/java/com/server/admin/controller/AdminPlaceController.java new file mode 100644 index 0000000..c96c07a --- /dev/null +++ b/src/main/java/com/server/admin/controller/AdminPlaceController.java @@ -0,0 +1,71 @@ +package com.server.admin.controller; + +import com.server.admin.dto.AdminIngestionStatusResponse; +import com.server.admin.dto.AdminPlaceResponse; +import com.server.admin.dto.PlaceHiddenUpdateRequest; +import com.server.admin.service.AdminPlaceService; +import com.server.place.ingestion.TourApiPlaceIngestionResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/places") +@Tag(name = "관리자 - 장소", description = "적재 상태 조회, 수동 적재, 장소 숨김") +public class AdminPlaceController { + + private final AdminPlaceService adminPlaceService; + + public AdminPlaceController(AdminPlaceService adminPlaceService) { + this.adminPlaceService = adminPlaceService; + } + + @GetMapping("/ingestion") + @Operation( + summary = "적재 상태와 남은 예산", + description = "ingestion_status 별 장소 수와 오늘(KST) 남은 TourAPI 호출 수를 준다. " + + "수동 적재 전에 여유가 있는지 확인한다." + ) + public AdminIngestionStatusResponse getIngestionStatus() { + return adminPlaceService.getIngestionStatus(); + } + + @PostMapping("/ingestion") + @Operation( + summary = "수동 적재 실행", + description = "스케줄러와 같은 하루 예산을 쓴다. 남은 양이 없으면 429 로 거절한다. " + + "시작해 봐야 예약 단계에서 막혀 아무것도 하지 못하기 때문이다. " + + "다른 적재가 진행 중이면 lockSkipped 가 true 로 돌아온다." + ) + public TourApiPlaceIngestionResult runIngestion() { + return adminPlaceService.runIngestion(); + } + + @GetMapping("/hidden") + @Operation(summary = "가려 둔 장소 목록", description = "최근에 가린 순이다.") + public List getHiddenPlaces() { + return adminPlaceService.getHiddenPlaces(); + } + + @PatchMapping("/{placeId}/hidden") + @Operation( + summary = "장소 숨김·해제", + description = "지우지 않고 가린다. 행을 지우면 TourAPI 증분 동기화가 다음 실행에서 " + + "같은 장소를 다시 만든다. 가린 장소는 검색과 상세 조회에서 빠진다." + ) + public AdminPlaceResponse updateHidden( + @Parameter(example = "42") @PathVariable Long placeId, + @Valid @RequestBody PlaceHiddenUpdateRequest request + ) { + return adminPlaceService.updateHidden(placeId, request.hidden(), request.reason()); + } +} diff --git a/src/main/java/com/server/admin/controller/AdminReportController.java b/src/main/java/com/server/admin/controller/AdminReportController.java new file mode 100644 index 0000000..8145cbc --- /dev/null +++ b/src/main/java/com/server/admin/controller/AdminReportController.java @@ -0,0 +1,115 @@ +package com.server.admin.controller; + +import com.server.admin.dto.AdminReportDetailResponse; +import com.server.admin.dto.AdminReportListResponse; +import com.server.admin.dto.AdminReportResponse; +import com.server.admin.dto.ReportStatusUpdateRequest; +import com.server.admin.service.AdminReportService; +import com.server.auth.web.CurrentUser; +import com.server.post.domain.ReportStatus; +import com.server.post.service.CommentService; +import com.server.post.service.PostService; +import com.server.report.domain.ReportTargetType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * 관리자 신고 처리. + * + *

{@code /api/v1/admin/**} 는 {@code SecurityConfig} 가 {@code hasRole('ADMIN')} 으로 + * 막는다. 사용자 API 인가를 아직 켜지 않은 단계에서도 이 경로만은 처음부터 닫혀 있다. + */ +@RestController +@RequestMapping("/api/v1/admin") +@Tag(name = "관리자 - 신고", description = "신고 조회와 처리, 신고 대상 삭제") +public class AdminReportController { + + private final AdminReportService adminReportService; + private final PostService postService; + private final CommentService commentService; + + public AdminReportController( + AdminReportService adminReportService, + PostService postService, + CommentService commentService + ) { + this.adminReportService = adminReportService; + this.postService = postService; + this.commentService = commentService; + } + + @GetMapping("/reports") + @Operation( + summary = "신고 목록", + description = "오래된 것부터 반환한다. 최신순이면 방치된 신고가 계속 뒤로 밀린다. " + + "status 와 targetType 은 생략하면 거르지 않는다." + ) + public AdminReportListResponse getReports( + @Parameter(description = "PENDING, REVIEWING, RESOLVED, REJECTED", example = "PENDING") + @RequestParam(required = false) ReportStatus status, + @Parameter(description = "POST, COMMENT, USER", example = "POST") + @RequestParam(required = false) ReportTargetType targetType, + @Parameter(example = "0") @RequestParam(required = false) Integer page, + @Parameter(example = "20") @RequestParam(required = false) Integer size + ) { + return adminReportService.getReports(status, targetType, page, size); + } + + @GetMapping("/reports/{reportId}") + @Operation( + summary = "신고 상세", + description = "신고 내용만으로는 판단할 수 없어 대상 원본을 함께 준다. " + + "대상이 이미 지워졌으면 target 이 null 이다." + ) + public AdminReportDetailResponse getReport( + @Parameter(example = "12") @PathVariable Long reportId) { + return adminReportService.getReport(reportId); + } + + @PatchMapping("/reports/{reportId}") + @Operation( + summary = "신고 처리 상태 변경", + description = "되돌리는 것도 허용한다. 잘못 종결한 신고를 다시 대기로 놓을 수 없으면 " + + "같은 내용의 새 신고를 기다리는 수밖에 없다." + ) + public AdminReportResponse updateStatus( + @Parameter(example = "12") @PathVariable Long reportId, + @Valid @RequestBody ReportStatusUpdateRequest request + ) { + return adminReportService.updateStatus(reportId, request.status(), CurrentUser.idOrNull()); + } + + @DeleteMapping("/posts/{postId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "게시물 삭제 (관리자)", + description = "작성자 확인만 건너뛰고 본인 삭제와 같게 처리한다. 소프트 삭제이므로 " + + "복구 기한 안에는 작성자가 되살릴 수 있다." + ) + public void deletePost(@Parameter(example = "7") @PathVariable Long postId) { + postService.deleteByAdmin(postId); + } + + @DeleteMapping("/comments/{commentId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "댓글 삭제 (관리자)", + description = "댓글 ID 만으로 지운다. 답글이 남아 있으면 자리를 유지하고 " + + "작성자와 내용을 감추는 것은 본인 삭제와 같다." + ) + public void deleteComment(@Parameter(example = "3") @PathVariable Long commentId) { + commentService.deleteByAdmin(commentId); + } +} diff --git a/src/main/java/com/server/admin/controller/AdminStatsController.java b/src/main/java/com/server/admin/controller/AdminStatsController.java new file mode 100644 index 0000000..b352ef5 --- /dev/null +++ b/src/main/java/com/server/admin/controller/AdminStatsController.java @@ -0,0 +1,71 @@ +package com.server.admin.controller; + +import com.server.admin.dto.AdminStatsPopularResponse; +import com.server.admin.dto.AdminStatsSummaryResponse; +import com.server.admin.dto.AdminStatsTrendResponse; +import com.server.admin.dto.StatsMetric; +import com.server.admin.service.AdminStatsService; +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +@RestController +@RequestMapping("/api/v1/admin/stats") +@Tag(name = "관리자 - 통계", description = "총계, 일자별 추이, 인기 장소·해시태그") +public class AdminStatsController { + + private final AdminStatsService adminStatsService; + + public AdminStatsController(AdminStatsService adminStatsService) { + this.adminStatsService = adminStatsService; + } + + @GetMapping("/summary") + @Operation( + summary = "총계와 기간 증감", + description = "가입자·게시물·일정의 누적과 최근 기간 증가분을 준다. " + + "대기 중인 신고 수와 정지 사용자 수도 함께 준다. days 기본값은 7이다." + ) + public AdminStatsSummaryResponse getSummary( + @Parameter(description = "집계 기간(일). 최대 365", example = "7") + @RequestParam(required = false) Integer days) { + return adminStatsService.getSummary(days); + } + + @GetMapping("/trend") + @Operation( + summary = "일자별 추이", + description = "값이 0인 날도 포함한다. 빈 날을 건너뛰면 그래프가 실제보다 완만해 보인다." + ) + public AdminStatsTrendResponse getTrend( + @Parameter(description = "USERS, POSTS, SCHEDULES", example = "POSTS") + @RequestParam StatsMetric metric, + @Parameter(description = "집계 기간(일). 최대 365", example = "30") + @RequestParam(required = false) Integer days) { + return adminStatsService.getTrend(metric, days); + } + + @GetMapping("/popular") + @Operation( + summary = "인기 장소·해시태그", + description = "장소는 게시물에 태그된 횟수로 센다. 일정에 담긴 횟수를 쓰면 " + + "Planner 가 고른 것이 섞여 사용자가 고른 것과 구분되지 않는다. " + + "해시태그는 집계 컬럼 대신 실제 연결을 센다." + ) + public AdminStatsPopularResponse getPopular( + @Parameter(description = "PLACE 또는 HASHTAG", example = "PLACE") + @RequestParam String type, + @Parameter(example = "10") @RequestParam(required = false) Integer size) { + return switch (type == null ? "" : type.toUpperCase()) { + case "PLACE" -> adminStatsService.getPopularPlaces(size); + case "HASHTAG" -> adminStatsService.getPopularHashtags(size); + default -> throw new BusinessException(ErrorCode.INVALID_STATS_TYPE); + }; + } +} diff --git a/src/main/java/com/server/admin/controller/AdminUserController.java b/src/main/java/com/server/admin/controller/AdminUserController.java new file mode 100644 index 0000000..b9b7c69 --- /dev/null +++ b/src/main/java/com/server/admin/controller/AdminUserController.java @@ -0,0 +1,75 @@ +package com.server.admin.controller; + +import com.server.admin.dto.AdminUserDetailResponse; +import com.server.admin.dto.AdminUserListResponse; +import com.server.admin.dto.AdminUserResponse; +import com.server.admin.dto.UserStatusUpdateRequest; +import com.server.admin.service.AdminUserService; +import com.server.user.domain.UserStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/users") +@Tag(name = "관리자 - 사용자", description = "사용자 조회와 정지·해제") +public class AdminUserController { + + private final AdminUserService adminUserService; + + public AdminUserController(AdminUserService adminUserService) { + this.adminUserService = adminUserService; + } + + @GetMapping + @Operation( + summary = "사용자 목록", + description = "닉네임과 이메일을 함께 검색한다. 탈퇴한 사용자도 포함하며 " + + "status 로 거를 수 있다. 최근 가입한 순이다." + ) + public AdminUserListResponse getUsers( + @Parameter(description = "닉네임 또는 이메일 일부", example = "여행") + @RequestParam(required = false) String keyword, + @Parameter(description = "ACTIVE, SUSPENDED, WITHDRAWN", example = "SUSPENDED") + @RequestParam(required = false) UserStatus status, + @Parameter(example = "0") @RequestParam(required = false) Integer page, + @Parameter(example = "20") @RequestParam(required = false) Integer size + ) { + return adminUserService.getUsers(keyword, status, page, size); + } + + @GetMapping("/{userId}") + @Operation( + summary = "사용자 상세", + description = "조치를 판단할 수 있도록 게시물 수와 신고 이력 요약을 함께 준다. " + + "받은 신고 수는 사용자 직접 신고뿐 아니라 그가 쓴 게시물·댓글 신고도 센다." + ) + public AdminUserDetailResponse getUser( + @Parameter(example = "3") @PathVariable Long userId) { + return adminUserService.getUser(userId); + } + + @PatchMapping("/{userId}/status") + @Operation( + summary = "사용자 정지·해제", + description = "정지하면 쓰기만 막고 읽기는 허용한다. 정지된 사용자가 자기 상태를 " + + "확인할 수는 있어야 한다. 정지 시 그 사용자의 리프레시 토큰을 모두 폐기하므로 " + + "액세스 토큰 수명이 끝나면 더 이상 이어갈 수 없다. days 를 생략하면 기한 없는 " + + "정지이며, 기한이 지난 정지는 스스로 풀린다. 관리자는 정지할 수 없다." + ) + public AdminUserResponse updateStatus( + @Parameter(example = "3") @PathVariable Long userId, + @Valid @RequestBody UserStatusUpdateRequest request + ) { + return adminUserService.updateStatus( + userId, request.suspended(), request.days(), request.reason()); + } +} diff --git a/src/main/java/com/server/admin/dto/AdminIngestionStatusResponse.java b/src/main/java/com/server/admin/dto/AdminIngestionStatusResponse.java new file mode 100644 index 0000000..81d2e5d --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminIngestionStatusResponse.java @@ -0,0 +1,29 @@ +package com.server.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.Map; + +@Schema(description = "TourAPI 적재 상태와 오늘 남은 호출 예산") +public record AdminIngestionStatusResponse( + @Schema(description = "ingestion_status 별 장소 수", example = "{\"SYNCED\":475,\"PENDING\":12}") + Map statusCounts, + + @Schema(description = "가려 둔 장소 수", example = "3") long hiddenCount, + + @Schema(description = "적재 스케줄러가 켜져 있는지", example = "true") boolean ingestionEnabled, + + @Schema(description = "상세 보강이 켜져 있는지. 끄면 같은 예산으로 약 3배 많은 장소를 발견한다", + example = "true") + boolean enrichmentEnabled, + + @Schema(description = "예산 기준일(KST)", example = "2026-08-25") LocalDate quotaDate, + + @Schema(description = "오늘 쓴 호출 수", example = "412") int requestsUsed, + + @Schema(description = "하루 한도", example = "900") int dailyLimit, + + @Schema(description = "오늘 남은 호출 수. 수동 적재 전에 확인한다", example = "488") + int requestsRemaining +) { +} diff --git a/src/main/java/com/server/admin/dto/AdminPlaceResponse.java b/src/main/java/com/server/admin/dto/AdminPlaceResponse.java new file mode 100644 index 0000000..0f9c26f --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminPlaceResponse.java @@ -0,0 +1,28 @@ +package com.server.admin.dto; + +import com.server.place.domain.Place; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "관리자 화면의 장소 한 건") +public record AdminPlaceResponse( + @Schema(example = "42") Long id, + @Schema(example = "해운대해수욕장") String name, + @Schema(example = "부산광역시 해운대구 우동") String address, + @Schema(example = "TOUR_API") String source, + @Schema(description = "가려져 있는지", example = "true") boolean hidden, + @Schema(description = "가린 시각") LocalDateTime hiddenAt, + @Schema(example = "좌표가 실제 위치와 다름") String hiddenReason +) { + + public static AdminPlaceResponse from(Place place) { + return new AdminPlaceResponse( + place.getId(), + place.getName(), + place.getAddress(), + place.getSource(), + place.isHidden(), + place.getHiddenAt(), + place.getHiddenReason()); + } +} diff --git a/src/main/java/com/server/admin/dto/AdminReportDetailResponse.java b/src/main/java/com/server/admin/dto/AdminReportDetailResponse.java new file mode 100644 index 0000000..572073b --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminReportDetailResponse.java @@ -0,0 +1,28 @@ +package com.server.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 신고 상세. 신고 내용만으로는 판단할 수 없어 대상 원본을 함께 준다. + * + *

대상이 이미 지워졌으면 {@code target} 이 {@code null} 이다. 신고가 접수된 뒤 작성자가 + * 스스로 지웠거나 다른 관리자가 먼저 조치한 경우다. 이때는 더 볼 것이 없으므로 신고를 + * 종결하면 된다. + */ +@Schema(description = "신고 상세와 대상 원본") +public record AdminReportDetailResponse( + AdminReportResponse report, + @Schema(description = "신고 대상 원본. 이미 삭제됐으면 null") + Target target +) { + + @Schema(description = "신고 대상 원본") + public record Target( + @Schema(example = "7") Long id, + @Schema(description = "작성자 또는 대상 사용자") AdminReportResponse.Reporter author, + @Schema(description = "게시물·댓글이면 본문, 사용자면 닉네임", example = "광안리 야경 보러 갔어요") + String content, + @Schema(description = "이미 삭제된 대상인지", example = "false") boolean deleted + ) { + } +} diff --git a/src/main/java/com/server/admin/dto/AdminReportListResponse.java b/src/main/java/com/server/admin/dto/AdminReportListResponse.java new file mode 100644 index 0000000..ef4e6c2 --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminReportListResponse.java @@ -0,0 +1,12 @@ +package com.server.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "신고 목록. 상태·대상 유형으로 거를 수 있다.") +public record AdminReportListResponse( + List items, + @Schema(description = "조건에 맞는 전체 건수. 화면이 페이지 수를 계산한다", example = "37") + long totalCount +) { +} diff --git a/src/main/java/com/server/admin/dto/AdminReportResponse.java b/src/main/java/com/server/admin/dto/AdminReportResponse.java new file mode 100644 index 0000000..ad6082d --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminReportResponse.java @@ -0,0 +1,43 @@ +package com.server.admin.dto; + +import com.server.post.domain.ReportStatus; +import com.server.report.domain.Report; +import com.server.report.domain.ReportTargetType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "신고 한 건") +public record AdminReportResponse( + @Schema(example = "12") Long id, + @Schema(description = "신고한 사용자") Reporter reporter, + @Schema(description = "POST, COMMENT, USER", example = "POST") ReportTargetType targetType, + @Schema(example = "7") Long targetId, + @Schema(example = "광고성 게시물입니다") String reason, + @Schema(description = "PENDING, REVIEWING, RESOLVED, REJECTED", example = "PENDING") + ReportStatus status, + @Schema(example = "2026-08-25T14:02:00") LocalDateTime createdAt, + @Schema(description = "처리한 관리자. 아직 처리 전이면 null") Reporter handledBy, + @Schema(description = "처리 시각. 아직 처리 전이면 null") LocalDateTime handledAt +) { + + @Schema(description = "사용자 요약") + public record Reporter( + @Schema(example = "3") Long id, + @Schema(example = "여행자") String nickname + ) { + } + + public static AdminReportResponse from(Report report) { + return new AdminReportResponse( + report.getId(), + new Reporter(report.getReporter().getId(), report.getReporter().getNickname()), + report.getTargetType(), + report.getTargetId(), + report.getReason(), + report.getStatus(), + report.getCreatedAt(), + report.getHandledBy() == null ? null + : new Reporter(report.getHandledBy().getId(), report.getHandledBy().getNickname()), + report.getHandledAt()); + } +} diff --git a/src/main/java/com/server/admin/dto/AdminStatsPopularResponse.java b/src/main/java/com/server/admin/dto/AdminStatsPopularResponse.java new file mode 100644 index 0000000..c3a8766 --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminStatsPopularResponse.java @@ -0,0 +1,19 @@ +package com.server.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "인기 장소 또는 해시태그") +public record AdminStatsPopularResponse( + @Schema(description = "PLACE 또는 HASHTAG", example = "PLACE") String type, + List items +) { + + @Schema(description = "인기 항목 한 건") + public record Item( + @Schema(description = "장소 ID. 해시태그면 null", example = "42") Long id, + @Schema(example = "해운대해수욕장") String name, + @Schema(description = "쓰인 횟수", example = "37") long count + ) { + } +} diff --git a/src/main/java/com/server/admin/dto/AdminStatsSummaryResponse.java b/src/main/java/com/server/admin/dto/AdminStatsSummaryResponse.java new file mode 100644 index 0000000..4265311 --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminStatsSummaryResponse.java @@ -0,0 +1,22 @@ +package com.server.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "총계와 기간 증감. 기간은 days 로 지정하며 기본 7일이다.") +public record AdminStatsSummaryResponse( + @Schema(description = "집계 기간(일)", example = "7") int days, + Metric users, + Metric posts, + Metric schedules, + @Schema(description = "처리 대기 중인 신고 수", example = "4") long pendingReports, + @Schema(description = "정지된 사용자 수", example = "2") long suspendedUsers, + @Schema(description = "적재된 장소 수. 가린 것은 뺀다", example = "475") long visiblePlaces +) { + + @Schema(description = "총계와 최근 기간 증가분") + public record Metric( + @Schema(description = "전체 누적", example = "128") long total, + @Schema(description = "최근 기간 안에 늘어난 수", example = "12") long recent + ) { + } +} diff --git a/src/main/java/com/server/admin/dto/AdminStatsTrendResponse.java b/src/main/java/com/server/admin/dto/AdminStatsTrendResponse.java new file mode 100644 index 0000000..578e6b0 --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminStatsTrendResponse.java @@ -0,0 +1,25 @@ +package com.server.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; + +/** + * 일자별 추이. + * + *

값이 0인 날도 채워 넣는다. DB 는 행이 없는 날을 돌려주지 않으므로, 그대로 두면 + * 화면이 빈 날을 건너뛰어 그래프가 실제보다 완만해 보인다. + */ +@Schema(description = "일자별 추이. 값이 0인 날도 포함한다.") +public record AdminStatsTrendResponse( + @Schema(description = "USERS, POSTS, SCHEDULES", example = "POSTS") String metric, + List points +) { + + @Schema(description = "하루치") + public record Point( + @Schema(example = "2026-08-25") LocalDate date, + @Schema(example = "7") long count + ) { + } +} diff --git a/src/main/java/com/server/admin/dto/AdminUserDetailResponse.java b/src/main/java/com/server/admin/dto/AdminUserDetailResponse.java new file mode 100644 index 0000000..0cc41b8 --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminUserDetailResponse.java @@ -0,0 +1,13 @@ +package com.server.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "사용자 상세. 조치를 판단하는 데 필요한 요약을 함께 준다.") +public record AdminUserDetailResponse( + AdminUserResponse user, + @Schema(description = "작성한 게시물 수. 삭제한 것은 빼고 센다", example = "12") long postCount, + @Schema(description = "이 사용자가 접수한 신고 수", example = "3") long reportsFiled, + @Schema(description = "이 사용자를 대상으로 접수된 신고 수. 조치 판단의 핵심이다", example = "5") + long reportsReceived +) { +} diff --git a/src/main/java/com/server/admin/dto/AdminUserListResponse.java b/src/main/java/com/server/admin/dto/AdminUserListResponse.java new file mode 100644 index 0000000..019efc5 --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminUserListResponse.java @@ -0,0 +1,11 @@ +package com.server.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "사용자 목록") +public record AdminUserListResponse( + List items, + @Schema(description = "조건에 맞는 전체 건수", example = "128") long totalCount +) { +} diff --git a/src/main/java/com/server/admin/dto/AdminUserResponse.java b/src/main/java/com/server/admin/dto/AdminUserResponse.java new file mode 100644 index 0000000..dedf8e2 --- /dev/null +++ b/src/main/java/com/server/admin/dto/AdminUserResponse.java @@ -0,0 +1,37 @@ +package com.server.admin.dto; + +import com.server.user.domain.User; +import com.server.user.domain.UserRole; +import com.server.user.domain.UserStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "관리자 화면의 사용자 한 건") +public record AdminUserResponse( + @Schema(example = "3") Long id, + @Schema(example = "여행자") String nickname, + @Schema(example = "traveler@example.com") String email, + @Schema(description = "USER 또는 ADMIN", example = "USER") UserRole role, + @Schema(description = "ACTIVE, SUSPENDED, WITHDRAWN", example = "ACTIVE") UserStatus status, + @Schema(description = "정지 만료 시각. 지나면 쓰기가 다시 열린다") LocalDateTime suspendedUntil, + @Schema(example = "광고성 게시물 반복 등록") String suspendedReason, + @Schema(description = "지금 쓰기가 막혀 있는지. 기간이 지난 정지는 false 다", example = "false") + boolean writeBlocked, + @Schema(example = "2026-08-25T14:38:55") LocalDateTime createdAt, + @Schema(description = "탈퇴 시각. 탈퇴하지 않았으면 null") LocalDateTime deletedAt +) { + + public static AdminUserResponse from(User user) { + return new AdminUserResponse( + user.getId(), + user.getNickname(), + user.getEmail(), + user.getRole(), + user.getStatus(), + user.getSuspendedUntil(), + user.getSuspendedReason(), + user.isWriteBlockedAt(LocalDateTime.now()), + user.getCreatedAt(), + user.getDeletedAt()); + } +} diff --git a/src/main/java/com/server/admin/dto/PlaceHiddenUpdateRequest.java b/src/main/java/com/server/admin/dto/PlaceHiddenUpdateRequest.java new file mode 100644 index 0000000..46bfcef --- /dev/null +++ b/src/main/java/com/server/admin/dto/PlaceHiddenUpdateRequest.java @@ -0,0 +1,19 @@ +package com.server.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Size; + +@Schema(description = "장소 숨김 또는 해제") +public record PlaceHiddenUpdateRequest( + @Schema(description = "true 면 숨김, false 면 해제", example = "true") boolean hidden, + + @Schema(description = "숨기는 사유. 해제할 때는 생략할 수 있다", example = "좌표가 실제 위치와 다름") + @Size(max = 500) String reason +) { + + @AssertTrue(message = "숨길 때는 사유가 필요합니다.") + public boolean isReasonPresentWhenHiding() { + return !hidden || (reason != null && !reason.isBlank()); + } +} diff --git a/src/main/java/com/server/admin/dto/ReportStatusUpdateRequest.java b/src/main/java/com/server/admin/dto/ReportStatusUpdateRequest.java new file mode 100644 index 0000000..f6dac2d --- /dev/null +++ b/src/main/java/com/server/admin/dto/ReportStatusUpdateRequest.java @@ -0,0 +1,12 @@ +package com.server.admin.dto; + +import com.server.post.domain.ReportStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "신고 처리 상태 변경") +public record ReportStatusUpdateRequest( + @Schema(description = "PENDING, REVIEWING, RESOLVED, REJECTED", example = "RESOLVED") + @NotNull ReportStatus status +) { +} diff --git a/src/main/java/com/server/admin/dto/StatsMetric.java b/src/main/java/com/server/admin/dto/StatsMetric.java new file mode 100644 index 0000000..8b8a7de --- /dev/null +++ b/src/main/java/com/server/admin/dto/StatsMetric.java @@ -0,0 +1,18 @@ +package com.server.admin.dto; + +/** 추이를 볼 수 있는 지표. 임의 테이블을 받지 않도록 값으로 제한한다. */ +public enum StatsMetric { + USERS("users"), + POSTS("posts"), + SCHEDULES("schedules"); + + private final String table; + + StatsMetric(String table) { + this.table = table; + } + + public String table() { + return table; + } +} diff --git a/src/main/java/com/server/admin/dto/UserStatusUpdateRequest.java b/src/main/java/com/server/admin/dto/UserStatusUpdateRequest.java new file mode 100644 index 0000000..1ec1efe --- /dev/null +++ b/src/main/java/com/server/admin/dto/UserStatusUpdateRequest.java @@ -0,0 +1,27 @@ +package com.server.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +@Schema(description = "사용자 정지 또는 해제") +public record UserStatusUpdateRequest( + @Schema(description = "true 면 정지, false 면 해제", example = "true") + boolean suspended, + + @Schema(description = "정지 기간(일). 생략하면 기한 없는 정지다. 해제할 때는 무시한다", + example = "7") + @Min(1) @Max(3650) Integer days, + + @Schema(description = "정지 사유. 해제할 때는 생략할 수 있다", + example = "광고성 게시물 반복 등록") + @Size(max = 500) String reason +) { + + @AssertTrue(message = "정지할 때는 사유가 필요합니다.") + public boolean isReasonPresentWhenSuspending() { + return !suspended || (reason != null && !reason.isBlank()); + } +} diff --git a/src/main/java/com/server/admin/service/AdminPlaceService.java b/src/main/java/com/server/admin/service/AdminPlaceService.java new file mode 100644 index 0000000..4d6b6b4 --- /dev/null +++ b/src/main/java/com/server/admin/service/AdminPlaceService.java @@ -0,0 +1,126 @@ +package com.server.admin.service; + +import com.server.admin.dto.AdminIngestionStatusResponse; +import com.server.admin.dto.AdminPlaceResponse; +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.place.domain.Place; +import com.server.place.ingestion.TourApiPlaceIngestionProperties; +import com.server.place.ingestion.TourApiPlaceIngestionResult; +import com.server.place.ingestion.TourApiPlaceIngestionService; +import com.server.place.repository.PlaceRepository; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 관리자 장소 데이터 관리. + * + *

잘못 적재된 장소는 지우지 않고 가린다. 행을 지우면 TourAPI 증분 동기화가 다음 실행에서 + * 같은 장소를 다시 만든다. + */ +@Service +public class AdminPlaceService { + + private static final Logger log = LoggerFactory.getLogger(AdminPlaceService.class); + + private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul"); + + private final PlaceRepository placeRepository; + private final TourApiPlaceIngestionService ingestionService; + private final TourApiPlaceIngestionProperties properties; + private final JdbcTemplate jdbcTemplate; + + public AdminPlaceService( + PlaceRepository placeRepository, + TourApiPlaceIngestionService ingestionService, + TourApiPlaceIngestionProperties properties, + JdbcTemplate jdbcTemplate + ) { + this.placeRepository = placeRepository; + this.ingestionService = ingestionService; + this.properties = properties; + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional(readOnly = true) + public AdminIngestionStatusResponse getIngestionStatus() { + Map statusCounts = new LinkedHashMap<>(); + jdbcTemplate.query( + "select ingestion_status, count(*) as count from places group by ingestion_status " + + "order by ingestion_status", + rs -> { + statusCounts.put(rs.getString("ingestion_status"), rs.getLong("count")); + }); + + int used = requestsUsedToday(); + int limit = properties.maxRequestsPerDay(); + + return new AdminIngestionStatusResponse( + statusCounts, + placeRepository.countByHiddenAtIsNotNull(), + properties.enabled(), + properties.enrichmentEnabled(), + LocalDate.now(KOREA_ZONE), + used, + limit, + Math.max(0, limit - used)); + } + + @Transactional(readOnly = true) + public List getHiddenPlaces() { + return placeRepository.findByHiddenAtIsNotNullOrderByHiddenAtDesc().stream() + .map(AdminPlaceResponse::from) + .toList(); + } + + /** + * 수동 적재. + * + *

스케줄러와 같은 하루 예산을 쓴다. 남은 양이 없으면 시작하지 않는다. 시작해 봐야 + * 예약 단계에서 막혀 아무것도 하지 못하고, 관리자는 왜 안 되는지 알 수 없다. + * + *

트랜잭션을 걸지 않는다. TourAPI 호출이 길게 이어지므로 DB 커넥션을 그동안 쥐고 + * 있으면 안 된다. + */ + public TourApiPlaceIngestionResult runIngestion() { + int remaining = Math.max(0, properties.maxRequestsPerDay() - requestsUsedToday()); + if (remaining <= 0) { + throw new BusinessException(ErrorCode.TOUR_API_QUOTA_EXHAUSTED); + } + + log.info("Manual TourAPI ingestion requested. remainingQuota={}", remaining); + return ingestionService.ingestConfigured(); + } + + @Transactional + public AdminPlaceResponse updateHidden(Long placeId, boolean hidden, String reason) { + Place place = placeRepository.findById(placeId) + .orElseThrow(() -> new BusinessException(ErrorCode.PLACE_NOT_FOUND)); + + if (hidden) { + place.hide(reason); + log.info("Place hidden. placeId={}, reason={}", placeId, reason); + } else { + place.unhide(); + log.info("Place unhidden. placeId={}", placeId); + } + return AdminPlaceResponse.from(place); + } + + /** 오늘(KST) 쓴 TourAPI 호출 수. 기록이 없으면 0이다. */ + private int requestsUsedToday() { + Integer used = jdbcTemplate.query( + "select requests_used from tour_api_request_usage where usage_date = ?", + rs -> rs.next() ? rs.getInt(1) : 0, + LocalDate.now(KOREA_ZONE)); + return used == null ? 0 : used; + } +} diff --git a/src/main/java/com/server/admin/service/AdminReportService.java b/src/main/java/com/server/admin/service/AdminReportService.java new file mode 100644 index 0000000..a9962f6 --- /dev/null +++ b/src/main/java/com/server/admin/service/AdminReportService.java @@ -0,0 +1,142 @@ +package com.server.admin.service; + +import com.server.admin.dto.AdminReportDetailResponse; +import com.server.admin.dto.AdminReportListResponse; +import com.server.admin.dto.AdminReportResponse; +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.post.domain.Comment; +import com.server.post.domain.Post; +import com.server.post.domain.ReportStatus; +import com.server.post.repository.CommentRepository; +import com.server.post.repository.PostRepository; +import com.server.report.domain.Report; +import com.server.report.domain.ReportTargetType; +import com.server.report.repository.ReportRepository; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 관리자 신고 처리. + * + *

신고 내용만으로는 판단할 수 없어 상세에서 대상 원본을 함께 읽는다. 대상이 이미 + * 지워졌으면 그대로 알린다. 신고 접수 뒤 작성자가 스스로 지웠거나 다른 관리자가 먼저 + * 조치한 경우이며, 그 사실을 감추면 관리자가 사라진 대상을 계속 찾게 된다. + */ +@Service +public class AdminReportService { + + private static final Logger log = LoggerFactory.getLogger(AdminReportService.class); + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + + private final ReportRepository reportRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + + public AdminReportService( + ReportRepository reportRepository, + PostRepository postRepository, + CommentRepository commentRepository, + UserRepository userRepository + ) { + this.reportRepository = reportRepository; + this.postRepository = postRepository; + this.commentRepository = commentRepository; + this.userRepository = userRepository; + } + + @Transactional(readOnly = true) + public AdminReportListResponse getReports( + ReportStatus status, ReportTargetType targetType, Integer page, Integer size) { + int resolvedPage = page == null || page < 0 ? 0 : page; + int resolvedSize = size == null || size <= 0 + ? DEFAULT_PAGE_SIZE + : Math.min(size, MAX_PAGE_SIZE); + + List reports = reportRepository.findForAdmin( + status, targetType, PageRequest.of(resolvedPage, resolvedSize)); + + // 전체 건수를 함께 준다. 없으면 화면이 페이지 수를 계산할 수 없어 다음 페이지가 + // 비어 있는지 눌러 봐야 안다. + return new AdminReportListResponse( + reports.stream().map(AdminReportResponse::from).toList(), + reportRepository.countForAdmin(status, targetType)); + } + + @Transactional(readOnly = true) + public AdminReportDetailResponse getReport(Long reportId) { + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + return new AdminReportDetailResponse( + AdminReportResponse.from(report), loadTarget(report)); + } + + /** + * 처리 상태를 바꾼다. + * + *

되돌리는 것도 허용한다. 잘못 종결한 신고를 다시 대기로 놓을 수 없으면 관리자가 + * 같은 내용의 새 신고가 들어오기를 기다리는 수밖에 없다. + */ + @Transactional + public AdminReportResponse updateStatus(Long reportId, ReportStatus status, Long adminId) { + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + User admin = userRepository.findByIdAndDeletedAtIsNull(adminId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + report.handle(status, admin); + log.info("Report status changed. reportId={}, status={}, adminId={}", + reportId, status, adminId); + return AdminReportResponse.from(report); + } + + /** + * 대상 원본을 읽는다. + * + *

삭제된 것도 찾는다. 신고 대상이 지워졌다는 사실 자체가 관리자에게 필요한 정보다. + */ + private AdminReportDetailResponse.Target loadTarget(Report report) { + return switch (report.getTargetType()) { + case POST -> postRepository.findById(report.getTargetId()) + .map(post -> new AdminReportDetailResponse.Target( + post.getId(), + author(post), + post.getContent(), + post.getDeletedAt() != null)) + .orElse(null); + case COMMENT -> commentRepository.findById(report.getTargetId()) + .map(comment -> new AdminReportDetailResponse.Target( + comment.getId(), + author(comment), + comment.getContent(), + comment.getDeletedAt() != null)) + .orElse(null); + case USER -> userRepository.findById(report.getTargetId()) + .map(user -> new AdminReportDetailResponse.Target( + user.getId(), + new AdminReportResponse.Reporter(user.getId(), user.getNickname()), + user.getNickname(), + user.getDeletedAt() != null)) + .orElse(null); + }; + } + + private AdminReportResponse.Reporter author(Post post) { + return new AdminReportResponse.Reporter( + post.getUser().getId(), post.getUser().getNickname()); + } + + private AdminReportResponse.Reporter author(Comment comment) { + return new AdminReportResponse.Reporter( + comment.getUser().getId(), comment.getUser().getNickname()); + } +} diff --git a/src/main/java/com/server/admin/service/AdminStatsService.java b/src/main/java/com/server/admin/service/AdminStatsService.java new file mode 100644 index 0000000..83b4d72 --- /dev/null +++ b/src/main/java/com/server/admin/service/AdminStatsService.java @@ -0,0 +1,160 @@ +package com.server.admin.service; + +import com.server.admin.dto.AdminStatsPopularResponse; +import com.server.admin.dto.AdminStatsSummaryResponse; +import com.server.admin.dto.AdminStatsTrendResponse; +import com.server.admin.dto.StatsMetric; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 관리자 통계. + * + *

실시간 집계다. 지금 규모에서는 매번 세도 충분하고, 집계 테이블을 두면 그것을 채우는 + * 배치와 어긋남을 관리해야 한다. 데이터가 커져 느려지면 그때 일 단위 집계로 옮긴다. + * + *

지표 이름을 SQL 에 그대로 넣지 않는다. {@link StatsMetric} 이 허용된 테이블만 + * 담고 있어, 문자열이 질의로 흘러들 여지를 두지 않는다. + */ +@Service +public class AdminStatsService { + + private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul"); + + private static final int DEFAULT_DAYS = 7; + private static final int MAX_DAYS = 365; + private static final int DEFAULT_POPULAR_SIZE = 10; + private static final int MAX_POPULAR_SIZE = 50; + + private final JdbcTemplate jdbcTemplate; + + public AdminStatsService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional(readOnly = true) + public AdminStatsSummaryResponse getSummary(Integer days) { + int window = resolveDays(days); + LocalDateTime since = LocalDate.now(KOREA_ZONE).minusDays(window - 1L).atStartOfDay(); + + return new AdminStatsSummaryResponse( + window, + metric("users", "deleted_at is null", since), + metric("posts", "deleted_at is null", since), + metric("schedules", null, since), + count("select count(*) from reports where status = 'PENDING'"), + count("select count(*) from users where status = 'SUSPENDED' and deleted_at is null"), + count("select count(*) from places where hidden_at is null")); + } + + @Transactional(readOnly = true) + public AdminStatsTrendResponse getTrend(StatsMetric metric, Integer days) { + int window = resolveDays(days); + LocalDate from = LocalDate.now(KOREA_ZONE).minusDays(window - 1L); + + // 지표는 enum 이 담은 테이블 이름만 쓴다. 사용자 입력이 SQL 로 흘러들지 않는다. + // 별칭에 day 를 쓰지 않는다. H2 에서 예약어라 문법 오류가 난다. + String sql = "select cast(created_at as date) as stat_date, count(*) as stat_count from " + + metric.table() + " where created_at >= ? group by cast(created_at as date)"; + + Map byDay = new HashMap<>(); + jdbcTemplate.query(sql, rs -> { + byDay.put(rs.getObject("stat_date", LocalDate.class), rs.getLong("stat_count")); + }, from.atStartOfDay()); + + // 값이 없는 날도 0으로 채운다. 빈 날을 건너뛰면 그래프가 실제보다 완만해 보인다. + List points = new ArrayList<>(window); + for (int offset = 0; offset < window; offset++) { + LocalDate date = from.plusDays(offset); + points.add(new AdminStatsTrendResponse.Point(date, byDay.getOrDefault(date, 0L))); + } + return new AdminStatsTrendResponse(metric.name(), points); + } + + /** + * 인기 장소와 해시태그. + * + *

장소는 게시물에 태그된 횟수로 센다. 일정에 담긴 횟수를 쓰면 Planner 가 고른 것이 + * 섞여 사용자가 고른 것과 구분되지 않는다. + */ + @Transactional(readOnly = true) + public AdminStatsPopularResponse getPopularPlaces(Integer size) { + int limit = resolvePopularSize(size); + List items = jdbcTemplate.query(""" + select place.id as id, place.name as name, count(*) as count + from post_place_tags tag + join places place on place.id = tag.place_id + join posts post on post.id = tag.post_id + where post.deleted_at is null and place.hidden_at is null + group by place.id, place.name + order by count desc, place.name asc + limit ? + """, + (rs, rowNum) -> new AdminStatsPopularResponse.Item( + rs.getLong("id"), rs.getString("name"), rs.getLong("count")), + limit); + return new AdminStatsPopularResponse("PLACE", items); + } + + @Transactional(readOnly = true) + public AdminStatsPopularResponse getPopularHashtags(Integer size) { + int limit = resolvePopularSize(size); + // hashtags.post_count 대신 실제 연결을 센다. 집계 컬럼은 어긋날 수 있고, + // 삭제된 게시물의 몫이 남아 있을 수도 있다. + List items = jdbcTemplate.query(""" + select hashtag.name as name, count(*) as count + from post_hashtags link + join hashtags hashtag on hashtag.id = link.hashtag_id + join posts post on post.id = link.post_id + where post.deleted_at is null + group by hashtag.name + order by count desc, hashtag.name asc + limit ? + """, + (rs, rowNum) -> new AdminStatsPopularResponse.Item( + null, rs.getString("name"), rs.getLong("count")), + limit); + return new AdminStatsPopularResponse("HASHTAG", items); + } + + private AdminStatsSummaryResponse.Metric metric( + String table, String aliveCondition, LocalDateTime since) { + String where = aliveCondition == null ? "" : " where " + aliveCondition; + long total = count("select count(*) from " + table + where); + + String recentWhere = aliveCondition == null + ? " where created_at >= ?" + : " where " + aliveCondition + " and created_at >= ?"; + Long recent = jdbcTemplate.queryForObject( + "select count(*) from " + table + recentWhere, Long.class, since); + + return new AdminStatsSummaryResponse.Metric(total, recent == null ? 0 : recent); + } + + private long count(String sql) { + Long value = jdbcTemplate.queryForObject(sql, Long.class); + return value == null ? 0 : value; + } + + private int resolveDays(Integer days) { + if (days == null || days <= 0) { + return DEFAULT_DAYS; + } + return Math.min(days, MAX_DAYS); + } + + private int resolvePopularSize(Integer size) { + if (size == null || size <= 0) { + return DEFAULT_POPULAR_SIZE; + } + return Math.min(size, MAX_POPULAR_SIZE); + } +} diff --git a/src/main/java/com/server/admin/service/AdminUserService.java b/src/main/java/com/server/admin/service/AdminUserService.java new file mode 100644 index 0000000..0c6792a --- /dev/null +++ b/src/main/java/com/server/admin/service/AdminUserService.java @@ -0,0 +1,132 @@ +package com.server.admin.service; + +import com.server.admin.dto.AdminUserDetailResponse; +import com.server.admin.dto.AdminUserListResponse; +import com.server.admin.dto.AdminUserResponse; +import com.server.auth.service.RefreshTokenStore; +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.post.repository.PostRepository; +import com.server.report.repository.ReportRepository; +import com.server.user.domain.User; +import com.server.user.domain.UserStatus; +import com.server.user.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 관리자 사용자 관리. + * + *

정지는 쓰기만 막고 읽기는 허용한다. 읽기까지 막으면 정지된 사용자가 자기 상태를 + * 확인할 방법이 없다. + */ +@Service +public class AdminUserService { + + private static final Logger log = LoggerFactory.getLogger(AdminUserService.class); + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + + private final UserRepository userRepository; + private final PostRepository postRepository; + private final ReportRepository reportRepository; + private final RefreshTokenStore refreshTokenStore; + + public AdminUserService( + UserRepository userRepository, + PostRepository postRepository, + ReportRepository reportRepository, + RefreshTokenStore refreshTokenStore + ) { + this.userRepository = userRepository; + this.postRepository = postRepository; + this.reportRepository = reportRepository; + this.refreshTokenStore = refreshTokenStore; + } + + @Transactional(readOnly = true) + public AdminUserListResponse getUsers( + String keyword, UserStatus status, Integer page, Integer size) { + int resolvedPage = page == null || page < 0 ? 0 : page; + int resolvedSize = size == null || size <= 0 + ? DEFAULT_PAGE_SIZE + : Math.min(size, MAX_PAGE_SIZE); + String normalized = keyword == null || keyword.isBlank() ? null : keyword.trim(); + + List users = userRepository.searchForAdmin( + normalized, status, PageRequest.of(resolvedPage, resolvedSize)); + + return new AdminUserListResponse( + users.stream().map(AdminUserResponse::from).toList(), + userRepository.countForAdmin(normalized, status)); + } + + /** 탈퇴한 사용자도 조회한다. 신고를 따라 들어왔을 때 이미 탈퇴했다는 사실이 필요하다. */ + @Transactional(readOnly = true) + public AdminUserDetailResponse getUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + return new AdminUserDetailResponse( + AdminUserResponse.from(user), + postRepository.countByUserIdAndDeletedAtIsNull(userId), + reportRepository.countByReporterId(userId), + reportRepository.countAgainstUser(userId)); + } + + /** + * 정지하거나 해제한다. + * + *

정지하면 그 사용자의 리프레시 토큰을 모두 지운다. 액세스 토큰은 무상태라 남은 + * 수명(기본 30분) 동안 유효하지만, 갱신을 막으면 그 뒤로는 이어갈 수 없다. 즉시 + * 끊으려면 액세스 토큰까지 저장해야 하는데, 그러면 모든 요청이 저장소를 거친다. + * + *

관리자는 정지할 수 없다. 관리자끼리 서로 정지시키면 아무도 풀 수 없는 상태가 + * 될 수 있고, 그때 남는 수단은 DB 를 직접 고치는 것뿐이다. + */ + @Transactional + public AdminUserResponse updateStatus( + Long userId, boolean suspended, Integer days, String reason) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (suspended && user.isAdmin()) { + throw new BusinessException(ErrorCode.CANNOT_SUSPEND_ADMIN); + } + + if (suspended) { + LocalDateTime until = days == null ? null : LocalDateTime.now().plusDays(days); + user.suspend(until, reason); + log.info("User suspended. userId={}, until={}", userId, until); + revokeTokensQuietly(userId); + } else { + user.releaseSuspension(); + log.info("User suspension released. userId={}", userId); + } + return AdminUserResponse.from(user); + } + + /** + * 리프레시 토큰 폐기는 실패해도 정지를 되돌리지 않는다. + * + *

Redis 가 죽었다고 악성 사용자를 정지시키지 못하면 곤란하다. 중요한 것은 DB 의 + * 정지 상태이며, 쓰기 차단은 그것만으로 동작한다. 게다가 Redis 가 없으면 갱신 자체가 + * 실패하므로 폐기하지 못한 토큰으로도 세션을 이어갈 수 없다. + */ + private void revokeTokensQuietly(Long userId) { + try { + long revoked = refreshTokenStore.revokeAll(userId); + log.info("Revoked refresh tokens for suspended user. userId={}, count={}", + userId, revoked); + } catch (RuntimeException exception) { + log.error("Failed to revoke refresh tokens for suspended user. userId={}", + userId, exception); + } + } +} diff --git a/src/main/java/com/server/auth/web/SuspendedUserWriteFilter.java b/src/main/java/com/server/auth/web/SuspendedUserWriteFilter.java new file mode 100644 index 0000000..b473ee3 --- /dev/null +++ b/src/main/java/com/server/auth/web/SuspendedUserWriteFilter.java @@ -0,0 +1,79 @@ +package com.server.auth.web; + +import com.server.common.error.ErrorCode; +import com.server.user.repository.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * 정지된 계정의 쓰기를 막는다. + * + *

정지 상태를 저장만 하고 확인하는 곳이 없으면 정지는 아무것도 막지 못한다. 관리자가 + * 정지시켜도 그 사용자는 계속 글을 쓴다. + * + *

쓰기 요청에서만 DB 를 읽는다. 액세스 토큰에는 정지 여부가 없다. 매 요청 확인은 + * 모든 조회에 질의를 하나 더하는 것이라, 읽기는 그대로 두고 쓰기만 본다. 읽기를 막지 않는 + * 것은 의도이기도 하다. 정지된 사용자가 자기 상태를 확인할 수는 있어야 한다. + * + *

기한이 지난 정지는 스스로 풀린 것으로 본다({@code User.isWriteBlockedAt}). 상태를 + * 되돌리는 배치가 없어도 만료가 동작한다. + */ +@Component +public class SuspendedUserWriteFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(SuspendedUserWriteFilter.class); + + private static final Set WRITE_METHODS = Set.of("POST", "PATCH", "PUT", "DELETE"); + + /** 로그인·갱신은 막지 않는다. 정지된 사용자도 로그인해 자기 상태를 볼 수 있어야 한다. */ + private static final String AUTH_PATH = "/api/v1/auth"; + + private final UserRepository userRepository; + private final SecurityErrorResponder responder; + + public SuspendedUserWriteFilter( + UserRepository userRepository, SecurityErrorResponder responder) { + this.userRepository = userRepository; + this.responder = responder; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !WRITE_METHODS.contains(request.getMethod()) + || request.getRequestURI().startsWith(AUTH_PATH); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + Long userId = CurrentUser.idOrNull(); + if (userId == null) { + // 로그인하지 않은 요청은 여기서 다루지 않는다. 인가가 판단할 몫이다. + filterChain.doFilter(request, response); + return; + } + + boolean blocked = userRepository.findById(userId) + .map(user -> user.isWriteBlockedAt(LocalDateTime.now())) + .orElse(false); + if (blocked) { + log.info("Blocked write from suspended user. userId={}, uri={}", + userId, request.getRequestURI()); + responder.write(request, response, ErrorCode.USER_SUSPENDED); + return; + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/server/common/config/SecurityConfig.java b/src/main/java/com/server/common/config/SecurityConfig.java index 2e27595..ebadc9c 100644 --- a/src/main/java/com/server/common/config/SecurityConfig.java +++ b/src/main/java/com/server/common/config/SecurityConfig.java @@ -3,6 +3,7 @@ import com.server.auth.web.AccessTokenAuthenticationFilter; import com.server.auth.web.ErrorResponseAccessDeniedHandler; import com.server.auth.web.ErrorResponseAuthenticationEntryPoint; +import com.server.auth.web.SuspendedUserWriteFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; @@ -21,17 +22,20 @@ public class SecurityConfig { private final AccessTokenAuthenticationFilter accessTokenAuthenticationFilter; private final ErrorResponseAuthenticationEntryPoint authenticationEntryPoint; private final ErrorResponseAccessDeniedHandler accessDeniedHandler; + private final SuspendedUserWriteFilter suspendedUserWriteFilter; public SecurityConfig( CorsProperties corsProperties, AccessTokenAuthenticationFilter accessTokenAuthenticationFilter, ErrorResponseAuthenticationEntryPoint authenticationEntryPoint, - ErrorResponseAccessDeniedHandler accessDeniedHandler + ErrorResponseAccessDeniedHandler accessDeniedHandler, + SuspendedUserWriteFilter suspendedUserWriteFilter ) { this.corsProperties = corsProperties; this.accessTokenAuthenticationFilter = accessTokenAuthenticationFilter; this.authenticationEntryPoint = authenticationEntryPoint; this.accessDeniedHandler = accessDeniedHandler; + this.suspendedUserWriteFilter = suspendedUserWriteFilter; } @Bean @@ -47,6 +51,10 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.addFilterBefore( accessTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + // 인증 정보를 채운 뒤에 본다. 정지된 계정의 쓰기를 여기서 막지 않으면 정지가 + // 상태만 저장하고 아무것도 막지 못한다. + http.addFilterAfter(suspendedUserWriteFilter, AccessTokenAuthenticationFilter.class); + // 401·403 도 다른 오류와 같은 ErrorResponse 형태로 내보낸다. Security 예외는 필터 // 단계에서 나 @ControllerAdvice 가 잡지 못하므로 여기서 직접 연결한다. http.exceptionHandling(handling -> handling diff --git a/src/main/java/com/server/common/error/ErrorCode.java b/src/main/java/com/server/common/error/ErrorCode.java index 02a7b3d..3e8ba84 100644 --- a/src/main/java/com/server/common/error/ErrorCode.java +++ b/src/main/java/com/server/common/error/ErrorCode.java @@ -43,6 +43,10 @@ public enum ErrorCode { TRANSIT_ROUTE_NOT_FOUND(422, "장소 사이 대중교통 경로를 찾지 못했습니다."), FACILITY_TYPE_NOT_SUPPORTED(501, "지원하지 않는 편의시설 유형입니다."), EXTERNAL_PROVIDER_UNAVAILABLE(503, "외부 서비스가 응답하지 않습니다."), + REPORT_NOT_FOUND(404, "신고를 찾을 수 없습니다."), + CANNOT_SUSPEND_ADMIN(400, "관리자는 정지할 수 없습니다."), + TOUR_API_QUOTA_EXHAUSTED(429, "오늘 TourAPI 호출 예산을 모두 썼습니다."), + INVALID_STATS_TYPE(400, "지원하지 않는 통계 유형입니다."), INVALID_GOOGLE_TOKEN(401, "구글 로그인 정보를 확인하지 못했습니다."), INVALID_TOKEN(401, "인증 정보가 올바르지 않습니다."), UNAUTHORIZED(401, "로그인이 필요합니다."), diff --git a/src/main/java/com/server/place/domain/Place.java b/src/main/java/com/server/place/domain/Place.java index a80681f..ca6175b 100644 --- a/src/main/java/com/server/place/domain/Place.java +++ b/src/main/java/com/server/place/domain/Place.java @@ -87,6 +87,16 @@ public class Place { @Column(name = "ingestion_last_error", columnDefinition = "text") private String ingestionLastError; + /** + * 관리자가 가린 시각. 지우지 않고 가리는 이유는, 행을 지우면 TourAPI 증분 동기화가 + * 다음 실행에서 같은 장소를 다시 만들기 때문이다. + */ + @Column(name = "hidden_at") + private LocalDateTime hiddenAt; + + @Column(name = "hidden_reason", columnDefinition = "text") + private String hiddenReason; + @Column(name = "ingestion_next_retry_at") private LocalDateTime ingestionNextRetryAt; @@ -130,6 +140,28 @@ public Place( this.ingestionStatus = PlaceIngestionStatus.SYNCED; } + public LocalDateTime getHiddenAt() { + return hiddenAt; + } + + public String getHiddenReason() { + return hiddenReason; + } + + public boolean isHidden() { + return hiddenAt != null; + } + + public void hide(String reason) { + this.hiddenAt = LocalDateTime.now(); + this.hiddenReason = reason; + } + + public void unhide() { + this.hiddenAt = null; + this.hiddenReason = null; + } + public Long getId() { return id; } diff --git a/src/main/java/com/server/place/repository/PlaceRepository.java b/src/main/java/com/server/place/repository/PlaceRepository.java index 4e8df4d..1f14958 100644 --- a/src/main/java/com/server/place/repository/PlaceRepository.java +++ b/src/main/java/com/server/place/repository/PlaceRepository.java @@ -23,4 +23,9 @@ public interface PlaceRepository extends JpaRepository { @Override @EntityGraph(attributePaths = {"detail", "operatingInfo", "images"}) Optional findById(Long id); + + /** 관리자가 가려 둔 장소. 최근에 가린 순이다. */ + List findByHiddenAtIsNotNullOrderByHiddenAtDesc(); + + long countByHiddenAtIsNotNull(); } diff --git a/src/main/java/com/server/place/service/PlaceService.java b/src/main/java/com/server/place/service/PlaceService.java index 5d9a826..3b91f3a 100644 --- a/src/main/java/com/server/place/service/PlaceService.java +++ b/src/main/java/com/server/place/service/PlaceService.java @@ -177,12 +177,19 @@ private PlaceResolveResponse toResolveResponse(Place saved) { public PlaceDetailResponse getDetail(Long placeId) { Place place = placeRepository.findById(placeId) .orElseThrow(() -> new BusinessException(ErrorCode.PLACE_NOT_FOUND)); + // 가린 장소는 없는 것으로 다룬다. 검색에서 빼놓고 상세만 열어 두면 예전 링크로 + // 그대로 들어올 수 있다. + if (place.isHidden()) { + throw new BusinessException(ErrorCode.PLACE_NOT_FOUND); + } return toDetailResponse(place); } private PlaceSearchResponse searchByKeyword(String keyword, String scope, int size) { List items = new ArrayList<>(placeRepository .findByNameContainingIgnoreCaseOrderByNameAsc(keyword).stream() + // 관리자가 가린 장소는 검색에 나오지 않는다. + .filter(place -> !place.isHidden()) .limit(size) .map(place -> toSearchItem(place, null)) .toList()); @@ -212,6 +219,7 @@ private PlaceSearchResponse searchByLocation( int size ) { List items = placeRepository.findAll().stream() + .filter(place -> !place.isHidden()) .map(place -> toSearchItem(place, distanceMeters(longitude, latitude, place))) .filter(item -> item.distanceMeters() <= radius) .sorted(Comparator.comparing(PlaceSearchResponse.Item::distanceMeters)) diff --git a/src/main/java/com/server/post/domain/ReportStatus.java b/src/main/java/com/server/post/domain/ReportStatus.java index dc5db02..75cd5cc 100644 --- a/src/main/java/com/server/post/domain/ReportStatus.java +++ b/src/main/java/com/server/post/domain/ReportStatus.java @@ -1,6 +1,24 @@ package com.server.post.domain; +/** + * 신고 처리 상태. + * + *

{@code REVIEWING} 을 둔 이유는 관리자가 여럿일 때 같은 신고를 두 사람이 동시에 + * 들여다보는 것을 줄이기 위함이다. 확인만 하고 조치하지 않은 것과 아직 아무도 보지 않은 + * 것을 구분하지 못하면 대기 목록이 계속 같은 항목으로 채워진다. + */ public enum ReportStatus { + /** 접수만 된 상태. */ PENDING, - RESOLVED + /** 관리자가 확인 중. */ + REVIEWING, + /** 조치를 마침. */ + RESOLVED, + /** 신고 사유가 되지 않는다고 판단함. */ + REJECTED; + + /** 처리가 끝난 상태인지. */ + public boolean isClosed() { + return this == RESOLVED || this == REJECTED; + } } diff --git a/src/main/java/com/server/post/service/CommentService.java b/src/main/java/com/server/post/service/CommentService.java index bc283f5..177d344 100644 --- a/src/main/java/com/server/post/service/CommentService.java +++ b/src/main/java/com/server/post/service/CommentService.java @@ -143,6 +143,20 @@ public CommentLikeResponse unlike(Long postId, Long commentId, Long userId) { return new CommentLikeResponse(commentRepository.findLikeCountById(commentId), false); } + /** + * 관리자 삭제. 작성자 확인과 게시물 소속 확인을 건너뛰고 댓글 ID 만으로 지운다. + * 신고 화면은 댓글 ID 만 들고 있어 게시물 ID 를 함께 요구하면 한 번 더 조회해야 한다. + * + *

댓글 수 감소와 자리 유지는 본인 삭제와 같다. + */ + @Transactional + public void deleteByAdmin(Long commentId) { + Comment comment = commentRepository.findByIdAndDeletedAtIsNull(commentId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); + comment.delete(); + postRepository.decreaseCommentCount(comment.getPost().getId()); + } + private Comment findComment(Long postId, Long commentId) { Comment comment = commentRepository.findByIdAndDeletedAtIsNull(commentId) .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); diff --git a/src/main/java/com/server/post/service/PostService.java b/src/main/java/com/server/post/service/PostService.java index 952ef72..57a390f 100644 --- a/src/main/java/com/server/post/service/PostService.java +++ b/src/main/java/com/server/post/service/PostService.java @@ -209,6 +209,21 @@ public void delete(Long postId, Long userId) { hashtagService.detachFromPost(postId); } + /** + * 관리자 삭제. 작성자 확인만 건너뛰고 나머지는 본인 삭제와 같다. + * + *

소프트 삭제이므로 복구 기한 안에는 되살릴 수 있고, 기한이 지나면 정리 스케줄러가 + * 함께 지운다. 해시태그 연결도 똑같이 끊는다. 여기서 다르게 처리하면 관리자가 지운 + * 게시물만 다른 상태로 남는다. + */ + @Transactional + public void deleteByAdmin(Long postId) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND)); + post.delete(); + hashtagService.detachFromPost(postId); + } + /** 내가 지운 게시물 목록. 복구 기한이 남은 것만 준다. */ @Transactional(readOnly = true) public PostSummaryListResponse getMyDeletedPosts(Long userId, Integer page, Integer size) { diff --git a/src/main/java/com/server/report/domain/Report.java b/src/main/java/com/server/report/domain/Report.java index 28053c1..f39aba6 100644 --- a/src/main/java/com/server/report/domain/Report.java +++ b/src/main/java/com/server/report/domain/Report.java @@ -51,6 +51,14 @@ public class Report { @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; + /** 처리한 관리자. 이견이 생겼을 때 되짚을 근거다. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "handled_by") + private User handledBy; + + @Column(name = "handled_at") + private LocalDateTime handledAt; + protected Report() { } @@ -63,6 +71,24 @@ public Report(User reporter, ReportTargetType targetType, Long targetId, String this.createdAt = LocalDateTime.now(); } + /** + * 처리 상태를 바꾼다. 되돌리는 것도 허용한다. 잘못 처리한 신고를 다시 대기로 놓을 수 + * 없으면 관리자가 새 신고를 기다리는 수밖에 없다. + */ + public void handle(ReportStatus status, User handler) { + this.status = status; + this.handledBy = handler; + this.handledAt = LocalDateTime.now(); + } + + public User getHandledBy() { + return handledBy; + } + + public LocalDateTime getHandledAt() { + return handledAt; + } + public Long getId() { return id; } diff --git a/src/main/java/com/server/report/repository/ReportRepository.java b/src/main/java/com/server/report/repository/ReportRepository.java index 4f53550..92993e4 100644 --- a/src/main/java/com/server/report/repository/ReportRepository.java +++ b/src/main/java/com/server/report/repository/ReportRepository.java @@ -2,10 +2,63 @@ import com.server.report.domain.Report; import com.server.report.domain.ReportTargetType; +import com.server.post.domain.ReportStatus; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; public interface ReportRepository extends JpaRepository { boolean existsByReporterIdAndTargetTypeAndTargetId( Long reporterId, ReportTargetType targetType, Long targetId); + + /** + * 관리자 신고 목록. 상태·대상 유형은 없으면 거르지 않는다. + * + *

대기 중인 것부터 오래된 순으로 본다. 최신순으로 두면 오래 방치된 신고가 계속 + * 뒤로 밀린다. + */ + @Query(""" + select report from Report report + join fetch report.reporter + left join fetch report.handledBy + where (:status is null or report.status = :status) + and (:targetType is null or report.targetType = :targetType) + order by report.createdAt asc + """) + List findForAdmin( + @Param("status") ReportStatus status, + @Param("targetType") ReportTargetType targetType, + Pageable pageable); + + @Query(""" + select count(report) from Report report + where (:status is null or report.status = :status) + and (:targetType is null or report.targetType = :targetType) + """) + long countForAdmin( + @Param("status") ReportStatus status, + @Param("targetType") ReportTargetType targetType); + + long countByReporterId(Long reporterId); + + /** + * 이 사용자를 대상으로 접수된 신고 수. + * + *

사용자 직접 신고뿐 아니라 그가 쓴 게시물·댓글에 대한 신고도 함께 센다. + * 사용자 신고만 세면 문제 글을 반복해 올리는 계정이 깨끗해 보인다. + */ + @Query(""" + select count(report) from Report report + where (report.targetType = com.server.report.domain.ReportTargetType.USER + and report.targetId = :userId) + or (report.targetType = com.server.report.domain.ReportTargetType.POST + and report.targetId in (select post.id from Post post where post.user.id = :userId)) + or (report.targetType = com.server.report.domain.ReportTargetType.COMMENT + and report.targetId in ( + select comment.id from Comment comment where comment.user.id = :userId)) + """) + long countAgainstUser(@Param("userId") Long userId); } diff --git a/src/main/java/com/server/user/repository/UserRepository.java b/src/main/java/com/server/user/repository/UserRepository.java index 7f7500a..76f37cc 100644 --- a/src/main/java/com/server/user/repository/UserRepository.java +++ b/src/main/java/com/server/user/repository/UserRepository.java @@ -5,6 +5,9 @@ import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; +import com.server.user.domain.UserStatus; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { @@ -24,4 +27,30 @@ List findByNicknameContainingIgnoreCaseAndDeletedAtIsNullOrderByNicknameAs /** 소셜 로그인 식별. 이메일이 아니라 제공자가 준 고유 ID 로 찾는다. */ Optional findByProviderAndProviderIdAndDeletedAtIsNull( AuthProvider provider, String providerId); + + /** + * 관리자 사용자 검색. 닉네임·이메일 부분 일치이며 탈퇴한 사용자도 포함한다. + * 신고를 따라 들어왔을 때 이미 탈퇴했다는 사실 자체가 필요한 정보다. + */ + @Query(""" + select user from User user + where (:keyword is null + or lower(user.nickname) like lower(concat('%', :keyword, '%')) + or lower(user.email) like lower(concat('%', :keyword, '%'))) + and (:status is null or user.status = :status) + order by user.createdAt desc + """) + List searchForAdmin( + @Param("keyword") String keyword, + @Param("status") UserStatus status, + Pageable pageable); + + @Query(""" + select count(user) from User user + where (:keyword is null + or lower(user.nickname) like lower(concat('%', :keyword, '%')) + or lower(user.email) like lower(concat('%', :keyword, '%'))) + and (:status is null or user.status = :status) + """) + long countForAdmin(@Param("keyword") String keyword, @Param("status") UserStatus status); } diff --git a/src/main/resources/db/migration/V12__add_report_handling.sql b/src/main/resources/db/migration/V12__add_report_handling.sql new file mode 100644 index 0000000..a00f838 --- /dev/null +++ b/src/main/resources/db/migration/V12__add_report_handling.sql @@ -0,0 +1,9 @@ +-- 신고 처리 이력. +-- +-- 누가 언제 처리했는지 남기지 않으면, 같은 신고를 두 관리자가 다시 들여다보거나 +-- 처리 결과에 이견이 생겼을 때 되짚을 근거가 없다. +ALTER TABLE reports ADD COLUMN IF NOT EXISTS handled_by bigint REFERENCES users(id); +ALTER TABLE reports ADD COLUMN IF NOT EXISTS handled_at timestamp; + +-- 관리자 화면은 대기 중인 신고부터 본다. status 별 선택도가 높다. +CREATE INDEX IF NOT EXISTS idx_reports_status_created_at ON reports (status, created_at DESC); diff --git a/src/main/resources/db/migration/V13__add_place_hidden.sql b/src/main/resources/db/migration/V13__add_place_hidden.sql new file mode 100644 index 0000000..3bc1883 --- /dev/null +++ b/src/main/resources/db/migration/V13__add_place_hidden.sql @@ -0,0 +1,9 @@ +-- 잘못 적재된 장소를 가린다. +-- +-- 행을 지우면 TourAPI 증분 동기화가 다음 실행에서 같은 장소를 다시 만든다. 숨김 표시로 +-- 검색과 일정 후보에서 빼는 편이 확실하다. 되돌리기도 쉽다. +ALTER TABLE places ADD COLUMN IF NOT EXISTS hidden_at timestamp; +ALTER TABLE places ADD COLUMN IF NOT EXISTS hidden_reason text; + +-- 검색과 일정 후보 조회가 매번 이 조건을 붙인다. 숨긴 장소는 극소수라 부분 인덱스로 둔다. +CREATE INDEX IF NOT EXISTS idx_places_hidden_at ON places (hidden_at) WHERE hidden_at IS NOT NULL; diff --git a/src/test/java/com/server/admin/controller/AdminReportAuthorizationTest.java b/src/test/java/com/server/admin/controller/AdminReportAuthorizationTest.java new file mode 100644 index 0000000..d62de3c --- /dev/null +++ b/src/test/java/com/server/admin/controller/AdminReportAuthorizationTest.java @@ -0,0 +1,121 @@ +package com.server.admin.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.server.auth.service.AccessTokenProvider; +import com.server.user.domain.AuthProvider; +import com.server.user.domain.User; +import com.server.user.domain.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 관리자 신고 API 의 인가. + * + *

사용자 API 는 아직 공개라 이 경로만 닫혀 있다. 여기가 열려 있으면 누구나 신고를 + * 처리하고 남의 게시물을 지울 수 있다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DisplayName("관리자 신고 API 인가") +class AdminReportAuthorizationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private AccessTokenProvider accessTokenProvider; + + private String token(UserRole role) { + User user = User.ofOAuth(AuthProvider.GOOGLE, "sub", "a@example.com", "동준", null, role); + ReflectionTestUtils.setField(user, "id", 1L); + return "Bearer " + accessTokenProvider.issue(user); + } + + @Test + @DisplayName("토큰 없이 신고 목록을 볼 수 없다") + void rejectsAnonymousReportList() throws Exception { + mockMvc.perform(get("/api/v1/admin/reports")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("UNAUTHORIZED")); + } + + @Test + @DisplayName("일반 사용자는 신고 목록을 볼 수 없다") + void rejectsNonAdminReportList() throws Exception { + mockMvc.perform(get("/api/v1/admin/reports").header("Authorization", token(UserRole.USER))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN")); + } + + @Test + @DisplayName("일반 사용자는 관리자 삭제를 쓸 수 없다") + void rejectsNonAdminDeletion() throws Exception { + // 이게 뚫리면 누구나 남의 게시물과 댓글을 지울 수 있다. + mockMvc.perform(delete("/api/v1/admin/posts/1").header("Authorization", token(UserRole.USER))) + .andExpect(status().isForbidden()); + mockMvc.perform(delete("/api/v1/admin/comments/1") + .header("Authorization", token(UserRole.USER))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("일반 사용자는 신고 상태를 바꿀 수 없다") + void rejectsNonAdminStatusChange() throws Exception { + mockMvc.perform(patch("/api/v1/admin/reports/1") + .header("Authorization", token(UserRole.USER)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"RESOLVED\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("일반 사용자는 사용자·장소·통계 관리자 API 를 쓸 수 없다") + void rejectsNonAdminAcrossAdminApis() throws Exception { + // 관리자 경로가 늘어날 때 한 곳이라도 빠지면 그 API 만 열린다. + for (String path : new String[]{ + "/api/v1/admin/users", + "/api/v1/admin/places/ingestion", + "/api/v1/admin/places/hidden", + "/api/v1/admin/stats/summary", + "/api/v1/admin/stats/popular?type=PLACE"}) { + mockMvc.perform(get(path).header("Authorization", token(UserRole.USER))) + .andExpect(status().isForbidden()); + mockMvc.perform(get(path)) + .andExpect(status().isUnauthorized()); + } + } + + @Test + @DisplayName("관리자는 통계와 장소 관리를 볼 수 있다") + void allowsAdminAcrossAdminApis() throws Exception { + for (String path : new String[]{ + "/api/v1/admin/users", + "/api/v1/admin/places/hidden", + "/api/v1/admin/stats/summary"}) { + mockMvc.perform(get(path).header("Authorization", token(UserRole.ADMIN))) + .andExpect(status().isOk()); + } + } + + @Test + @DisplayName("관리자는 신고 목록을 볼 수 있다") + void allowsAdminReportList() throws Exception { + mockMvc.perform(get("/api/v1/admin/reports").header("Authorization", token(UserRole.ADMIN))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isArray()) + .andExpect(jsonPath("$.totalCount").exists()); + } +} diff --git a/src/test/java/com/server/admin/service/AdminPlaceQuotaIntegrationTest.java b/src/test/java/com/server/admin/service/AdminPlaceQuotaIntegrationTest.java new file mode 100644 index 0000000..90d8d37 --- /dev/null +++ b/src/test/java/com/server/admin/service/AdminPlaceQuotaIntegrationTest.java @@ -0,0 +1,75 @@ +package com.server.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +/** + * TourAPI 예산 확인. + * + *

{@code tour_api_request_usage} 는 Flyway 만 만드는 테이블이라 JPA 스키마를 쓰는 H2 + * 테스트에는 존재하지 않는다. 실제 PostgreSQL 에 전체 migration 을 적용해 확인한다. + */ +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///tour_quota_test", + "spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver", + "spring.datasource.username=test", + "spring.datasource.password=test", + "spring.flyway.enabled=true", + "spring.flyway.baseline-on-migrate=false", + "spring.jpa.hibernate.ddl-auto=validate" +}) +@ActiveProfiles("test") +@DisplayName("TourAPI 예산") +class AdminPlaceQuotaIntegrationTest { + + private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul"); + + @Autowired + private AdminPlaceService adminPlaceService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private void useQuota(int requests) { + jdbcTemplate.update(""" + INSERT INTO tour_api_request_usage (usage_date, requests_used, updated_at) + VALUES (?, ?, ?) + ON CONFLICT (usage_date) DO UPDATE SET requests_used = EXCLUDED.requests_used + """, LocalDate.now(KOREA_ZONE), requests, LocalDateTime.now()); + } + + @Test + @DisplayName("남은 예산을 한도에서 사용량을 뺀 값으로 준다") + void reportsRemainingQuota() { + useQuota(100); + + var status = adminPlaceService.getIngestionStatus(); + + assertThat(status.requestsUsed()).isEqualTo(100); + assertThat(status.requestsRemaining()).isEqualTo(status.dailyLimit() - 100); + assertThat(status.quotaDate()).isEqualTo(LocalDate.now(KOREA_ZONE)); + } + + @Test + @DisplayName("예산을 다 쓰면 수동 적재를 시작하지 않는다") + void refusesIngestionWhenQuotaExhausted() { + // 시작해 봐야 예약 단계에서 막혀 아무것도 하지 못하고, 관리자는 왜 안 되는지 알 수 없다. + useQuota(adminPlaceService.getIngestionStatus().dailyLimit()); + + assertThatThrownBy(() -> adminPlaceService.runIngestion()) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.TOUR_API_QUOTA_EXHAUSTED); + } +} diff --git a/src/test/java/com/server/admin/service/AdminPlaceServiceTest.java b/src/test/java/com/server/admin/service/AdminPlaceServiceTest.java new file mode 100644 index 0000000..880311b --- /dev/null +++ b/src/test/java/com/server/admin/service/AdminPlaceServiceTest.java @@ -0,0 +1,116 @@ +package com.server.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.place.domain.Place; +import com.server.place.dto.PlaceSearchResponse; +import com.server.place.repository.PlaceRepository; +import com.server.place.service.PlaceService; +import java.math.BigDecimal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("관리자 장소 관리") +class AdminPlaceServiceTest { + + @Autowired + private AdminPlaceService adminPlaceService; + @Autowired + private PlaceService placeService; + @Autowired + private PlaceRepository placeRepository; + + private Place place; + + @BeforeEach + void setUp() { + place = placeRepository.saveAndFlush(newPlace("숨김테스트장소")); + } + + private Place newPlace(String name) { + return new Place( + "TOUR_API", + name + "-id", + "12", + name, + "관광지", + "부산광역시 해운대구 우동", + new BigDecimal("129.16040000"), + new BigDecimal("35.15870000"), + null); + } + + @Test + @DisplayName("숨긴 장소는 이름 검색에서 빠진다") + void hiddenPlaceDisappearsFromSearch() { + PlaceSearchResponse before = placeService.search( + "숨김테스트장소", null, null, null, "INTERNAL", 20); + assertThat(before.items()).isNotEmpty(); + + adminPlaceService.updateHidden(place.getId(), true, "좌표가 실제 위치와 다름"); + + PlaceSearchResponse after = placeService.search( + "숨김테스트장소", null, null, null, "INTERNAL", 20); + assertThat(after.items()).isEmpty(); + } + + @Test + @DisplayName("숨긴 장소는 상세 조회도 404 다") + void hiddenPlaceIsNotVisibleInDetail() { + // 검색에서만 빼고 상세를 열어 두면 예전 링크로 그대로 들어올 수 있다. + adminPlaceService.updateHidden(place.getId(), true, "잘못된 정보"); + + assertThatThrownBy(() -> placeService.getDetail(place.getId())) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PLACE_NOT_FOUND); + } + + @Test + @DisplayName("숨김을 풀면 다시 검색된다") + void unhideRestoresSearch() { + adminPlaceService.updateHidden(place.getId(), true, "잘못된 정보"); + adminPlaceService.updateHidden(place.getId(), false, null); + + assertThat(placeService.search("숨김테스트장소", null, null, null, "INTERNAL", 20).items()) + .isNotEmpty(); + } + + @Test + @DisplayName("숨김 사유와 시각을 남긴다") + void recordsHiddenReason() { + var result = adminPlaceService.updateHidden(place.getId(), true, "좌표 오류"); + + assertThat(result.hidden()).isTrue(); + assertThat(result.hiddenReason()).isEqualTo("좌표 오류"); + assertThat(result.hiddenAt()).isNotNull(); + } + + @Test + @DisplayName("가려 둔 장소 목록에 나온다") + void listsHiddenPlaces() { + adminPlaceService.updateHidden(place.getId(), true, "좌표 오류"); + + assertThat(adminPlaceService.getHiddenPlaces()) + .extracting(com.server.admin.dto.AdminPlaceResponse::id) + .contains(place.getId()); + } + + @Test + @DisplayName("없는 장소를 숨기려 하면 404 다") + void rejectsUnknownPlace() { + assertThatThrownBy(() -> adminPlaceService.updateHidden(999999L, true, "사유")) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PLACE_NOT_FOUND); + } +} diff --git a/src/test/java/com/server/admin/service/AdminReportServiceTest.java b/src/test/java/com/server/admin/service/AdminReportServiceTest.java new file mode 100644 index 0000000..7b374ca --- /dev/null +++ b/src/test/java/com/server/admin/service/AdminReportServiceTest.java @@ -0,0 +1,182 @@ +package com.server.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.server.admin.dto.AdminReportDetailResponse; +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.post.domain.Post; +import com.server.post.domain.ReportStatus; +import com.server.post.repository.PostRepository; +import com.server.post.service.PostService; +import com.server.report.domain.Report; +import com.server.report.domain.ReportTargetType; +import com.server.report.repository.ReportRepository; +import com.server.user.domain.AuthProvider; +import com.server.user.domain.User; +import com.server.user.domain.UserRole; +import com.server.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("관리자 신고 처리") +class AdminReportServiceTest { + + @Autowired + private AdminReportService adminReportService; + @Autowired + private PostService postService; + @Autowired + private ReportRepository reportRepository; + @Autowired + private PostRepository postRepository; + @Autowired + private UserRepository userRepository; + + private User admin; + private User author; + private User reporter; + + @BeforeEach + void setUp() { + admin = save("관리자", UserRole.ADMIN, "admin-sub"); + author = save("작성자", UserRole.USER, "author-sub"); + reporter = save("신고자", UserRole.USER, "reporter-sub"); + } + + private User save(String nickname, UserRole role, String sub) { + return userRepository.saveAndFlush(User.ofOAuth( + AuthProvider.GOOGLE, sub, sub + "@example.com", nickname, null, role)); + } + + private Post savePost(String content) { + return postRepository.saveAndFlush(new Post(author, content)); + } + + private Report saveReport(ReportTargetType type, Long targetId) { + return reportRepository.saveAndFlush( + new Report(reporter, type, targetId, "광고성입니다")); + } + + @Test + @DisplayName("상세에 신고 대상 원본을 함께 준다") + void includesTargetContentInDetail() { + // 신고 사유만 보고는 조치할 수 없다. 관리자가 원본을 따로 찾아야 하면 화면이 성립하지 않는다. + Post post = savePost("광안리 야경 보러 갔어요"); + Report report = saveReport(ReportTargetType.POST, post.getId()); + + AdminReportDetailResponse detail = adminReportService.getReport(report.getId()); + + assertThat(detail.target()).isNotNull(); + assertThat(detail.target().content()).isEqualTo("광안리 야경 보러 갔어요"); + assertThat(detail.target().author().id()).isEqualTo(author.getId()); + assertThat(detail.target().deleted()).isFalse(); + } + + @Test + @DisplayName("이미 삭제된 대상도 삭제 표시와 함께 보여준다") + void marksAlreadyDeletedTarget() { + // 접수 뒤 작성자가 스스로 지웠거나 다른 관리자가 먼저 조치한 경우다. 감추면 + // 관리자가 사라진 대상을 계속 찾게 된다. + Post post = savePost("지워질 글"); + Report report = saveReport(ReportTargetType.POST, post.getId()); + postService.deleteByAdmin(post.getId()); + + AdminReportDetailResponse detail = adminReportService.getReport(report.getId()); + + assertThat(detail.target()).isNotNull(); + assertThat(detail.target().deleted()).isTrue(); + } + + @Test + @DisplayName("대상 행 자체가 없으면 target 이 null 이다") + void returnsNullTargetWhenRowIsGone() { + Report report = saveReport(ReportTargetType.POST, 999999L); + + assertThat(adminReportService.getReport(report.getId()).target()).isNull(); + } + + @Test + @DisplayName("처리하면 담당자와 시각을 남긴다") + void recordsHandler() { + Report report = saveReport(ReportTargetType.POST, savePost("글").getId()); + + var updated = adminReportService.updateStatus( + report.getId(), ReportStatus.RESOLVED, admin.getId()); + + assertThat(updated.status()).isEqualTo(ReportStatus.RESOLVED); + assertThat(updated.handledBy().id()).isEqualTo(admin.getId()); + assertThat(updated.handledAt()).isNotNull(); + } + + @Test + @DisplayName("종결한 신고를 다시 대기로 되돌릴 수 있다") + void allowsReopening() { + // 되돌릴 수 없으면 잘못 종결한 신고를 다루려고 같은 내용의 새 신고를 기다려야 한다. + Report report = saveReport(ReportTargetType.POST, savePost("글").getId()); + adminReportService.updateStatus(report.getId(), ReportStatus.RESOLVED, admin.getId()); + + var reopened = adminReportService.updateStatus( + report.getId(), ReportStatus.PENDING, admin.getId()); + + assertThat(reopened.status()).isEqualTo(ReportStatus.PENDING); + } + + @Test + @DisplayName("상태로 거르고 전체 건수를 함께 준다") + void filtersByStatusWithTotalCount() { + saveReport(ReportTargetType.POST, savePost("글1").getId()); + Report second = saveReport(ReportTargetType.COMMENT, 1L); + adminReportService.updateStatus(second.getId(), ReportStatus.RESOLVED, admin.getId()); + + var pending = adminReportService.getReports(ReportStatus.PENDING, null, 0, 20); + + assertThat(pending.items()).hasSize(1); + assertThat(pending.totalCount()).isEqualTo(1); + assertThat(adminReportService.getReports(null, null, 0, 20).totalCount()).isEqualTo(2); + } + + @Test + @DisplayName("오래된 신고부터 반환한다") + void returnsOldestFirst() { + // 최신순이면 방치된 신고가 계속 뒤로 밀린다. + Report first = saveReport(ReportTargetType.POST, savePost("먼저").getId()); + Report second = saveReport(ReportTargetType.COMMENT, 2L); + + var reports = adminReportService.getReports(null, null, 0, 20); + + assertThat(reports.items().get(0).id()).isEqualTo(first.getId()); + assertThat(reports.items().get(1).id()).isEqualTo(second.getId()); + } + + @Test + @DisplayName("없는 신고를 처리하려 하면 404 다") + void rejectsUnknownReport() { + assertThatThrownBy(() -> adminReportService.updateStatus( + 999999L, ReportStatus.RESOLVED, admin.getId())) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.REPORT_NOT_FOUND); + } + + @Test + @DisplayName("관리자 삭제도 소프트 삭제라 복구 기한 안에는 되살릴 수 있다") + void adminDeleteIsSoftDelete() { + // 관리자가 지운 게시물만 다른 상태로 남으면 복구·정리 경로가 갈라진다. + Post post = savePost("관리자가 지울 글"); + + postService.deleteByAdmin(post.getId()); + + Post found = postRepository.findById(post.getId()).orElseThrow(); + assertThat(found.getDeletedAt()).isNotNull(); + assertThat(postService.restore(post.getId(), author.getId()).id()).isEqualTo(post.getId()); + } +} diff --git a/src/test/java/com/server/admin/service/AdminStatsServiceTest.java b/src/test/java/com/server/admin/service/AdminStatsServiceTest.java new file mode 100644 index 0000000..b87fec9 --- /dev/null +++ b/src/test/java/com/server/admin/service/AdminStatsServiceTest.java @@ -0,0 +1,157 @@ +package com.server.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.server.admin.dto.StatsMetric; +import com.server.post.domain.Post; +import com.server.post.repository.PostRepository; +import com.server.user.domain.AuthProvider; +import com.server.user.domain.User; +import com.server.user.domain.UserRole; +import com.server.user.repository.UserRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("관리자 통계") +class AdminStatsServiceTest { + + private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul"); + + @Autowired + private AdminStatsService adminStatsService; + @Autowired + private UserRepository userRepository; + @Autowired + private PostRepository postRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + + private User author; + + @BeforeEach + void setUp() { + author = userRepository.saveAndFlush(User.ofOAuth( + AuthProvider.GOOGLE, "stats-sub", "stats@example.com", "통계작성자", null, + UserRole.USER)); + } + + private Post savePost(String content) { + return postRepository.saveAndFlush(new Post(author, content)); + } + + /** 과거 날짜의 게시물을 만든다. created_at 은 엔티티가 정하므로 직접 옮긴다. */ + private void backdate(Long postId, int daysAgo) { + jdbcTemplate.update("update posts set created_at = ? where id = ?", + LocalDateTime.now().minusDays(daysAgo), postId); + } + + @Test + @DisplayName("총계와 기간 증가분을 함께 준다") + void reportsTotalAndRecent() { + savePost("최근 글"); + backdate(savePost("오래된 글").getId(), 60); + + var summary = adminStatsService.getSummary(7); + + assertThat(summary.days()).isEqualTo(7); + assertThat(summary.posts().total()).isGreaterThanOrEqualTo(2); + assertThat(summary.posts().recent()).isGreaterThanOrEqualTo(1); + assertThat(summary.posts().recent()).isLessThan(summary.posts().total()); + } + + @Test + @DisplayName("삭제된 게시물은 총계에서 뺀다") + void excludesDeletedPosts() { + Post post = savePost("지울 글"); + long before = adminStatsService.getSummary(7).posts().total(); + + post.delete(); + postRepository.saveAndFlush(post); + + assertThat(adminStatsService.getSummary(7).posts().total()).isEqualTo(before - 1); + } + + @Test + @DisplayName("추이는 값이 없는 날도 0으로 채운다") + void fillsEmptyDaysWithZero() { + // 빈 날을 건너뛰면 화면이 그래프를 실제보다 완만하게 그린다. + savePost("오늘 글"); + + var trend = adminStatsService.getTrend(StatsMetric.POSTS, 7); + + assertThat(trend.points()).hasSize(7); + assertThat(trend.points()).extracting(p -> p.date()).doesNotHaveDuplicates(); + assertThat(trend.points().get(6).date()).isEqualTo(LocalDate.now(KOREA_ZONE)); + } + + @Test + @DisplayName("추이가 날짜 순으로 이어진다") + void trendIsContinuous() { + var trend = adminStatsService.getTrend(StatsMetric.POSTS, 5); + + for (int i = 1; i < trend.points().size(); i++) { + assertThat(trend.points().get(i).date()) + .isEqualTo(trend.points().get(i - 1).date().plusDays(1)); + } + } + + @Test + @DisplayName("오늘 만든 게시물이 오늘 칸에 잡힌다") + void countsTodayPost() { + long before = adminStatsService.getTrend(StatsMetric.POSTS, 1).points().get(0).count(); + + savePost("오늘 글"); + + assertThat(adminStatsService.getTrend(StatsMetric.POSTS, 1).points().get(0).count()) + .isEqualTo(before + 1); + } + + @Test + @DisplayName("기간을 생략하면 7일이고 상한을 넘기면 잘린다") + void clampsDays() { + assertThat(adminStatsService.getSummary(null).days()).isEqualTo(7); + assertThat(adminStatsService.getSummary(0).days()).isEqualTo(7); + assertThat(adminStatsService.getTrend(StatsMetric.POSTS, 9999).points()).hasSize(365); + } + + @Test + @DisplayName("정지된 사용자 수를 센다") + void countsSuspendedUsers() { + long before = adminStatsService.getSummary(7).suspendedUsers(); + author.suspend(LocalDateTime.now().plusDays(7), "광고성"); + userRepository.saveAndFlush(author); + + assertThat(adminStatsService.getSummary(7).suspendedUsers()).isEqualTo(before + 1); + } + + @Test + @DisplayName("인기 해시태그는 삭제된 게시물의 몫을 세지 않는다") + void excludesDeletedPostsFromPopularHashtags() { + // hashtags.post_count 를 그대로 쓰면 삭제된 게시물의 몫이 남아 순위가 뒤틀린다. + var popular = adminStatsService.getPopularHashtags(10); + + assertThat(popular.type()).isEqualTo("HASHTAG"); + assertThat(popular.items()).allSatisfy(item -> assertThat(item.count()).isPositive()); + } + + @Test + @DisplayName("인기 장소는 가려진 장소를 제외한다") + void excludesHiddenPlacesFromPopular() { + var popular = adminStatsService.getPopularPlaces(10); + + assertThat(popular.type()).isEqualTo("PLACE"); + assertThat(popular.items()).allSatisfy(item -> assertThat(item.id()).isNotNull()); + } +} diff --git a/src/test/java/com/server/admin/service/AdminUserServiceTest.java b/src/test/java/com/server/admin/service/AdminUserServiceTest.java new file mode 100644 index 0000000..a07003a --- /dev/null +++ b/src/test/java/com/server/admin/service/AdminUserServiceTest.java @@ -0,0 +1,145 @@ +package com.server.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.user.domain.AuthProvider; +import com.server.user.domain.User; +import com.server.user.domain.UserRole; +import com.server.user.domain.UserStatus; +import com.server.user.repository.UserRepository; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("관리자 사용자 관리") +class AdminUserServiceTest { + + @Autowired + private AdminUserService adminUserService; + @Autowired + private UserRepository userRepository; + + private User target; + + @BeforeEach + void setUp() { + target = save("여행자", UserRole.USER, "target-sub", "traveler@example.com"); + } + + private User save(String nickname, UserRole role, String sub, String email) { + return userRepository.saveAndFlush( + User.ofOAuth(AuthProvider.GOOGLE, sub, email, nickname, null, role)); + } + + @Test + @DisplayName("정지하면 상태와 사유가 남고 쓰기가 막힌 것으로 표시된다") + void suspendsUser() { + var result = adminUserService.updateStatus(target.getId(), true, 7, "광고성 게시물 반복"); + + assertThat(result.status()).isEqualTo(UserStatus.SUSPENDED); + assertThat(result.suspendedReason()).isEqualTo("광고성 게시물 반복"); + assertThat(result.suspendedUntil()).isNotNull(); + assertThat(result.writeBlocked()).isTrue(); + } + + @Test + @DisplayName("기간을 생략하면 기한 없는 정지다") + void suspendsIndefinitely() { + var result = adminUserService.updateStatus(target.getId(), true, null, "심각한 위반"); + + assertThat(result.suspendedUntil()).isNull(); + assertThat(result.writeBlocked()).isTrue(); + } + + @Test + @DisplayName("기간이 지난 정지는 스스로 풀린다") + void expiredSuspensionUnblocksItself() { + // 상태를 되돌리는 배치가 없어도 만료가 동작해야 한다. + target.suspend(LocalDateTime.now().minusDays(1), "지난 정지"); + userRepository.saveAndFlush(target); + + assertThat(adminUserService.getUser(target.getId()).user().writeBlocked()).isFalse(); + } + + @Test + @DisplayName("해제하면 사유와 기한이 지워진다") + void releasesSuspension() { + adminUserService.updateStatus(target.getId(), true, 7, "광고성"); + + var released = adminUserService.updateStatus(target.getId(), false, null, null); + + assertThat(released.status()).isEqualTo(UserStatus.ACTIVE); + assertThat(released.suspendedUntil()).isNull(); + assertThat(released.suspendedReason()).isNull(); + assertThat(released.writeBlocked()).isFalse(); + } + + @Test + @DisplayName("관리자는 정지할 수 없다") + void cannotSuspendAdmin() { + // 관리자끼리 서로 정지시키면 아무도 풀 수 없는 상태가 될 수 있고, + // 그때 남는 수단은 DB 를 직접 고치는 것뿐이다. + User admin = save("관리자", UserRole.ADMIN, "admin-sub", "admin@example.com"); + + assertThatThrownBy(() -> adminUserService.updateStatus(admin.getId(), true, 7, "사유")) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.CANNOT_SUSPEND_ADMIN); + } + + @Test + @DisplayName("닉네임과 이메일을 함께 검색한다") + void searchesNicknameAndEmail() { + save("다른사람", UserRole.USER, "other-sub", "someone@example.com"); + + assertThat(adminUserService.getUsers("여행", null, 0, 20).items()).hasSize(1); + assertThat(adminUserService.getUsers("traveler@", null, 0, 20).items()).hasSize(1); + assertThat(adminUserService.getUsers("없는값", null, 0, 20).items()).isEmpty(); + } + + @Test + @DisplayName("상태로 거르고 전체 건수를 함께 준다") + void filtersByStatus() { + save("정상", UserRole.USER, "active-sub", "active@example.com"); + adminUserService.updateStatus(target.getId(), true, 7, "사유"); + + var suspended = adminUserService.getUsers(null, UserStatus.SUSPENDED, 0, 20); + + assertThat(suspended.items()).hasSize(1); + assertThat(suspended.totalCount()).isEqualTo(1); + } + + @Test + @DisplayName("탈퇴한 사용자도 상세로 조회된다") + void findsWithdrawnUser() { + // 신고를 따라 들어왔을 때 이미 탈퇴했다는 사실 자체가 필요한 정보다. + target.delete(LocalDateTime.now()); + userRepository.saveAndFlush(target); + + var detail = adminUserService.getUser(target.getId()); + + assertThat(detail.user().status()).isEqualTo(UserStatus.WITHDRAWN); + assertThat(detail.user().deletedAt()).isNotNull(); + } + + @Test + @DisplayName("탈퇴한 사용자는 정지할 수 없다") + void cannotSuspendWithdrawnUser() { + target.delete(LocalDateTime.now()); + userRepository.saveAndFlush(target); + + assertThatThrownBy(() -> adminUserService.updateStatus(target.getId(), true, 7, "사유")) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); + } +} diff --git a/src/test/java/com/server/admin/service/AdminUserSuspensionTokenTest.java b/src/test/java/com/server/admin/service/AdminUserSuspensionTokenTest.java new file mode 100644 index 0000000..622f98b --- /dev/null +++ b/src/test/java/com/server/admin/service/AdminUserSuspensionTokenTest.java @@ -0,0 +1,92 @@ +package com.server.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.server.auth.config.AuthProperties; +import com.server.auth.service.InMemoryStringRedisTemplate; +import com.server.auth.service.RefreshTokenStore; +import com.server.post.repository.PostRepository; +import com.server.report.repository.ReportRepository; +import com.server.user.domain.AuthProvider; +import com.server.user.domain.User; +import com.server.user.domain.UserRole; +import com.server.user.repository.UserRepository; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +/** + * 정지 시 리프레시 토큰 폐기. + * + *

살아 있는 Redis 를 요구하지 않도록 저장소를 대역으로 직접 구성한다. 검증하려는 것은 + * 정지와 폐기가 이어지는지이지 Redis 자체가 아니다. + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("정지 시 토큰 폐기") +class AdminUserSuspensionTokenTest { + + @Autowired + private UserRepository userRepository; + @Autowired + private PostRepository postRepository; + @Autowired + private ReportRepository reportRepository; + + private RefreshTokenStore store() { + return new RefreshTokenStore(new InMemoryStringRedisTemplate(), new AuthProperties( + new AuthProperties.Google(java.util.List.of("client"), null, null), + new AuthProperties.Jwt("test-secret-key-long-enough-0123456789abcdef", null, + null, Duration.ofDays(14)))); + } + + @Test + @DisplayName("정지하면 그 사용자의 모든 리프레시 토큰이 사라진다") + void revokesEveryRefreshTokenOnSuspend() { + // 액세스 토큰은 무상태라 남은 수명 동안 유효하다. 갱신을 막아야 그 뒤로 이어갈 수 없다. + RefreshTokenStore store = store(); + AdminUserService service = new AdminUserService( + userRepository, postRepository, reportRepository, store); + User user = userRepository.saveAndFlush(User.ofOAuth( + AuthProvider.GOOGLE, "sub-x", "x@example.com", "정지대상", null, UserRole.USER)); + + String phone = store.issue(user.getId()); + String laptop = store.issue(user.getId()); + + service.updateStatus(user.getId(), true, 7, "광고성"); + + assertThat(store.consume(user.getId(), phone)).isFalse(); + assertThat(store.consume(user.getId(), laptop)).isFalse(); + } + + @Test + @DisplayName("토큰 폐기가 실패해도 정지는 유지된다") + void keepsSuspensionWhenRevocationFails() { + // Redis 가 죽었다고 악성 사용자를 정지시키지 못하면 곤란하다. 게다가 Redis 가 없으면 + // 갱신 자체가 실패하므로 폐기하지 못한 토큰으로도 세션을 이어갈 수 없다. + RefreshTokenStore failing = new RefreshTokenStore( + new InMemoryStringRedisTemplate() { + @Override + public java.util.Set keys(String pattern) { + throw new IllegalStateException("Redis down"); + } + }, + new AuthProperties( + new AuthProperties.Google(java.util.List.of("client"), null, null), + new AuthProperties.Jwt("test-secret-key-long-enough-0123456789abcdef", + null, null, Duration.ofDays(14)))); + AdminUserService service = new AdminUserService( + userRepository, postRepository, reportRepository, failing); + User user = userRepository.saveAndFlush(User.ofOAuth( + AuthProvider.GOOGLE, "sub-y", "y@example.com", "정지대상2", null, UserRole.USER)); + + var result = service.updateStatus(user.getId(), true, 7, "광고성"); + + assertThat(result.writeBlocked()).isTrue(); + } +} diff --git a/src/test/java/com/server/auth/service/InMemoryStringRedisTemplate.java b/src/test/java/com/server/auth/service/InMemoryStringRedisTemplate.java index 59bd525..d95de2c 100644 --- a/src/test/java/com/server/auth/service/InMemoryStringRedisTemplate.java +++ b/src/test/java/com/server/auth/service/InMemoryStringRedisTemplate.java @@ -22,7 +22,7 @@ * *

TTL 은 무시한다. 만료는 Redis 의 몫이고, 여기서 검증하려는 것은 회전과 재사용 판정이다. */ -class InMemoryStringRedisTemplate extends StringRedisTemplate { +public class InMemoryStringRedisTemplate extends StringRedisTemplate { private final Map store = new LinkedHashMap<>(); diff --git a/src/test/java/com/server/auth/web/SuspendedUserWriteFilterTest.java b/src/test/java/com/server/auth/web/SuspendedUserWriteFilterTest.java new file mode 100644 index 0000000..fd70d45 --- /dev/null +++ b/src/test/java/com/server/auth/web/SuspendedUserWriteFilterTest.java @@ -0,0 +1,118 @@ +package com.server.auth.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.server.admin.service.AdminUserService; +import com.server.auth.service.AccessTokenProvider; +import com.server.user.domain.AuthProvider; +import com.server.user.domain.User; +import com.server.user.domain.UserRole; +import com.server.user.repository.UserRepository; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 정지된 계정의 쓰기 차단. + * + *

정지 상태를 저장만 하고 확인하는 곳이 없으면 정지는 아무것도 막지 못한다. + * 관리자가 정지시켜도 그 사용자는 계속 글을 쓴다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@DisplayName("정지 계정 쓰기 차단") +class SuspendedUserWriteFilterTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private UserRepository userRepository; + @Autowired + private AccessTokenProvider accessTokenProvider; + @Autowired + private AdminUserService adminUserService; + private User user; + private String token; + + @BeforeEach + void setUp() throws Exception { + user = userRepository.saveAndFlush(User.ofOAuth( + AuthProvider.GOOGLE, "suspend-sub", "s@example.com", "정지대상", null, UserRole.USER)); + token = "Bearer " + accessTokenProvider.issue(user); + } + + private void suspend() { + adminUserService.updateStatus(user.getId(), true, 7, "광고성 게시물 반복"); + } + + @Test + @DisplayName("정지되면 쓰기 요청이 403 이다") + void blocksWriteWhenSuspended() throws Exception { + suspend(); + + mockMvc.perform(post("/api/v1/posts") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"글\",\"mediaList\":[]}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_SUSPENDED")); + } + + @Test + @DisplayName("정지돼도 읽기는 열려 있다") + void allowsReadWhenSuspended() throws Exception { + // 읽기까지 막으면 정지된 사용자가 자기 상태를 확인할 방법이 없다. + suspend(); + + mockMvc.perform(get("/api/v1/posts").header("Authorization", token)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("정지돼도 로그인 갱신 경로는 막지 않는다") + void allowsAuthPathWhenSuspended() throws Exception { + suspend(); + + // 토큰 형식이 틀려 거절되더라도 USER_SUSPENDED 로 막히지는 않아야 한다. + mockMvc.perform(post("/api/v1/auth/refresh") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"garbage\"}")) + .andExpect(jsonPath("$.code").value("INVALID_TOKEN")); + } + + @Test + @DisplayName("정지 해제하면 쓰기가 다시 열린다") + void unblocksAfterRelease() throws Exception { + suspend(); + adminUserService.updateStatus(user.getId(), false, null, null); + + // 본문 검증에서 걸리더라도 USER_SUSPENDED 는 아니어야 한다. + mockMvc.perform(post("/api/v1/posts") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"\",\"mediaList\":[]}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("정지되지 않은 사용자의 쓰기는 막지 않는다") + void doesNotBlockActiveUser() throws Exception { + mockMvc.perform(post("/api/v1/posts") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\":\"\",\"mediaList\":[]}")) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/server/common/database/PostgresMigrationIntegrationTest.java b/src/test/java/com/server/common/database/PostgresMigrationIntegrationTest.java index 0e67bb5..a660298 100644 --- a/src/test/java/com/server/common/database/PostgresMigrationIntegrationTest.java +++ b/src/test/java/com/server/common/database/PostgresMigrationIntegrationTest.java @@ -74,6 +74,12 @@ void migrationsMatchJpaSchema() throws IOException { + "and indexname = 'uk_users_provider_active'", Integer.class ); + Integer reportHandlingColumnCount = jdbcTemplate.queryForObject( + "select count(*) from information_schema.columns " + + "where table_schema = 'public' and table_name = 'reports' " + + "and column_name in ('handled_by', 'handled_at')", + Integer.class + ); Integer scheduleOwnerColumnCount = jdbcTemplate.queryForObject( "select count(*) from information_schema.columns " + "where table_schema = 'public' and table_name = 'schedules' " @@ -100,6 +106,7 @@ void migrationsMatchJpaSchema() throws IOException { // 같은 구글 계정으로 두 번 가입되지 않게 막는 부분 고유 인덱스. assertThat(providerIndexCount).isEqualTo(1); assertThat(scheduleOwnerColumnCount).isEqualTo(1); + assertThat(reportHandlingColumnCount).isEqualTo(2); } /** classpath의 db/migration 아래 있는 실제 스크립트 수. */ diff --git a/src/test/java/com/server/question/controller/QuestionControllerTest.java b/src/test/java/com/server/question/controller/QuestionControllerTest.java index c2646b6..0abab2a 100644 --- a/src/test/java/com/server/question/controller/QuestionControllerTest.java +++ b/src/test/java/com/server/question/controller/QuestionControllerTest.java @@ -2,14 +2,13 @@ import com.server.question.dto.QuestionResponse; import com.server.question.dto.TripQuestionsResponse; -import com.server.auth.service.AccessTokenProvider; import com.server.question.service.QuestionService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import com.server.answer.dto.AnswerResponse; @@ -20,8 +19,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -@WebMvcTest(QuestionController.class) -@AutoConfigureMockMvc(addFilters = false) +// 슬라이스(@WebMvcTest) 대신 전체 컨텍스트를 쓴다. 보안 필터가 늘 때마다 필터가 의존하는 +// 빈을 하나씩 목으로 채워야 해서, 이 저장소의 다른 컨트롤러 테스트와 방식을 맞춘다. +@SpringBootTest +@AutoConfigureMockMvc @ActiveProfiles("test") class QuestionControllerTest { @@ -32,9 +33,6 @@ class QuestionControllerTest { * 슬라이스 테스트는 서비스 계층을 싣지 않는다. SecurityConfig 가 인증 필터를 거치면서 * 필터가 의존하는 토큰 제공자가 필요해진다. addFilters = false 여도 빈은 만들어진다. */ - @MockitoBean - private AccessTokenProvider accessTokenProvider; - @Autowired private MockMvc mockMvc;