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;