Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
9 changes: 9 additions & 0 deletions docs/ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`에는 외래키가 없다.** 대상이 게시물·댓글·사용자로 달라져 한 테이블을 가리킬 수
Expand All @@ -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. 기존 테이블 변경
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AdminPlaceResponse> 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());
}
}
115 changes: 115 additions & 0 deletions src/main/java/com/server/admin/controller/AdminReportController.java
Original file line number Diff line number Diff line change
@@ -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;

/**
* 관리자 신고 처리.
*
* <p>{@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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
};
}
}
75 changes: 75 additions & 0 deletions src/main/java/com/server/admin/controller/AdminUserController.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading
Loading