Skip to content

feat(admin): 관리자 기능 — 신고 처리, 사용자 관리, 장소 데이터, 통계 - #83

Merged
SD-gif merged 1 commit into
mainfrom
feat/admin-features
Aug 25, 2026
Merged

feat(admin): 관리자 기능 — 신고 처리, 사용자 관리, 장소 데이터, 통계#83
SD-gif merged 1 commit into
mainfrom
feat/admin-features

Conversation

@SD-gif

@SD-gif SD-gif commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

관리자 기능 네 가지다. 모두 /api/v1/admin 아래이며 hasRole('ADMIN') 으로 막힌다.
사용자 API 인가는 아직 켜지 않았다.

엔드포인트

영역 Endpoint
신고 GET/PATCH /admin/reports, GET /admin/reports/{id}, DELETE /admin/posts|comments/{id}
사용자 GET /admin/users, GET /admin/users/{id}, PATCH /admin/users/{id}/status
장소 GET/POST /admin/places/ingestion, GET /admin/places/hidden, PATCH /admin/places/{id}/hidden
통계 GET /admin/stats/summary|trend|popular

정지가 아무것도 막지 않고 있었다

V9 에서 suspend()isWriteBlockedAt() 을 만들어 두었는데 확인하는 곳이 한 군데도
없었다.
관리자가 정지시켜도 그 사용자는 계속 글을 쓴다. SuspendedUserWriteFilter 로 막는다.

쓰기 요청에서만 사용자를 조회한다. 액세스 토큰에 정지 여부가 없어 매 요청 확인하면
모든 조회에 질의가 하나씩 늘어난다. 읽기를 막지 않는 것은 의도이기도 하다. 정지된
사용자가 자기 상태를 확인할 수는 있어야 한다. /api/v1/auth 도 열어 둔다.

토큰 폐기 실패는 정지를 되돌리지 않는다. Redis 가 죽었다고 악성 사용자를 정지시키지
못하면 곤란하다. 게다가 Redis 가 없으면 갱신 자체가 실패하므로 폐기하지 못한 토큰으로
세션을 이어갈 수도 없다.

판단한 것

신고 상세에 대상 원본을 함께 준다. 신고 사유만 보고는 조치할 수 없다. 이미 지워진
대상도 삭제 표시와 함께 보여준다. 감추면 관리자가 사라진 대상을 계속 찾는다.

오래된 신고부터 반환한다. 최신순이면 방치된 신고가 계속 뒤로 밀린다.

관리자 삭제도 소프트 삭제다. 작성자 확인만 건너뛰고 해시태그 정리·댓글 수 감소는
본인 삭제와 같다. 다르게 처리하면 관리자가 지운 것만 다른 상태로 남아 복구·정리 경로가
갈라진다.

관리자는 정지할 수 없다. 서로 정지시키면 아무도 풀 수 없는 상태가 되고, 그때 남는
수단은 DB 를 직접 고치는 것뿐이다.

장소는 지우지 않고 가린다. 행을 지우면 TourAPI 증분 동기화가 다음 실행에서 같은 장소를
다시 만든다. 검색과 상세 양쪽에서 뺀다. 검색에서만 빼고 상세를 열어 두면 예전 링크로
그대로 들어온다.

예산이 없으면 수동 적재를 시작하지 않는다. 시작해 봐야 예약 단계에서 막혀 아무것도
하지 못하고, 관리자는 왜 안 되는지 알 수 없다.

통계는 값이 0 인 날도 채운다. DB 는 행이 없는 날을 돌려주지 않아, 그대로 두면 화면이
빈 날을 건너뛰어 그래프가 실제보다 완만해 보인다.

