From 17a760d62bbd1c642694ea4cf6e46bfd0e20f1f6 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sat, 11 Jul 2026 21:47:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=EC=8B=A0=EA=B3=A0(=ED=94=BC?= =?UTF-8?q?=EC=8B=A0=EA=B3=A0=EC=9E=90=20=EA=B4=80=EB=A6=AC)=20+=20?= =?UTF-8?q?=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20+=20Redis=20?= =?UTF-8?q?=EB=82=B4=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V8: reports, blacklist 테이블 + 엔티티/리포지토리 - 신고 접수 POST /api/reports (로그인 사용자) - 관리자: 피신고자 집계·개별 신고 조회·상태변경(RESOLVED/DISMISSED) - 블랙리스트: 목록/추가(회원→BANNED+세션무효화, 또는 이메일)/해제, 소셜 재가입 차단(create-branch) - CheckInRedisStore: Redis 미가동/장애 시 실시간 노출만 생략하고 정상 동작(관리자·로컬은 Redis 불필요) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/AdminBlacklistController.java | 46 ++++++++ .../admin/AdminBlacklistService.java | 79 ++++++++++++++ .../admin/AdminReportController.java | 49 +++++++++ .../dognation/admin/AdminReportService.java | 102 ++++++++++++++++++ .../dognation/admin/dto/BlacklistRequest.java | 5 + .../admin/dto/BlacklistResponse.java | 13 +++ .../dognation/admin/dto/ReportResponse.java | 14 +++ .../admin/dto/ReportStatusRequest.java | 7 ++ .../admin/dto/ReportedUserSummary.java | 16 +++ .../com/dog/dognation/auth/AuthService.java | 14 ++- .../dog/dognation/config/StatsSeedData.java | 27 +++++ .../com/dog/dognation/config/WebConfig.java | 2 +- .../dognation/domain/report/Blacklist.java | 48 +++++++++ .../domain/report/BlacklistRepository.java | 8 ++ .../dog/dognation/domain/report/Report.java | 81 ++++++++++++++ .../domain/report/ReportRepository.java | 6 ++ .../domain/spot/CheckInRedisStore.java | 37 +++++-- .../dognation/report/ReportController.java | 33 ++++++ .../dog/dognation/report/ReportService.java | 36 +++++++ .../report/dto/CreateReportRequest.java | 13 +++ .../db/migration/V8__reports_blacklist.sql | 30 ++++++ 21 files changed, 654 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/dog/dognation/admin/AdminBlacklistController.java create mode 100644 src/main/java/com/dog/dognation/admin/AdminBlacklistService.java create mode 100644 src/main/java/com/dog/dognation/admin/AdminReportController.java create mode 100644 src/main/java/com/dog/dognation/admin/AdminReportService.java create mode 100644 src/main/java/com/dog/dognation/admin/dto/BlacklistRequest.java create mode 100644 src/main/java/com/dog/dognation/admin/dto/BlacklistResponse.java create mode 100644 src/main/java/com/dog/dognation/admin/dto/ReportResponse.java create mode 100644 src/main/java/com/dog/dognation/admin/dto/ReportStatusRequest.java create mode 100644 src/main/java/com/dog/dognation/admin/dto/ReportedUserSummary.java create mode 100644 src/main/java/com/dog/dognation/domain/report/Blacklist.java create mode 100644 src/main/java/com/dog/dognation/domain/report/BlacklistRepository.java create mode 100644 src/main/java/com/dog/dognation/domain/report/Report.java create mode 100644 src/main/java/com/dog/dognation/domain/report/ReportRepository.java create mode 100644 src/main/java/com/dog/dognation/report/ReportController.java create mode 100644 src/main/java/com/dog/dognation/report/ReportService.java create mode 100644 src/main/java/com/dog/dognation/report/dto/CreateReportRequest.java create mode 100644 src/main/resources/db/migration/V8__reports_blacklist.sql diff --git a/src/main/java/com/dog/dognation/admin/AdminBlacklistController.java b/src/main/java/com/dog/dognation/admin/AdminBlacklistController.java new file mode 100644 index 0000000..ce066af --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/AdminBlacklistController.java @@ -0,0 +1,46 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.BlacklistRequest; +import com.dog.dognation.admin.dto.BlacklistResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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; + +import java.util.List; + +/** + * 관리자 블랙리스트 관리 API. (/api/admin/blacklist — ADMIN 전용) + */ +@RestController +@RequestMapping("/api/admin/blacklist") +public class AdminBlacklistController { + + private final AdminBlacklistService blacklistService; + + public AdminBlacklistController(AdminBlacklistService blacklistService) { + this.blacklistService = blacklistService; + } + + @GetMapping + public List list() { + return blacklistService.list(); + } + + @PostMapping + public ResponseEntity add(@RequestBody BlacklistRequest req) { + blacklistService.add(req); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity remove(@PathVariable Long id) { + blacklistService.remove(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dog/dognation/admin/AdminBlacklistService.java b/src/main/java/com/dog/dognation/admin/AdminBlacklistService.java new file mode 100644 index 0000000..88bd6a9 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/AdminBlacklistService.java @@ -0,0 +1,79 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.BlacklistRequest; +import com.dog.dognation.admin.dto.BlacklistResponse; +import com.dog.dognation.common.exception.ApiException; +import com.dog.dognation.domain.auth.AuthSessionRepository; +import com.dog.dognation.domain.report.Blacklist; +import com.dog.dognation.domain.report.BlacklistRepository; +import com.dog.dognation.domain.user.User; +import com.dog.dognation.domain.user.UserRepository; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * 관리자 블랙리스트 관리. 회원 차단 시 상태를 BANNED 로 바꾸고 세션을 무효화하며, + * 이메일은 소셜 재가입 차단 키로 저장된다. + */ +@Service +public class AdminBlacklistService { + + private final BlacklistRepository blacklistRepository; + private final UserRepository userRepository; + private final AuthSessionRepository sessionRepository; + private final JdbcTemplate jdbc; + + public AdminBlacklistService(BlacklistRepository blacklistRepository, + UserRepository userRepository, + AuthSessionRepository sessionRepository, + JdbcTemplate jdbc) { + this.blacklistRepository = blacklistRepository; + this.userRepository = userRepository; + this.sessionRepository = sessionRepository; + this.jdbc = jdbc; + } + + @Transactional(readOnly = true) + public List list() { + return jdbc.query(""" + SELECT b.id, b.user_id, b.email, u.nickname, b.reason, b.created_at + FROM blacklist b LEFT JOIN users u ON b.user_id = u.id + ORDER BY b.created_at DESC + """, (rs, i) -> new BlacklistResponse( + rs.getLong("id"), + (Long) rs.getObject("user_id"), + rs.getString("email"), + rs.getString("nickname"), + rs.getString("reason"), + rs.getObject("created_at", OffsetDateTime.class))); + } + + /** 회원(userId) 또는 이메일 차단. 회원이면 BANNED + 세션 무효화. */ + @Transactional + public void add(BlacklistRequest req) { + if (req.userId() != null) { + User user = userRepository.findById(req.userId()) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.")); + blacklistRepository.save(new Blacklist(user.getId(), user.getEmail(), req.reason())); + user.setStatus("BANNED"); + sessionRepository.deleteByUserId(user.getId()); + } else if (req.email() != null && !req.email().isBlank()) { + blacklistRepository.save(new Blacklist(null, req.email().trim(), req.reason())); + } else { + throw new ApiException(HttpStatus.BAD_REQUEST, "회원 또는 이메일을 지정하세요."); + } + } + + @Transactional + public void remove(Long id) { + if (!blacklistRepository.existsById(id)) { + throw new ApiException(HttpStatus.NOT_FOUND, "블랙리스트 항목을 찾을 수 없습니다."); + } + blacklistRepository.deleteById(id); + } +} diff --git a/src/main/java/com/dog/dognation/admin/AdminReportController.java b/src/main/java/com/dog/dognation/admin/AdminReportController.java new file mode 100644 index 0000000..ad9c20c --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/AdminReportController.java @@ -0,0 +1,49 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.ReportResponse; +import com.dog.dognation.admin.dto.ReportStatusRequest; +import com.dog.dognation.admin.dto.ReportedUserSummary; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +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; + +import java.util.List; + +/** + * 관리자 신고/피신고자 관리 API. (/api/admin/reports — ADMIN 전용) + */ +@RestController +@RequestMapping("/api/admin/reports") +public class AdminReportController { + + private final AdminReportService reportService; + + public AdminReportController(AdminReportService reportService) { + this.reportService = reportService; + } + + /** 피신고자 집계 목록. */ + @GetMapping("/reported-users") + public List reportedUsers() { + return reportService.reportedUsers(); + } + + /** 특정 피신고자의 신고 목록. */ + @GetMapping + public List byUser(@RequestParam Long reportedUserId) { + return reportService.byUser(reportedUserId); + } + + @PutMapping("/{id}/status") + public ResponseEntity updateStatus(@PathVariable Long id, + @Valid @RequestBody ReportStatusRequest req) { + reportService.updateStatus(id, req.status()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dog/dognation/admin/AdminReportService.java b/src/main/java/com/dog/dognation/admin/AdminReportService.java new file mode 100644 index 0000000..d01290e --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/AdminReportService.java @@ -0,0 +1,102 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.ReportResponse; +import com.dog.dognation.admin.dto.ReportedUserSummary; +import com.dog.dognation.common.exception.ApiException; +import com.dog.dognation.domain.report.Report; +import com.dog.dognation.domain.report.ReportRepository; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 관리자 신고 관리 — 피신고자 집계 + 개별 신고 조회/처리. + */ +@Service +public class AdminReportService { + + private static final Set STATUSES = Set.of("PENDING", "RESOLVED", "DISMISSED"); + + private final JdbcTemplate jdbc; + private final ReportRepository reportRepository; + + public AdminReportService(JdbcTemplate jdbc, ReportRepository reportRepository) { + this.jdbc = jdbc; + this.reportRepository = reportRepository; + } + + /** 미처리 우선 정렬. */ + private static final class Acc { + String email, nickname, status; + long total, pending; + OffsetDateTime last; + final List reasons = new ArrayList<>(); + } + + /** 피신고자별 집계 (미처리 많은 순). */ + @Transactional(readOnly = true) + public List reportedUsers() { + Map map = new LinkedHashMap<>(); + jdbc.query(""" + SELECT r.reported_user_id AS uid, u.email, u.nickname, u.status AS ustatus, + r.reason, r.status AS rstatus, r.created_at + FROM reports r JOIN users u ON r.reported_user_id = u.id + ORDER BY r.created_at DESC + """, rs -> { + long uid = rs.getLong("uid"); + Acc a = map.computeIfAbsent(uid, k -> new Acc()); + a.email = rs.getString("email"); + a.nickname = rs.getString("nickname"); + a.status = rs.getString("ustatus"); + a.total++; + if ("PENDING".equals(rs.getString("rstatus"))) a.pending++; + OffsetDateTime at = rs.getObject("created_at", OffsetDateTime.class); + if (a.last == null || (at != null && at.isAfter(a.last))) a.last = at; + String reason = rs.getString("reason"); + if (!a.reasons.contains(reason)) a.reasons.add(reason); + }); + + return map.entrySet().stream() + .map(e -> new ReportedUserSummary(e.getKey(), e.getValue().email, e.getValue().nickname, + e.getValue().status, e.getValue().total, e.getValue().pending, + e.getValue().last, e.getValue().reasons)) + .sorted((x, y) -> { + int c = Long.compare(y.pendingReports(), x.pendingReports()); + return c != 0 ? c : Long.compare(y.totalReports(), x.totalReports()); + }) + .toList(); + } + + /** 특정 피신고자의 개별 신고 목록. */ + @Transactional(readOnly = true) + public List byUser(Long reportedUserId) { + return jdbc.query(""" + SELECT r.id, ru.nickname AS reporter, r.target_type, r.reason, r.detail, + r.status, r.created_at + FROM reports r LEFT JOIN users ru ON r.reporter_id = ru.id + WHERE r.reported_user_id = ? + ORDER BY r.created_at DESC + """, (rs, i) -> new ReportResponse( + rs.getLong("id"), rs.getString("reporter"), rs.getString("target_type"), + rs.getString("reason"), rs.getString("detail"), rs.getString("status"), + rs.getObject("created_at", OffsetDateTime.class)), reportedUserId); + } + + @Transactional + public void updateStatus(Long reportId, String status) { + if (!STATUSES.contains(status)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "유효하지 않은 상태입니다."); + } + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "신고를 찾을 수 없습니다.")); + report.updateStatus(status); + } +} diff --git a/src/main/java/com/dog/dognation/admin/dto/BlacklistRequest.java b/src/main/java/com/dog/dognation/admin/dto/BlacklistRequest.java new file mode 100644 index 0000000..d14c2d8 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/BlacklistRequest.java @@ -0,0 +1,5 @@ +package com.dog.dognation.admin.dto; + +/** 블랙리스트 추가 — userId(회원 차단) 또는 email(이메일 차단) 중 하나. */ +public record BlacklistRequest(Long userId, String email, String reason) { +} diff --git a/src/main/java/com/dog/dognation/admin/dto/BlacklistResponse.java b/src/main/java/com/dog/dognation/admin/dto/BlacklistResponse.java new file mode 100644 index 0000000..cd2afe9 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/BlacklistResponse.java @@ -0,0 +1,13 @@ +package com.dog.dognation.admin.dto; + +import java.time.OffsetDateTime; + +/** 블랙리스트 항목 (관리자). */ +public record BlacklistResponse( + Long id, + Long userId, + String email, + String nickname, + String reason, + OffsetDateTime createdAt) { +} diff --git a/src/main/java/com/dog/dognation/admin/dto/ReportResponse.java b/src/main/java/com/dog/dognation/admin/dto/ReportResponse.java new file mode 100644 index 0000000..8b53eb3 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/ReportResponse.java @@ -0,0 +1,14 @@ +package com.dog.dognation.admin.dto; + +import java.time.OffsetDateTime; + +/** 개별 신고 항목 (관리자). */ +public record ReportResponse( + Long id, + String reporter, + String targetType, + String reason, + String detail, + String status, + OffsetDateTime createdAt) { +} diff --git a/src/main/java/com/dog/dognation/admin/dto/ReportStatusRequest.java b/src/main/java/com/dog/dognation/admin/dto/ReportStatusRequest.java new file mode 100644 index 0000000..ff64ca9 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/ReportStatusRequest.java @@ -0,0 +1,7 @@ +package com.dog.dognation.admin.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 신고 처리 상태 변경 — RESOLVED / DISMISSED / PENDING. */ +public record ReportStatusRequest(@NotBlank(message = "상태를 입력하세요.") String status) { +} diff --git a/src/main/java/com/dog/dognation/admin/dto/ReportedUserSummary.java b/src/main/java/com/dog/dognation/admin/dto/ReportedUserSummary.java new file mode 100644 index 0000000..3b91ec7 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/ReportedUserSummary.java @@ -0,0 +1,16 @@ +package com.dog.dognation.admin.dto; + +import java.time.OffsetDateTime; +import java.util.List; + +/** 피신고자 요약 — 회원 + 신고 집계. */ +public record ReportedUserSummary( + Long userId, + String email, + String nickname, + String status, + long totalReports, + long pendingReports, + OffsetDateTime lastReportedAt, + List reasons) { +} diff --git a/src/main/java/com/dog/dognation/auth/AuthService.java b/src/main/java/com/dog/dognation/auth/AuthService.java index 1986ed7..c38dd5b 100644 --- a/src/main/java/com/dog/dognation/auth/AuthService.java +++ b/src/main/java/com/dog/dognation/auth/AuthService.java @@ -40,6 +40,7 @@ public class AuthService { private final UserRepository userRepository; private final AuthSessionRepository sessionRepository; private final AppleTokenVerifier appleTokenVerifier; + private final com.dog.dognation.domain.report.BlacklistRepository blacklistRepository; private final RestClient restClient = RestClient.create(); /** 구글 OAuth 클라이언트 ID (ID 토큰 aud 검증용). 미설정 시 구글 로그인 비활성. */ @@ -52,10 +53,19 @@ public class AuthService { public AuthService(UserRepository userRepository, AuthSessionRepository sessionRepository, - AppleTokenVerifier appleTokenVerifier) { + AppleTokenVerifier appleTokenVerifier, + com.dog.dognation.domain.report.BlacklistRepository blacklistRepository) { this.userRepository = userRepository; this.sessionRepository = sessionRepository; this.appleTokenVerifier = appleTokenVerifier; + this.blacklistRepository = blacklistRepository; + } + + /** 블랙리스트(이메일) 재가입 차단. */ + private void ensureNotBlacklisted(String email) { + if (email != null && !email.isBlank() && blacklistRepository.existsByEmail(email)) { + throw new ApiException(HttpStatus.FORBIDDEN, "차단된 계정입니다."); + } } public String googleClientId() { @@ -106,6 +116,7 @@ public class AuthService { } if (user == null) { + ensureNotBlacklisted(email); user = userRepository.save(User.createSocial("GOOGLE", sub, email, name, picture)); log.info("[google] new user id={} email={}", user.getId(), email); } @@ -172,6 +183,7 @@ public class AuthService { } if (user == null) { + ensureNotBlacklisted(email); user = userRepository.save(User.createSocial("APPLE", sub, email, name, null)); log.info("[apple] new user id={} email={}", user.getId(), email); } diff --git a/src/main/java/com/dog/dognation/config/StatsSeedData.java b/src/main/java/com/dog/dognation/config/StatsSeedData.java index fe7d7a4..8079f8b 100644 --- a/src/main/java/com/dog/dognation/config/StatsSeedData.java +++ b/src/main/java/com/dog/dognation/config/StatsSeedData.java @@ -77,10 +77,37 @@ public class StatsSeedData implements CommandLineRunner { insertWithdrawal("left4@dog.com", "AD_FREE", now.minusDays(4)); insertWithdrawal("left5@dog.com", "FREE", now.minusDays(6)); + // 신고 (피신고자: visitor4, visitor5, visitor3) + insertReport(users.get(0), users.get(3), "SPAM", "도배성 매칭 신청 반복", "PENDING"); + insertReport(users.get(1), users.get(3), "ABUSE", "채팅에서 욕설", "PENDING"); + insertReport(users.get(2), users.get(3), "INAPPROPRIATE", "부적절한 프로필 사진", "PENDING"); + insertReport(users.get(0), users.get(4), "FRAUD", "허위 정보", "PENDING"); + insertReport(users.get(2), users.get(4), "ABUSE", "비방", "RESOLVED"); + insertReport(users.get(5), users.get(2), "ETC", "기타", "DISMISSED"); + + // 블랙리스트 (이메일 재가입 차단) + insertBlacklist("banned1@dog.com", "스팸 반복으로 영구 차단"); + insertBlacklist("banned2@dog.com", "결제 사기"); + System.out.printf("[stats seed] 스팟 %d, 방문자 %d, 체크인 %d 준비 완료%n", spots.length, users.size(), total); } + private void insertReport(long reporterId, long reportedUserId, String reason, String detail, String status) { + OffsetDateTime resolvedAt = "PENDING".equals(status) ? null : OffsetDateTime.now(); + jdbc.update(""" + INSERT INTO reports(reporter_id, reported_user_id, target_type, reason, detail, status, created_at, resolved_at) + VALUES (?, ?, 'USER', ?, ?, ?, CURRENT_TIMESTAMP, ?) + """, reporterId, reportedUserId, reason, detail, status, resolvedAt); + } + + private void insertBlacklist(String email, String reason) { + jdbc.update(""" + INSERT INTO blacklist(user_id, email, reason, created_at) + VALUES (NULL, ?, ?, CURRENT_TIMESTAMP) + """, email, reason); + } + private long insertUser(String email, String nickname, String tier) { jdbc.update(""" INSERT INTO users(email, nickname, subscription_tier, status, role, provider, created_at, updated_at) diff --git a/src/main/java/com/dog/dognation/config/WebConfig.java b/src/main/java/com/dog/dognation/config/WebConfig.java index a7ac358..c25b7a1 100644 --- a/src/main/java/com/dog/dognation/config/WebConfig.java +++ b/src/main/java/com/dog/dognation/config/WebConfig.java @@ -42,7 +42,7 @@ public class WebConfig implements WebMvcConfigurer { // 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행) registry.addInterceptor(authInterceptor) .addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/admin/**", - "/api/dogs/**", "/api/chat/**"); + "/api/dogs/**", "/api/chat/**", "/api/reports"); // 인가: 관리자 전용 (authInterceptor 다음에 실행되어 role 검사) registry.addInterceptor(adminInterceptor) .addPathPatterns("/api/admin/**"); diff --git a/src/main/java/com/dog/dognation/domain/report/Blacklist.java b/src/main/java/com/dog/dognation/domain/report/Blacklist.java new file mode 100644 index 0000000..3b24fb8 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/report/Blacklist.java @@ -0,0 +1,48 @@ +package com.dog.dognation.domain.report; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** + * 블랙리스트. 차단 회원/이메일 — 소셜 재가입 차단 및 관리자 조회용. + */ +@Entity +@Table(name = "blacklist") +public class Blacklist { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(length = 255) + private String email; + + @Column(length = 200) + private String reason; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + protected Blacklist() { + } + + public Blacklist(Long userId, String email, String reason) { + this.userId = userId; + this.email = email; + this.reason = reason; + this.createdAt = OffsetDateTime.now(); + } + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/dog/dognation/domain/report/BlacklistRepository.java b/src/main/java/com/dog/dognation/domain/report/BlacklistRepository.java new file mode 100644 index 0000000..a96a7bc --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/report/BlacklistRepository.java @@ -0,0 +1,8 @@ +package com.dog.dognation.domain.report; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BlacklistRepository extends JpaRepository { + + boolean existsByEmail(String email); +} diff --git a/src/main/java/com/dog/dognation/domain/report/Report.java b/src/main/java/com/dog/dognation/domain/report/Report.java new file mode 100644 index 0000000..4f082b1 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/report/Report.java @@ -0,0 +1,81 @@ +package com.dog.dognation.domain.report; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.OffsetDateTime; + +/** + * 신고. 피신고자(reported_user_id) 기준으로 관리자가 관리한다. + */ +@Entity +@Table(name = "reports") +public class Report { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "reporter_id") + private Long reporterId; // 신고자(탈퇴 시 null) + + @Column(name = "reported_user_id", nullable = false) + private Long reportedUserId; // 피신고자 + + @Column(name = "target_type", nullable = false, length = 20) + private String targetType = "USER"; // USER / REVIEW / CHAT / DOG + + @Column(name = "target_id") + private Long targetId; + + @Column(nullable = false, length = 30) + private String reason; // SPAM / ABUSE / INAPPROPRIATE / FRAUD / ETC + + @Column(length = 500) + private String detail; + + @Column(nullable = false, length = 20) + private String status = "PENDING"; // PENDING / RESOLVED / DISMISSED + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Column(name = "resolved_at") + private OffsetDateTime resolvedAt; + + protected Report() { + } + + public Report(Long reporterId, Long reportedUserId, String targetType, Long targetId, + String reason, String detail) { + this.reporterId = reporterId; + this.reportedUserId = reportedUserId; + this.targetType = targetType == null ? "USER" : targetType; + this.targetId = targetId; + this.reason = reason; + this.detail = detail; + this.status = "PENDING"; + this.createdAt = OffsetDateTime.now(); + } + + public void updateStatus(String status) { + this.status = status; + this.resolvedAt = "PENDING".equals(status) ? null : OffsetDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getReportedUserId() { + return reportedUserId; + } + + public String getStatus() { + return status; + } +} diff --git a/src/main/java/com/dog/dognation/domain/report/ReportRepository.java b/src/main/java/com/dog/dognation/domain/report/ReportRepository.java new file mode 100644 index 0000000..e604845 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/report/ReportRepository.java @@ -0,0 +1,6 @@ +package com.dog.dognation.domain.report; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { +} diff --git a/src/main/java/com/dog/dognation/domain/spot/CheckInRedisStore.java b/src/main/java/com/dog/dognation/domain/spot/CheckInRedisStore.java index af3abd8..30e04fc 100644 --- a/src/main/java/com/dog/dognation/domain/spot/CheckInRedisStore.java +++ b/src/main/java/com/dog/dognation/domain/spot/CheckInRedisStore.java @@ -1,5 +1,7 @@ package com.dog.dognation.domain.spot; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; @@ -13,10 +15,15 @@ import java.util.Set; * 스팟별 ZSet(member=dogId, score=체크인 epoch초)으로 관리한다. * - 조회 시 24시간이 지난 항목은 정리하고, 키에도 24h TTL 을 걸어 유휴 시 자동 삭제. * - 영구 통계는 PostgreSQL check_ins 테이블이 담당(이 저장소와 별개). + * + * Redis 가 없거나 장애일 때는 실시간 노출만 비활성되고 나머지 기능은 정상 동작한다 + * (예: local 프로파일에서 Redis 없이 기동). 관리자 기능은 Redis 에 의존하지 않는다. */ @Component public class CheckInRedisStore { + private static final Logger log = LoggerFactory.getLogger(CheckInRedisStore.class); + /** 실시간 체크인 유효시간. 이 시간이 지나면 활성 메이트에서 비노출. */ static final Duration TTL = Duration.ofHours(24); @@ -32,21 +39,31 @@ public class CheckInRedisStore { /** 체크인 기록 — dogId 를 현재 시각 점수로 추가하고 키 TTL 을 24h 로 갱신한다. */ public void add(Long spotId, Long dogId) { - String k = key(spotId); - redis.opsForZSet().add(k, String.valueOf(dogId), Instant.now().getEpochSecond()); - redis.expire(k, TTL); + try { + String k = key(spotId); + redis.opsForZSet().add(k, String.valueOf(dogId), Instant.now().getEpochSecond()); + redis.expire(k, TTL); + } catch (RuntimeException e) { + // Redis 장애/미가동 → 실시간 노출만 생략(영구 통계는 DB가 담당) + log.warn("[checkin] Redis 기록 실패(무시): {}", e.toString()); + } } /** 최근 24시간 내 체크인한 dogId 목록(최신순). 만료 항목은 조회 시 정리. */ public List activeDogIds(Long spotId) { - String k = key(spotId); - double cutoff = Instant.now().minus(TTL).getEpochSecond(); - redis.opsForZSet().removeRangeByScore(k, Double.NEGATIVE_INFINITY, cutoff); - Set members = redis.opsForZSet() - .reverseRangeByScore(k, cutoff, Double.POSITIVE_INFINITY); - if (members == null || members.isEmpty()) { + try { + String k = key(spotId); + double cutoff = Instant.now().minus(TTL).getEpochSecond(); + redis.opsForZSet().removeRangeByScore(k, Double.NEGATIVE_INFINITY, cutoff); + Set members = redis.opsForZSet() + .reverseRangeByScore(k, cutoff, Double.POSITIVE_INFINITY); + if (members == null || members.isEmpty()) { + return List.of(); + } + return members.stream().map(Long::valueOf).toList(); + } catch (RuntimeException e) { + log.warn("[checkin] Redis 조회 실패(무시): {}", e.toString()); return List.of(); } - return members.stream().map(Long::valueOf).toList(); } } \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/report/ReportController.java b/src/main/java/com/dog/dognation/report/ReportController.java new file mode 100644 index 0000000..a9c80e7 --- /dev/null +++ b/src/main/java/com/dog/dognation/report/ReportController.java @@ -0,0 +1,33 @@ +package com.dog.dognation.report; + +import com.dog.dognation.auth.SessionUser; +import com.dog.dognation.auth.web.AuthInterceptor; +import com.dog.dognation.report.dto.CreateReportRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 신고 접수 API (로그인 사용자). */ +@RestController +@RequestMapping("/api/reports") +public class ReportController { + + private final ReportService reportService; + + public ReportController(ReportService reportService) { + this.reportService = reportService; + } + + @PostMapping + public ResponseEntity create( + @Valid @RequestBody CreateReportRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + reportService.create(current.id(), req); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/src/main/java/com/dog/dognation/report/ReportService.java b/src/main/java/com/dog/dognation/report/ReportService.java new file mode 100644 index 0000000..1c45282 --- /dev/null +++ b/src/main/java/com/dog/dognation/report/ReportService.java @@ -0,0 +1,36 @@ +package com.dog.dognation.report; + +import com.dog.dognation.common.exception.ApiException; +import com.dog.dognation.domain.report.Report; +import com.dog.dognation.domain.report.ReportRepository; +import com.dog.dognation.domain.user.UserRepository; +import com.dog.dognation.report.dto.CreateReportRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** 신고 접수 (사용자). */ +@Service +public class ReportService { + + private final ReportRepository reportRepository; + private final UserRepository userRepository; + + public ReportService(ReportRepository reportRepository, UserRepository userRepository) { + this.reportRepository = reportRepository; + this.userRepository = userRepository; + } + + @Transactional + public void create(Long reporterId, CreateReportRequest req) { + if (reporterId != null && reporterId.equals(req.reportedUserId())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "본인은 신고할 수 없습니다."); + } + if (!userRepository.existsById(req.reportedUserId())) { + throw new ApiException(HttpStatus.NOT_FOUND, "신고 대상 회원을 찾을 수 없습니다."); + } + reportRepository.save(new Report( + reporterId, req.reportedUserId(), req.targetType(), req.targetId(), + req.reason(), req.detail())); + } +} diff --git a/src/main/java/com/dog/dognation/report/dto/CreateReportRequest.java b/src/main/java/com/dog/dognation/report/dto/CreateReportRequest.java new file mode 100644 index 0000000..88060f5 --- /dev/null +++ b/src/main/java/com/dog/dognation/report/dto/CreateReportRequest.java @@ -0,0 +1,13 @@ +package com.dog.dognation.report.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** 신고 접수 요청 (신고자는 세션에서). */ +public record CreateReportRequest( + @NotNull(message = "피신고자를 지정하세요.") Long reportedUserId, + String targetType, + Long targetId, + @NotBlank(message = "신고 사유를 입력하세요.") String reason, + String detail) { +} diff --git a/src/main/resources/db/migration/V8__reports_blacklist.sql b/src/main/resources/db/migration/V8__reports_blacklist.sql new file mode 100644 index 0000000..58ac8e4 --- /dev/null +++ b/src/main/resources/db/migration/V8__reports_blacklist.sql @@ -0,0 +1,30 @@ +-- ============================================================ +-- V8: 신고(피신고자 관리) + 블랙리스트 +-- ============================================================ + +-- 신고 +CREATE TABLE reports ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + reporter_id BIGINT REFERENCES users(id) ON DELETE SET NULL, -- 신고자(탈퇴 시 NULL) + reported_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- 피신고자 + target_type VARCHAR(20) NOT NULL DEFAULT 'USER' + CHECK (target_type IN ('USER', 'REVIEW', 'CHAT', 'DOG')), + target_id BIGINT, -- 대상 콘텐츠 id(선택) + reason VARCHAR(30) NOT NULL, -- SPAM/ABUSE/INAPPROPRIATE/FRAUD/ETC + detail VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'RESOLVED', 'DISMISSED')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ +); +CREATE INDEX idx_reports_reported ON reports(reported_user_id, status); + +-- 블랙리스트 (차단 회원/이메일). 재가입 차단 및 관리자 조회용. +CREATE TABLE blacklist ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT, -- 삭제될 수 있어 FK 없음 + email VARCHAR(255), -- 소셜 재가입 차단 키 + reason VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_blacklist_email ON blacklist(email);