feat(admin): 신고(피신고자 관리) + 블랙리스트 + Redis 내성
- 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:
@@ -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,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<Long> activeDogIds(Long spotId) {
|
||||
String k = key(spotId);
|
||||
double cutoff = Instant.now().minus(TTL).getEpochSecond();
|
||||
redis.opsForZSet().removeRangeByScore(k, Double.NEGATIVE_INFINITY, cutoff);
|
||||
Set<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user