인기 해시태그는 hashtags.post_count 대신 실제 연결을 센다. 집계 컬럼은 어긋날 수 있고
삭제된 게시물의 몫이 남아 있을 수 있다. (#78 리뷰에서 지적한 그 문제다)

정리 스케줄러를 배포에서 켠다

COMMUNITY_POST_PURGE_ENABLED 가 기본 false 인데 배포 워크플로에 없어, #78 로 만든
정리 스케줄러가 배포돼도 한 번도 돌지 않는 상태였다. 지운 게시물이 영원히 쌓이고
사용자가 지웠다고 믿는 본문과 사진 URL 이 DB 에 남는다.

스키마

  • V12reports.handled_by, handled_at + idx_reports_status_created_at
  • V13places.hidden_at, hidden_reason + 부분 인덱스

data 저장소와 함께 봐야 한다

가린 장소를 일정 후보에서 빼는 것은 data#38 이다.
일정 후보는 FastAPI 가 places 를 직접 읽어 뽑으므로 Spring 만으로는 완결되지 않는다.

이 PR 을 먼저 배포해야 한다. V13 이 hidden_at 을 만든다. 순서가 바뀌면 없는 컬럼을
참조해 후보 조회가 전부 실패한다.

검증

./gradlew test267건 통과 (기존 232 + 신규 35)

  • 정지된 계정의 쓰기 403, 읽기 200, /auth 통과, 해제 후 복구
  • 관리자 API 전반 인가 — 토큰 없음 401 / 일반 사용자 403 / 관리자 200
  • 숨긴 장소가 검색·상세에서 빠지고 해제하면 돌아옴
  • 예산 소진 시 수동 적재 429 (Testcontainers PostgreSQL)
  • 통계 집계 — 삭제 제외, 빈 날 0 채움, 날짜 연속성

Testcontainers 로 실제 PostgreSQL 에 V12·V13 적용과 JPA 스키마 검증을 확인했다.

영향

  • API 추가 (/api/v1/admin/**)
  • DB·ERD 변경 (V12, V13)
  • 기존 API 계약 변경 없음
  • 파괴적 변경 없음

🤖 Generated with Claude Code

신고 처리, 사용자 관리, 장소 데이터 관리, 통계다. 모두 /api/v1/admin 아래에 있고
SecurityConfig 가 hasRole('ADMIN') 으로 막는다. 사용자 API 인가는 아직 켜지 않았다.

정지가 아무것도 막지 않고 있었다
  V9 에서 suspend() 와 isWriteBlockedAt() 을 만들어 두었지만 확인하는 곳이 없었다.
  관리자가 정지시켜도 그 사용자는 계속 글을 썼다. SuspendedUserWriteFilter 로 막는다.
  쓰기 요청에서만 사용자를 조회한다. 액세스 토큰에 정지 여부가 없어 매 요청 확인하면
  모든 조회에 질의가 하나씩 늘어난다. 읽기를 막지 않는 것은 의도이기도 하다. 정지된
  사용자가 자기 상태를 확인할 수는 있어야 한다. /api/v1/auth 도 열어 둔다.

  토큰 폐기 실패는 정지를 되돌리지 않는다. Redis 가 죽었다고 악성 사용자를 정지시키지
  못하면 곤란하다. 게다가 Redis 가 없으면 갱신 자체가 실패하므로 폐기하지 못한 토큰으로
  세션을 이어갈 수도 없다.

신고 처리
  상세에 대상 원본을 함께 준다. 신고 사유만 보고는 조치할 수 없다. 이미 지워진 대상도
  삭제 표시와 함께 보여준다. 감추면 관리자가 사라진 대상을 계속 찾는다.
  오래된 신고부터 반환한다. 최신순이면 방치된 신고가 계속 뒤로 밀린다.
  종결한 신고를 되돌릴 수 있다. 못 되돌리면 잘못 종결한 건을 다루려고 같은 내용의 새
  신고를 기다려야 한다.
  ReportStatus 에 REVIEWING 과 REJECTED 를 더한다. 확인만 하고 조치하지 않은 것과 아직
  아무도 보지 않은 것을 구분하지 못하면 대기 목록이 같은 항목으로 계속 채워진다.
  관리자 삭제도 소프트 삭제다. 작성자 확인만 건너뛰고 해시태그 정리와 댓글 수 감소는
  본인 삭제와 같다. 다르게 처리하면 관리자가 지운 것만 다른 상태로 남아 복구·정리
  경로가 갈라진다.

사용자 관리
  관리자는 정지할 수 없다. 서로 정지시키면 아무도 풀 수 없는 상태가 되고, 그때 남는
  수단은 DB 를 직접 고치는 것뿐이다.
  받은 신고 수는 사용자 신고뿐 아니라 그가 쓴 게시물·댓글 신고도 함께 센다. 사용자
  신고만 세면 문제 글을 반복해 올리는 계정이 깨끗해 보인다.

장소 데이터 관리
  지우지 않고 가린다. 행을 지우면 TourAPI 증분 동기화가 다음 실행에서 같은 장소를 다시
  만든다. 검색과 상세 조회 양쪽에서 뺀다. 검색에서만 빼고 상세를 열어 두면 예전 링크로
  그대로 들어온다.
  예산이 없으면 수동 적재를 시작하지 않는다. 시작해 봐야 예약 단계에서 막혀 아무것도
  하지 못하고, 관리자는 왜 안 되는지 알 수 없다.
  적재 트리거에는 트랜잭션을 걸지 않는다. TourAPI 호출이 길게 이어진다.

통계
  실시간 집계다. 집계 테이블을 두면 그것을 채우는 배치와 어긋남을 관리해야 한다.
  지표 이름을 SQL 에 그대로 넣지 않는다. StatsMetric 이 허용된 테이블만 담는다.
  값이 0 인 날도 채운다. DB 는 행이 없는 날을 돌려주지 않아, 그대로 두면 화면이 빈 날을
  건너뛰어 그래프가 실제보다 완만해 보인다.
  인기 장소는 게시물 태그 횟수로 센다. 일정에 담긴 횟수를 쓰면 Planner 가 고른 것이
  섞여 사용자가 고른 것과 구분되지 않는다.
  인기 해시태그는 hashtags.post_count 대신 실제 연결을 센다. 집계 컬럼은 어긋날 수 있고
  삭제된 게시물의 몫이 남아 있을 수 있다.

정리 스케줄러를 배포에서 켠다
  COMMUNITY_POST_PURGE_ENABLED 가 기본 false 인데 배포 워크플로에 없어, 지운 게시물이
  영원히 쌓이고 본문과 사진 URL 이 DB 에 남는 상태였다.

스키마
  V12 는 reports 에 handled_by 와 handled_at 을 더한다.
  V13 은 places 에 hidden_at 과 hidden_reason 을 더한다. 일정 후보에서 빼는 것은 FastAPI
  가 places 를 직접 읽으므로 data 저장소에서 함께 처리한다(SMBDHTeam/data#38).

전체 267건 통과. Testcontainers 로 실제 PostgreSQL 에 V12·V13 적용을 확인했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🤖 Gemini PR Review

⚠️ PR 변경량이 커서 Diff 일부만 분석했습니다.
리뷰되지 않은 파일이나 코드가 존재할 수 있으니 사람이 추가로 확인해야 합니다.

1. [HIGH] 인증 누락 시 부적절한 예외 처리 및 보안 흐름 취약점

  • 파일: src/main/java/com/server/admin/controller/AdminReportController.java
  • 라인: 89
  • 문제점:
    AdminReportController.updateStatus 메서드에서 처리 관리자의 ID를 식별하기 위해 CurrentUser.idOrNull()을 호출하여 서비스 레이어로 전달하고 있습니다.
    만약 세션 만료, 인증 필터 예외, 또는 테스트 환경에서의 설정 누락 등으로 인해 인증 정보가 없는 상태에서 요청이 도달하면 CurrentUser.idOrNull()null을 반환합니다. 이 null 값이 AdminReportService.updateStatus로 전달되면 userRepository.findByIdAndDeletedAtIsNull(null)이 호출되어 결국 USER_NOT_FOUND 예외가 발생하게 됩니다.
    인증되지 않은 사용자의 요청을 "존재하지 않는 사용자" 예외로 처리하는 것은 올바르지 못한 보안 흐름이며, 실제 인증 문제인지 데이터 존재 유무의 문제인지 디버깅하기 어렵게 만듭니다. 이 경우 명확하게 인증 실패(UNAUTHORIZED, 401) 예외가 발생해야 합니다.
  • 개선 제안:
    인증 정보가 필수적인 관리자 기능이므로, null을 허용하는 idOrNull() 대신 인증되지 않았을 때 즉시 예외를 던지는 메서드(예: CurrentUser.id() 또는 CurrentUser.getRequiredId())를 사용하거나, 컨트롤러 진입 시점에 null 체크를 수행하여 명시적인 인증 예외를 던져야 합니다.
  • 개선 예시:
// AdminReportController.java
@PatchMapping("/reports/{reportId}")
public AdminReportResponse updateStatus(
        @Parameter(example = "12") @PathVariable Long reportId,
        @Valid @RequestBody ReportStatusUpdateRequest request
) {
    Long adminId = CurrentUser.idOrNull();
    if (adminId == null) {
        throw new BusinessException(ErrorCode.UNAUTHORIZED); // 또는 적절한 인증 예외
    }
    return adminReportService.updateStatus(reportId, request.status(), adminId);
}

2. [MEDIUM] JDBC 드라이버 호환성에 따른 런타임 오류 가능성

  • 파일: src/main/java/com/server/admin/service/AdminStatsService.java
  • 라인: 68
  • 문제점:
    AdminStatsService.getTrend 메서드 내에서 jdbcTemplate.query를 사용할 때 rs.getObject("stat_date", LocalDate.class)를 통해 데이터베이스의 DATE 타입을 LocalDate로 직접 변환하고 있습니다.
    이 방식은 최신 PostgreSQL 드라이버에서는 정상 작동할 수 있으나, 로컬 개발 및 통합 테스트 환경에서 주로 사용하는 H2 데이터베이스나 특정 구버전 JDBC 드라이버 환경에서는 LocalDate로의 직접 변환을 지원하지 않아 SQLFeatureNotSupportedException 또는 Type mismatch 예외를 발생시키며 통계 조회 기능이 실패할 수 있습니다.
  • 개선 제안:
    다양한 데이터베이스 환경 및 드라이버 버전에서의 호환성을 보장하기 위해, rs.getDate()를 통해 java.sql.Date 객체로 안전하게 값을 가져온 후 toLocalDate()를 호출하여 변환하는 방식을 권장합니다.
  • 개선 예시:
// AdminStatsService.java
Map<LocalDate, Long> byDay = new HashMap<>();
jdbcTemplate.query(sql, rs -> {
    java.sql.Date sqlDate = rs.getDate("stat_date");
    if (sqlDate != null) {
        byDay.put(sqlDate.toLocalDate(), rs.getLong("stat_count"));
    }
}, from.atStartOfDay());

Model: `gemini-3.5-flash` · API key: `PRIMARY` · Commit: `68e029c`

@SD-gif
SD-gif merged commit e66e5ab into main Aug 25, 2026
2 checks passed
@SD-gif
SD-gif deleted the feat/admin-features branch August 25, 2026 07:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant