feat(admin): 신고(피신고자 관리) + 블랙리스트 + Redis 내성
CI / build (push) Failing after 13m0s
Deploy (dev) / deploy (push) Failing after 15m52s

- V8: reports, blacklist 테이블 + 엔티티/리포지토리
- 신고 접수 POST /api/reports (로그인 사용자)
- 관리자: 피신고자 집계·개별 신고 조회·상태변경(RESOLVED/DISMISSED)
- 블랙리스트: 목록/추가(회원→BANNED+세션무효화, 또는 이메일)/해제, 소셜 재가입 차단(create-branch)
- CheckInRedisStore: Redis 미가동/장애 시 실시간 노출만 생략하고 정상 동작(관리자·로컬은 Redis 불필요)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-11 21:47:38 +09:00
parent 96b415dd09
commit 17a760d62b
21 changed files with 654 additions and 12 deletions
@@ -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<BlacklistResponse> list() {
return blacklistService.list();
}
@PostMapping
public ResponseEntity<Void> add(@RequestBody BlacklistRequest req) {
blacklistService.add(req);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> remove(@PathVariable Long id) {
blacklistService.remove(id);
return ResponseEntity.noContent().build();
}
}
@@ -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<BlacklistResponse> 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);
}
}
@@ -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<ReportedUserSummary> reportedUsers() {
return reportService.reportedUsers();
}
/** 특정 피신고자의 신고 목록. */
@GetMapping
public List<ReportResponse> byUser(@RequestParam Long reportedUserId) {
return reportService.byUser(reportedUserId);
}
@PutMapping("/{id}/status")
public ResponseEntity<Void> updateStatus(@PathVariable Long id,
@Valid @RequestBody ReportStatusRequest req) {
reportService.updateStatus(id, req.status());
return ResponseEntity.noContent().build();
}
}
@@ -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<String> 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<String> reasons = new ArrayList<>();
}
/** 피신고자별 집계 (미처리 많은 순). */
@Transactional(readOnly = true)
public List<ReportedUserSummary> reportedUsers() {
Map<Long, Acc> 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<ReportResponse> 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);
}
}
@@ -0,0 +1,5 @@
package com.dog.dognation.admin.dto;
/** 블랙리스트 추가 — userId(회원 차단) 또는 email(이메일 차단) 중 하나. */
public record BlacklistRequest(Long userId, String email, String reason) {
}
@@ -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) {
}
@@ -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) {
}
@@ -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) {
}
@@ -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<String> reasons) {
}
@@ -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);
}
@@ -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)
@@ -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/**");
@@ -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;
}
}
@@ -0,0 +1,8 @@
package com.dog.dognation.domain.report;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BlacklistRepository extends JpaRepository<Blacklist, Long> {
boolean existsByEmail(String email);
}
@@ -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;
}
}
@@ -0,0 +1,6 @@
package com.dog.dognation.domain.report;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ReportRepository extends JpaRepository<Report, Long> {
}
@@ -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,13 +39,19 @@ public class CheckInRedisStore {
/** 체크인 기록 — dogId 를 현재 시각 점수로 추가하고 키 TTL 을 24h 로 갱신한다. */
public void add(Long spotId, Long dogId) {
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<Long> activeDogIds(Long spotId) {
try {
String k = key(spotId);
double cutoff = Instant.now().minus(TTL).getEpochSecond();
redis.opsForZSet().removeRangeByScore(k, Double.NEGATIVE_INFINITY, cutoff);
@@ -48,5 +61,9 @@ public class CheckInRedisStore {
return List.of();
}
return members.stream().map(Long::valueOf).toList();
} catch (RuntimeException e) {
log.warn("[checkin] Redis 조회 실패(무시): {}", e.toString());
return List.of();
}
}
}
@@ -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<Void> create(
@Valid @RequestBody CreateReportRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
reportService.create(current.id(), req);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
@@ -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()));
}
}
@@ -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) {
}
@@ -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);