diff --git a/src/main/java/com/ruleup/ruleup_backend/admin/controller/AdminController.java b/src/main/java/com/ruleup/ruleup_backend/admin/controller/AdminController.java index c047ab99..7bd58945 100644 --- a/src/main/java/com/ruleup/ruleup_backend/admin/controller/AdminController.java +++ b/src/main/java/com/ruleup/ruleup_backend/admin/controller/AdminController.java @@ -1,7 +1,9 @@ package com.ruleup.ruleup_backend.admin.controller; +import com.ruleup.ruleup_backend.admin.dto.AdminAppealDtos; import com.ruleup.ruleup_backend.admin.dto.AdminDtos; import com.ruleup.ruleup_backend.admin.dto.AdminInquiryDtos; +import com.ruleup.ruleup_backend.admin.service.AdminAppealService; import com.ruleup.ruleup_backend.admin.service.AdminDashboardService; import com.ruleup.ruleup_backend.admin.service.AdminInquiryService; import com.ruleup.ruleup_backend.admin.service.AdminOpsService; @@ -33,7 +35,7 @@ * 2단계 확인은 형태와 무관하게 필수라 서버가 소유한다. 경로 prefix 전체에 인터셉터가 걸려 * 있어 엔드포인트를 추가해도 권한 검사를 빠뜨릴 자리가 없다. */ -@Tag(name = "Admin", description = "운영자 백오피스 — 대시보드 · 일별 지표 · 신고 · 제재 · CS · 직권 폐쇄 · 이상탐지 · 공지") +@Tag(name = "Admin", description = "운영자 백오피스 — 대시보드 · 일별 지표 · 신고 · 제재 · CS · 이의 · 직권 폐쇄 · 이상탐지 · 공지") @SecurityRequirement(name = "bearerAuth") @RestController @RequestMapping("/api/v1/admin") @@ -46,6 +48,7 @@ public class AdminController { private final AdminInquiryService inquiryService; private final AdminDashboardService dashboardService; private final AdminStatsService statsService; + private final AdminAppealService appealService; // ===== 대시보드 ===== @@ -341,6 +344,47 @@ public ApiResponse reclassifyInquiry( return ApiResponse.ok(inquiryService.reclassify(UUID.fromString(userId), inquiryId, request)); } + // ===== 이의 ===== + + @Operation(summary = "이의 목록", description = """ + **최신순**이다. 이의는 접수 = 인용이라 상태(계류·기각)가 없어 상태 필터를 두지 않는다. + + - `from`·`to`: 접수일(KST, `yyyy-MM-dd`), 양끝 포함 + - `userId`·`challengeId`: 회원·방 + - `hasImage`: true 사진 있음 / false 없음 / 생략 전부 + - `failureReason` 은 접수 당시 실패 사유 코드다. 2026-10-29 이전 접수는 null 이다. + """) + @ApiErrorCodes({ErrorCode.INVALID_REQUEST, ErrorCode.ADMIN_FORBIDDEN, ErrorCode.LOGIN_REQUIRED}) + @GetMapping("/appeals") + public ApiResponse appeals( + @AuthenticationPrincipal String userId, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to, + @RequestParam(name = "userId", required = false) String memberId, + @RequestParam(required = false) String challengeId, + @RequestParam(required = false) Boolean hasImage, + @RequestParam(required = false) String cursor, + @RequestParam(required = false) Integer size) { + return ApiResponse.ok(appealService.queue(UUID.fromString(userId), from, to, memberId, + challengeId, hasImage, cursor, size)); + } + + @Operation(summary = "이의 상세", description = """ + 사유 전문 · 증빙 사진(단기 접근 주소와 만료 시각) · **이의 전 실패 설명**(사유 코드·근거·기준값·실제값) · + 같은 회원의 최근 30일 이의 수. + + 실패 설명은 유저가 신청 화면에서 본 값을 접수 때 고정해 둔 것이다 — 인용하면 판정의 실패 사유가 지워지기 때문이다. + 2026-10-29 이전 접수는 `failure` 가 null 이다. + + 상세 열람은 **개인정보 열람**이라 감사 로그에 `APPEAL_VIEW` 로 따로 남는다. + """) + @ApiErrorCodes({ErrorCode.APPEAL_NOT_FOUND, ErrorCode.ADMIN_FORBIDDEN, ErrorCode.LOGIN_REQUIRED}) + @GetMapping("/appeals/{appealId}") + public ApiResponse appeal(@AuthenticationPrincipal String userId, + @PathVariable UUID appealId) { + return ApiResponse.ok(appealService.detail(UUID.fromString(userId), appealId)); + } + // ===== 이상탐지 ===== @Operation(summary = "이상탐지 신호", description = """ diff --git a/src/main/java/com/ruleup/ruleup_backend/admin/domain/AdminAction.java b/src/main/java/com/ruleup/ruleup_backend/admin/domain/AdminAction.java index 9829affb..6800df35 100644 --- a/src/main/java/com/ruleup/ruleup_backend/admin/domain/AdminAction.java +++ b/src/main/java/com/ruleup/ruleup_backend/admin/domain/AdminAction.java @@ -35,6 +35,11 @@ public enum AdminAction { /** 분류 변경 — 유저에게 노출하지 않는 조작이라 기록이 유일한 흔적이다. */ INQUIRY_RECLASSIFY, + // ===== 이의 ===== + APPEAL_QUEUE_VIEW, + /** 이의 상세 열람 — 사유 원문과 증빙 사진이 담긴 개인정보 열람이라 목록 조회와 섞지 않는다. */ + APPEAL_VIEW, + // ===== 조회 전용 ===== /** 대시보드 — 가드레일 지표를 본 시각이 남아야 "언제부터 알고 있었나"를 답할 수 있다. */ DASHBOARD_VIEW, diff --git a/src/main/java/com/ruleup/ruleup_backend/admin/domain/AdminAuditLog.java b/src/main/java/com/ruleup/ruleup_backend/admin/domain/AdminAuditLog.java index 151c5510..786f9122 100644 --- a/src/main/java/com/ruleup/ruleup_backend/admin/domain/AdminAuditLog.java +++ b/src/main/java/com/ruleup/ruleup_backend/admin/domain/AdminAuditLog.java @@ -33,7 +33,7 @@ public enum Result { ALLOWED, DENIED } * 조작 대상의 종류. 값이 늘어도 컬럼은 그대로다 — 대상별 테이블을 두지 않는 이유는 * 「이 유저에게 가해진 조작 전부」를 한 인덱스로 읽기 위해서다. */ - public enum TargetType { USER, CHALLENGE, REPORT, INQUIRY, ANOMALY, ANNOUNCEMENT } + public enum TargetType { USER, CHALLENGE, REPORT, INQUIRY, ANOMALY, ANNOUNCEMENT, APPEAL } @Id @JdbcTypeCode(SqlTypes.BINARY) diff --git a/src/main/java/com/ruleup/ruleup_backend/admin/dto/AdminAppealDtos.java b/src/main/java/com/ruleup/ruleup_backend/admin/dto/AdminAppealDtos.java new file mode 100644 index 00000000..74dc9631 --- /dev/null +++ b/src/main/java/com/ruleup/ruleup_backend/admin/dto/AdminAppealDtos.java @@ -0,0 +1,61 @@ +package com.ruleup.ruleup_backend.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; +import java.util.Map; + +/** + * 운영자 쪽 이의 내역 계약 — Backend #361. + * + *

이의는 접수 = 인용이라 상태(계류·기각)가 없다. 운영자가 여기서 하는 일은 판단이 아니라 확인이다 — + * 남용 의심(이상탐지 {@code APPEAL_ABUSE})의 근거를 보고, 「제 이의가 반영됐나요」 문의에 답한다. + */ +public final class AdminAppealDtos { + + private AdminAppealDtos() {} + + @Schema(name = "AdminAppealQueueResponse", description = "이의 목록 — 최신순. 커서는 불투명 문자열이다.") + public record QueueResponse(List items, String nextCursor, + @Schema(description = "필터에 맞는 전체 건수") long totalCount) {} + + @Schema(name = "AdminAppealItem") + public record Item( + String appealId, + @Schema(description = "접수(= 인용) 시각") String acceptedAt, + String userId, + @Schema(description = "승인된 닉네임 — CS 대응이라 신원을 가리지 않는다") String nickname, + String challengeId, + @Schema(description = "방 제목. 방이 지워졌으면 null") String challengeTitle, + String challengeCategory, + @Schema(description = "귀속일(KST)") String targetDate, + @Schema(description = "접수 당시 실패 사유 코드. V21(2026-10-29) 이전 접수는 null") String failureReason, + @Schema(description = "사유 앞부분") String reasonPreview, + boolean hasImage) {} + + @Schema(name = "AdminAppealDetail", description = """ + 이의 한 건. 상세 열람은 사유 원문과 증빙 사진이 담긴 **개인정보 열람**이라 감사 로그에 `APPEAL_VIEW` 로 따로 남는다.""") + public record Detail( + String appealId, + String acceptedAt, + String userId, + String nickname, + @Schema(description = "ACTIVE / SUSPENDED / WITHDRAWN") String accountStatus, + String challengeId, + String challengeTitle, + String challengeCategory, + String targetDate, + @Schema(description = "이의 대상 인증(= 앱의 verificationId)") String verificationId, + @Schema(description = "사유 전문") String reason, + @Schema(description = "증빙 사진 단기 접근 주소(없으면 빈 배열)") List imageUrls, + @Schema(description = "사진 주소 만료 시각 — 지나면 상세를 다시 불러 새 주소를 받는다") String imageUrlsExpireAt, + @Schema(description = "이의 전 실패 설명. V21(2026-10-29) 이전 접수는 null") Failure failure, + @Schema(description = "같은 회원이 최근 30일 동안 낸 이의 수(이 건 포함)") long recentAppealCount30d) {} + + @Schema(name = "AdminAppealFailure", description = "유저가 신청 화면에서 본 실패 설명 — 접수 당시 값으로 고정돼 있다") + public record Failure( + String reasonCode, + @Schema(description = "판정 근거 한 줄") String summary, + @Schema(description = "기준값") Map expected, + @Schema(description = "실제값") Map actual) {} +} diff --git a/src/main/java/com/ruleup/ruleup_backend/admin/service/AdminAppealService.java b/src/main/java/com/ruleup/ruleup_backend/admin/service/AdminAppealService.java new file mode 100644 index 00000000..9d8231e7 --- /dev/null +++ b/src/main/java/com/ruleup/ruleup_backend/admin/service/AdminAppealService.java @@ -0,0 +1,249 @@ +package com.ruleup.ruleup_backend.admin.service; + +import com.ruleup.ruleup_backend.admin.domain.AdminAction; +import com.ruleup.ruleup_backend.admin.domain.AdminAuditLog; +import com.ruleup.ruleup_backend.admin.dto.AdminAppealDtos; +import com.ruleup.ruleup_backend.common.DbTime; +import com.ruleup.ruleup_backend.common.error.BusinessException; +import com.ruleup.ruleup_backend.common.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.json.JsonMapper; + +import java.nio.ByteBuffer; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 이의 내역(운영자) — Backend #361. + * + *

판단하는 화면이 아니다

+ * 이의는 접수하는 순간 인용되고 횟수 한도도 없다(인증 정책 §5). 그래서 상태 필터도, 처리 버튼도 없다. + * 운영자는 남용 의심을 확인하거나 「제 이의가 반영됐나요」 문의에 답하려고 이 내역을 본다. + * + *

원래 실패 사유는 이의 행에서 읽는다

+ * 인용하면 판정 행의 사유가 지워지고, 확정 전 인용이라 {@code verification_failure_details} 도 생기지 않는다. + * 접수 때 이의 행에 옮겨 둔 설명(V21)이 유일한 원천이다 — 그 전 접수는 비어 있다. + * + *

관리자 DB 계정이 읽는 테이블을 늘리지 않으려고 이의·회원·방 세 테이블만 읽는다. + */ +@Service +@RequiredArgsConstructor +public class AdminAppealService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final int PAGE = 30; + private static final int PREVIEW_LENGTH = 80; + private static final Duration RECENT = Duration.ofDays(30); + private static final JsonMapper JSON = JsonMapper.builder().build(); + + private final JdbcTemplate jdbc; + private final AdminAuditService auditService; + private final AdminImageLinks imageLinks; + + private static final String FROM = " FROM verification_appeals a" + + " LEFT JOIN users u ON u.id = a.userId" + + " LEFT JOIN challenges c ON c.id = a.challengeId"; + + // ===== 목록 ===== + + /** + * 최신순 목록. 기간은 접수일(KST) 양끝 포함이다. + * + * @param hasImage true 면 사진이 붙은 것만, false 면 없는 것만, null 이면 전부 + */ + @Transactional(readOnly = true) + public AdminAppealDtos.QueueResponse queue(UUID operatorId, String from, String to, String userIdRaw, + String challengeIdRaw, Boolean hasImage, String cursor, Integer size) { + auditService.allowed(operatorId, AdminAction.APPEAL_QUEUE_VIEW, null, null, null); + + LocalDate fromDate = parseDate(from); + LocalDate toDate = parseDate(to); + UUID userId = parseId(userIdRaw); + UUID challengeId = parseId(challengeIdRaw); + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) + throw new BusinessException(ErrorCode.INVALID_REQUEST); + int limit = (size == null || size <= 0 || size > 100) ? PAGE : size; + + StringBuilder where = new StringBuilder(" WHERE 1 = 1"); + List args = new ArrayList<>(); + if (fromDate != null) { + where.append(" AND a.acceptedAt >= ?"); + args.add(DbTime.utc(fromDate.atStartOfDay(KST).toInstant())); + } + if (toDate != null) { + where.append(" AND a.acceptedAt < ?"); + args.add(DbTime.utc(toDate.plusDays(1).atStartOfDay(KST).toInstant())); + } + if (userId != null) { + where.append(" AND a.userId = ?"); + args.add(bytes(userId)); + } + if (challengeId != null) { + where.append(" AND a.challengeId = ?"); + args.add(bytes(challengeId)); + } + if (hasImage != null) { + where.append(hasImage ? " AND a.imageUrl IS NOT NULL AND a.imageUrl <> ''" + : " AND (a.imageUrl IS NULL OR a.imageUrl = '')"); + } + + long total = jdbc.queryForObject("SELECT COUNT(*)" + FROM + where, Long.class, args.toArray()); + + List pageArgs = new ArrayList<>(args); + StringBuilder pageWhere = new StringBuilder(where); + AdminCursors.TimeIdCursor after = AdminCursors.toTimeId(cursor); + if (after != null) { + // 같은 시각에 여러 건이 있어도 건너뛰지 않게 (시각, id) 로 이어받는다. + pageWhere.append(" AND (a.acceptedAt < ? OR (a.acceptedAt = ? AND a.id < ?))"); + pageArgs.add(DbTime.utc(after.at())); + pageArgs.add(DbTime.utc(after.at())); + pageArgs.add(bytes(after.id())); + } + pageArgs.add(limit + 1); + + // 다음 커서를 만들려면 마지막 행 뒤에 뭔가 더 있는지 알아야 한다 — 한 건 더 읽는다. + List rows = jdbc.query("SELECT a.id, a.acceptedAt, a.userId, u.approved_nickname, a.challengeId," + + " c.title, c.category, a.targetDate, a.failureReason, a.reason, a.imageUrl" + + FROM + pageWhere + " ORDER BY a.acceptedAt DESC, a.id DESC LIMIT ?", + (rs, i) -> Row.of(rs), pageArgs.toArray()); + + boolean hasMore = rows.size() > limit; + List page = hasMore ? rows.subList(0, limit) : rows; + String nextCursor = hasMore ? AdminCursors.ofTimeId(page.getLast().acceptedAt(), page.getLast().id()) : null; + + return new AdminAppealDtos.QueueResponse( + page.stream().map(r -> new AdminAppealDtos.Item( + r.id().toString(), r.acceptedAt().toString(), r.userId().toString(), r.nickname(), + r.challengeId().toString(), r.title(), r.category(), r.targetDate().toString(), + r.failureReason(), preview(r.reason()), hasText(r.imageUrl()))) + .toList(), + nextCursor, total); + } + + // ===== 상세 ===== + + /** 상세 — 사유 원문과 증빙 사진이 담긴 개인정보 열람이라 목록과 다른 action 으로 남긴다. */ + @Transactional(readOnly = true) + public AdminAppealDtos.Detail detail(UUID operatorId, UUID appealId) { + auditService.allowed(operatorId, AdminAction.APPEAL_VIEW, + AdminAuditLog.TargetType.APPEAL, appealId, null); + + List found = jdbc.query("SELECT a.id, a.acceptedAt, a.userId, u.approved_nickname," + + " u.status, a.challengeId, c.title, c.category, a.targetDate, a.verificationDailyId, a.reason," + + " a.imageUrl, a.failureReason, a.failureSummary, a.failureExpected, a.failureActual" + + FROM + " WHERE a.id = ?", + (rs, i) -> detailOf(rs), bytes(appealId)); + if (found.isEmpty()) throw new BusinessException(ErrorCode.APPEAL_NOT_FOUND); + return found.getFirst(); + } + + private AdminAppealDtos.Detail detailOf(ResultSet rs) throws SQLException { + UUID userId = uuid(rs.getBytes("userId")); + String imageUrl = rs.getString("imageUrl"); + AdminImageLinks.Links links = imageLinks.presign(hasText(imageUrl) ? List.of(imageUrl) : List.of()); + String reasonCode = rs.getString("failureReason"); + String summary = rs.getString("failureSummary"); + AdminAppealDtos.Failure failure = (reasonCode == null && summary == null) ? null + : new AdminAppealDtos.Failure(reasonCode, summary, + json(rs.getString("failureExpected")), json(rs.getString("failureActual"))); + + return new AdminAppealDtos.Detail( + uuid(rs.getBytes("id")).toString(), + DbTime.read(rs, "acceptedAt").toString(), + userId.toString(), + rs.getString("approved_nickname"), + rs.getString("status"), + uuid(rs.getBytes("challengeId")).toString(), + rs.getString("title"), + rs.getString("category"), + rs.getObject("targetDate", LocalDate.class).toString(), + uuid(rs.getBytes("verificationDailyId")).toString(), + rs.getString("reason"), + links.urls(), + links.expiresAt(), + failure, + recentCount(userId)); + } + + /** 같은 회원의 최근 30일 이의 수 — 남용 의심을 볼 때 이 건 하나가 아니라 빈도를 함께 본다. */ + private long recentCount(UUID userId) { + Long n = jdbc.queryForObject("SELECT COUNT(*) FROM verification_appeals WHERE userId = ? AND acceptedAt >= ?", + Long.class, bytes(userId), DbTime.utc(Instant.now().minus(RECENT))); + return n == null ? 0 : n; + } + + // ===== 내부 ===== + + private record Row(UUID id, Instant acceptedAt, UUID userId, String nickname, UUID challengeId, + String title, String category, LocalDate targetDate, String failureReason, + String reason, String imageUrl) { + + static Row of(ResultSet rs) throws SQLException { + return new Row(uuid(rs.getBytes("id")), DbTime.read(rs, "acceptedAt"), uuid(rs.getBytes("userId")), + rs.getString("approved_nickname"), uuid(rs.getBytes("challengeId")), rs.getString("title"), + rs.getString("category"), rs.getObject("targetDate", LocalDate.class), + rs.getString("failureReason"), rs.getString("reason"), rs.getString("imageUrl")); + } + } + + private Map json(String raw) { + if (raw == null) return null; + try { + return JSON.readValue(raw, new TypeReference<>() {}); + } catch (Exception e) { + return null; // 설명 보조값이라 깨져도 상세를 막지 않는다 + } + } + + private static LocalDate parseDate(String raw) { + if (raw == null || raw.isBlank()) return null; + try { + return LocalDate.parse(raw.trim()); + } catch (DateTimeParseException e) { + throw new BusinessException(ErrorCode.INVALID_REQUEST); + } + } + + /** 잘못된 id 를 500 으로 흘리지 않는다 — 잘못된 입력이다. */ + private static UUID parseId(String raw) { + if (raw == null || raw.isBlank()) return null; + try { + return UUID.fromString(raw.trim()); + } catch (IllegalArgumentException e) { + throw new BusinessException(ErrorCode.INVALID_REQUEST); + } + } + + private static String preview(String reason) { + return reason.length() <= PREVIEW_LENGTH ? reason : reason.substring(0, PREVIEW_LENGTH) + "…"; + } + + private static boolean hasText(String s) { + return s != null && !s.isBlank(); + } + + private static byte[] bytes(UUID id) { + ByteBuffer bb = ByteBuffer.allocate(16); + bb.putLong(id.getMostSignificantBits()); + bb.putLong(id.getLeastSignificantBits()); + return bb.array(); + } + + private static UUID uuid(byte[] raw) { + ByteBuffer bb = ByteBuffer.wrap(raw); + return new UUID(bb.getLong(), bb.getLong()); + } +} diff --git a/src/main/java/com/ruleup/ruleup_backend/admin/service/AdminCursors.java b/src/main/java/com/ruleup/ruleup_backend/admin/service/AdminCursors.java index 90f42715..467dbe2e 100644 --- a/src/main/java/com/ruleup/ruleup_backend/admin/service/AdminCursors.java +++ b/src/main/java/com/ruleup/ruleup_backend/admin/service/AdminCursors.java @@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Base64; +import java.util.UUID; /** * 커서 인코딩 — 백오피스 공통 5-2-1 A #1 「커서는 불투명 문자열」. @@ -54,6 +55,26 @@ static Instant toInstant(String cursor) { } } + /** (시각, id) 두 축으로 정렬하는 목록(이의)의 커서 — 같은 시각에 여러 건이 있어도 건너뛰지 않는다. */ + record TimeIdCursor(Instant at, UUID id) {} + + /** 시각은 잘라내지 않는다 — DB 가 마이크로초까지 담아 밀리초로 자르면 같은 밀리초의 다음 건을 건너뛴다. */ + static String ofTimeId(Instant at, UUID id) { + return encode(at + "|" + id); + } + + static TimeIdCursor toTimeId(String cursor) { + String raw = decode(cursor); + if (raw == null) return null; + String[] parts = raw.split("\\|", 2); + if (parts.length != 2) throw new BusinessException(ErrorCode.INVALID_REQUEST); + try { + return new TimeIdCursor(Instant.parse(parts[0]), UUID.fromString(parts[1])); + } catch (IllegalArgumentException | java.time.format.DateTimeParseException e) { + throw new BusinessException(ErrorCode.INVALID_REQUEST); + } + } + /** (점수, 탐지 시각) 두 축으로 정렬하는 이상탐지 큐의 커서. */ record ScoreCursor(int score, Instant detectedAt) {} diff --git a/src/main/java/com/ruleup/ruleup_backend/common/error/ErrorCode.java b/src/main/java/com/ruleup/ruleup_backend/common/error/ErrorCode.java index a2456053..fb2963f0 100644 --- a/src/main/java/com/ruleup/ruleup_backend/common/error/ErrorCode.java +++ b/src/main/java/com/ruleup/ruleup_backend/common/error/ErrorCode.java @@ -235,6 +235,7 @@ public enum ErrorCode { INVALID_REASON(HttpStatus.BAD_REQUEST, "이의 사유를 10자 이상 적어주세요."), NOT_FAILED(HttpStatus.CONFLICT, "실패했거나 실패가 예상되는 인증에만 이의를 신청할 수 있어요."), APPEAL_WINDOW_CLOSED(HttpStatus.CONFLICT, "이의를 신청할 수 있는 기간이 지났어요."), + APPEAL_NOT_FOUND(HttpStatus.NOT_FOUND, "이의 신청 내역을 찾을 수 없어요."), ALREADY_DECIDED(HttpStatus.CONFLICT, "이미 승인/거절된 제출입니다."), INVALID_TARGET_DATE(HttpStatus.BAD_REQUEST, "유효하지 않은 대상 날짜입니다."), CONTENT_REQUIRED(HttpStatus.BAD_REQUEST, "글 내용을 입력해주세요."), diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/domain/Appeal.java b/src/main/java/com/ruleup/ruleup_backend/verification/domain/Appeal.java index 41adf1eb..5d90d345 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/domain/Appeal.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/domain/Appeal.java @@ -13,6 +13,7 @@ import java.time.Instant; import java.time.LocalDate; +import java.util.Map; import java.util.UUID; /** @@ -75,6 +76,26 @@ public class Appeal extends AssignedIdEntity { @Column(name = "acceptedAt", nullable = false, updatable = false) private Instant acceptedAt; + /** + * 접수 당시 유저가 본 실패 설명 — 사유 코드 · 한 줄 요약 · 기준값 · 실제값. + * + *

인용되면 판정 행의 사유가 지워지고, 확정 전 인용이라 {@code verification_failure_details} 도 생기지 + * 않는다. 운영자가 「무엇에 이의를 냈는지」를 볼 수 있는 곳은 여기뿐이다. V21 이전 접수는 비어 있다. + */ + @Column(name = "failureReason", length = 40, updatable = false) + private String failureReason; + + @Column(name = "failureSummary", length = 512, updatable = false) + private String failureSummary; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "failureExpected", updatable = false) + private Map failureExpected; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "failureActual", updatable = false) + private Map failureActual; + @Generated(event = EventType.INSERT) @Column(name = "createdAt", nullable = false, updatable = false) private Instant createdAt; @@ -82,7 +103,7 @@ public class Appeal extends AssignedIdEntity { /** 형식 요건을 통과한 이의를 인용 상태로 접수한다. */ public static Appeal accept(UUID verificationDailyId, UUID challengeId, UUID challengeMemberId, UUID userId, LocalDate targetDate, String reason, String imageUrl, - Instant acceptedAt) { + FailureSnapshot failure, Instant acceptedAt) { Appeal a = new Appeal(); a.id = UuidGenerator.generate(); a.verificationDailyId = verificationDailyId; @@ -92,10 +113,20 @@ public static Appeal accept(UUID verificationDailyId, UUID challengeId, UUID cha a.targetDate = targetDate; a.reason = reason; a.imageUrl = imageUrl; + if (failure != null) { + a.failureReason = failure.reasonCode(); + a.failureSummary = failure.summary(); + a.failureExpected = failure.expected(); + a.failureActual = failure.actual(); + } a.acceptedAt = acceptedAt; return a; } + /** 접수 당시 실패 설명. */ + public record FailureSnapshot(String reasonCode, String summary, + Map expected, Map actual) {} + /** 사유가 형식 요건을 충족하는지. 공백만 있는 값은 사유가 아니다. */ public static boolean isValidReason(String reason) { return reason != null && reason.trim().length() >= MIN_REASON_LENGTH; diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/AppealService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/AppealService.java index 05b2797c..53ffb0db 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/AppealService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/AppealService.java @@ -15,6 +15,7 @@ import com.ruleup.ruleup_backend.notification.domain.NotificationType; import com.ruleup.ruleup_backend.verification.domain.Appeal; import com.ruleup.ruleup_backend.verification.domain.Polarity; +import com.ruleup.ruleup_backend.verification.domain.VerificationConfig; import com.ruleup.ruleup_backend.verification.domain.VerificationPolarity; import com.ruleup.ruleup_backend.verification.domain.VerificationDaily; import com.ruleup.ruleup_backend.verification.dto.AppealResponse; @@ -68,6 +69,7 @@ public class AppealService { private final VerificationMetrics metrics; private final ChallengeQueryService challengeQuery; private final VerificationConfigFactory configFactory; + private final FailureExplainer failureExplainer; private final VerificationProgressService progressService; private final StreakService streakService; private final NotificationPublisher notificationPublisher; @@ -91,14 +93,16 @@ public AppealResponse submit(UUID userId, UUID verificationId, AppealSubmitReque if (!Appeal.isValidReason(reason)) throw new BusinessException(ErrorCode.INVALID_REASON); Instant now = Instant.now(); - Polarity polarity = polarityOf(daily.getChallengeId()); + VerificationConfig config = configOf(daily.getChallengeId()); + Polarity polarity = (config == null) ? Polarity.ACHIEVEMENT : VerificationPolarity.of(config); // 실패로 확정됐거나 이대로면 실패인 건에만. 이미 인용돼 완료로 바뀐 건도 여기서 걸린다 // (실패 결과 기준 멱등 — 두 번째 요청은 완료 상태라 대상이 아니다). if (!failing(daily, polarity, now)) throw new BusinessException(ErrorCode.NOT_FAILED); if (!daily.isAppealable(polarity, now)) throw new BusinessException(ErrorCode.APPEAL_WINDOW_CLOSED); + // 인용하면 판정 행의 사유가 지워진다 — 정정 전에 유저가 본 설명을 이의에 옮겨 둔다. Appeal appeal = saveAppeal(daily, userId, reason.trim(), - (request != null) ? request.imageUrl() : null, now); + (request != null) ? request.imageUrl() : null, failureOf(daily, config), now); // 인용 — 정상 성공과 동일하게 정정한다. daily.correctByAppeal(now); @@ -147,12 +151,19 @@ private boolean failing(VerificationDaily daily, Polarity polarity, Instant now) return daily.getStatus() == VerificationStatus.FAILED || daily.isFailExpected(polarity, now); } - /** 판정 방향은 챌린지 설정에서 온다 — 목표 달성형인지 규칙 지키기형인지로 실패 예정 여부가 갈린다. */ - private Polarity polarityOf(UUID challengeId) { - return challengeQuery.findChallenge(challengeId) - .map(configFactory::build) - .map(VerificationPolarity::of) - .orElse(Polarity.ACHIEVEMENT); + /** + * 챌린지 인증 설정. 판정 방향(목표 달성형·규칙 지키기형)이 여기서 와 실패 예정 여부가 갈린다. + * 방이 사라졌으면 null 이다. + */ + private VerificationConfig configOf(UUID challengeId) { + return challengeQuery.findChallenge(challengeId).map(configFactory::build).orElse(null); + } + + /** 신청 화면의 실패 설명과 같은 값. 설정이 없으면(방이 사라짐) 남길 설명도 없다. */ + private Appeal.FailureSnapshot failureOf(VerificationDaily daily, VerificationConfig config) { + if (config == null) return null; + FailureExplainer.Explanation e = failureExplainer.explain(daily, config); + return new Appeal.FailureSnapshot(e.reasonCode(), e.summary(), e.expected(), e.actual()); } /** @@ -173,11 +184,12 @@ private static void afterCommit(Runnable action) { }); } - private Appeal saveAppeal(VerificationDaily daily, UUID userId, String reason, String imageUrl, Instant now) { + private Appeal saveAppeal(VerificationDaily daily, UUID userId, String reason, String imageUrl, + Appeal.FailureSnapshot failure, Instant now) { try { return appealRepo.saveAndFlush(Appeal.accept( daily.getId(), daily.getChallengeId(), daily.getChallengeMemberId(), - userId, daily.getTargetDate(), reason, imageUrl, now)); + userId, daily.getTargetDate(), reason, imageUrl, failure, now)); } catch (DataIntegrityViolationException e) { // 같은 판정에 두 번째 이의. 유일 제약이 막았다 — 중복 정정·중복 지급의 방어선이 // 실제로 일하고 있는지는 이 값으로만 알 수 있다. diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/FailureExplainer.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/FailureExplainer.java new file mode 100644 index 00000000..74579290 --- /dev/null +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/FailureExplainer.java @@ -0,0 +1,62 @@ +package com.ruleup.ruleup_backend.verification.service; + +import com.ruleup.ruleup_backend.common.verification.VerificationStatus; +import com.ruleup.ruleup_backend.verification.domain.FailureEvidence; +import com.ruleup.ruleup_backend.verification.domain.FailureReasons; +import com.ruleup.ruleup_backend.verification.domain.GapReason; +import com.ruleup.ruleup_backend.verification.domain.VerificationConfig; +import com.ruleup.ruleup_backend.verification.domain.VerificationDaily; +import com.ruleup.ruleup_backend.verification.domain.VerificationFailureDetail; +import com.ruleup.ruleup_backend.verification.domain.VerificationMethod; +import com.ruleup.ruleup_backend.verification.domain.VerificationMethodResult; +import com.ruleup.ruleup_backend.verification.repository.VerificationFailureDetailRepository; +import com.ruleup.ruleup_backend.verification.repository.VerificationMethodResultRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 실패(예정) 한 건의 설명 — 오늘 인증 카드와 이의 접수가 같은 문장을 쓰게 한 곳에 둔다. + * + *

확정된 실패는 판정 당시 스냅샷을, 실패 예정은 지금 신호로 계산한다. 실패 예정에 상세 행을 + * 만들지 않는 이유가 여기 드러난다. 그 상태는 늦게 도착한 신호나 이의로 뒤집힐 수 있어서, 저장해 두면 + * 완료가 된 뒤에도 실패 기록이 남는다. 대신 화면에 보여줄 근거는 그때그때 계산한다 — 유저는 이 숫자를 + * 보고 이의를 낸다(공통 5-8). + */ +@Component +@RequiredArgsConstructor +public class FailureExplainer { + + private final VerificationMethodResultRepository methodResultRepo; + private final VerificationFailureDetailRepository failureDetailRepo; + + /** 실패 설명 — 사유 코드 · 판정 불가 사유 · 한 줄 요약 · 기준값 · 실제값. */ + public record Explanation(String reasonCode, String gapReason, String summary, + Map expected, Map actual) {} + + public Explanation explain(VerificationDaily daily, VerificationConfig config) { + if (daily.getStatus() == VerificationStatus.FAILED) { + VerificationFailureDetail detail = failureDetailRepo.findById(daily.getId()).orElse(null); + if (detail != null) { + return new Explanation(daily.getFailureReason(), daily.getGapReason(), detail.getEvidenceSummary(), + detail.getExpectedValue(), detail.getActualValue()); + } + } + VerificationMethod method = config.primaryMethod(); + Map evidence = (method == null) ? null + : methodResultRepo.findByVerificationDailyIdAndMethod(daily.getId(), method.name()) + .map(VerificationMethodResult::getEvidence).orElse(null); + + // 목표 미달은 확정 전까지 사유가 비어 있다 — 확정 배치와 같은 규칙으로 채워야 + // 같은 사건이 화면에서 두 번 다르게 설명되지 않는다. + String pending = FailureReasons.pendingReasonOf(evidence); + String reasonCode = (daily.getFailureReason() != null) ? daily.getFailureReason() + : (pending != null) ? pending : FailureReasons.of(method, config); + String gapReason = (daily.getGapReason() != null) + ? daily.getGapReason() : GapReason.of(reasonCode); + + FailureEvidence ev = FailureEvidence.of(reasonCode, evidence); + return new Explanation(reasonCode, gapReason, ev.summary(), ev.expected(), ev.actual()); + } +} diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java index 670027fd..7d6bd456 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationReadService.java @@ -7,12 +7,7 @@ import com.ruleup.ruleup_backend.common.error.BusinessException; import com.ruleup.ruleup_backend.common.error.ErrorCode; import com.ruleup.ruleup_backend.common.verification.VerificationStatus; -import com.ruleup.ruleup_backend.verification.domain.FailureEvidence; -import com.ruleup.ruleup_backend.verification.domain.FailureReasons; -import com.ruleup.ruleup_backend.verification.domain.GapReason; import com.ruleup.ruleup_backend.verification.domain.Polarity; -import com.ruleup.ruleup_backend.verification.domain.VerificationMethod; -import com.ruleup.ruleup_backend.verification.domain.VerificationMethodResult; import com.ruleup.ruleup_backend.verification.domain.VerificationConfig; import com.ruleup.ruleup_backend.verification.domain.VerificationPolarity; import com.ruleup.ruleup_backend.verification.domain.VerificationDaily; @@ -20,8 +15,6 @@ import com.ruleup.ruleup_backend.verification.dto.TodayVerificationResponse; import com.ruleup.ruleup_backend.verification.repository.AppealRepository; import com.ruleup.ruleup_backend.verification.repository.VerificationDailyRepository; -import com.ruleup.ruleup_backend.verification.repository.VerificationFailureDetailRepository; -import com.ruleup.ruleup_backend.verification.repository.VerificationMethodResultRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,7 +26,6 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.UUID; @@ -55,8 +47,7 @@ public class VerificationReadService { private final com.ruleup.ruleup_backend.challenge.view.ChallengeMasking masking; private final com.ruleup.ruleup_backend.challenge.view.ReportedChallengeLabels reportedLabels; private final VerificationDailyRepository dailyRepo; - private final VerificationMethodResultRepository methodResultRepo; - private final VerificationFailureDetailRepository failureDetailRepo; + private final FailureExplainer failureExplainer; private final AppealRepository appealRepo; private final VerificationConfigFactory configFactory; private final StreakService streakService; @@ -135,35 +126,10 @@ public TodayVerificationResponse today(UUID userId, UUID challengeId) { failing ? appeal(member, daily, polarity, now) : null); } - /** - * 실패 설명 — 확정된 실패는 판정 당시 스냅샷을, 실패 예정은 지금 신호로 계산한다. - * - *

실패 예정에 상세 행을 만들지 않는 이유가 여기 드러난다. 그 상태는 늦게 도착한 신호나 - * 이의로 뒤집힐 수 있어서, 저장해 두면 완료가 된 뒤에도 실패 기록이 남는다. 대신 화면에 - * 보여줄 근거는 그때그때 계산한다 — 유저는 이 숫자를 보고 이의를 낸다(공통 5-8). - */ + /** 실패 설명 — 확정된 실패는 판정 당시 스냅샷, 실패 예정은 지금 신호로 계산한다({@link FailureExplainer}). */ private Failure failureOf(VerificationDaily daily, VerificationConfig config) { - if (daily.getStatus() == VerificationStatus.FAILED) { - String summary = failureDetailRepo.findById(daily.getId()) - .map(d -> d.getEvidenceSummary()).orElse(null); - if (summary != null) { - return new Failure(daily.getFailureReason(), daily.getGapReason(), summary); - } - } - VerificationMethod method = config.primaryMethod(); - Map evidence = (method == null) ? null - : methodResultRepo.findByVerificationDailyIdAndMethod(daily.getId(), method.name()) - .map(VerificationMethodResult::getEvidence).orElse(null); - - // 목표 미달은 확정 전까지 사유가 비어 있다 — 확정 배치와 같은 규칙으로 채워야 - // 같은 사건이 화면에서 두 번 다르게 설명되지 않는다. - String pending = FailureReasons.pendingReasonOf(evidence); - String reasonCode = (daily.getFailureReason() != null) ? daily.getFailureReason() - : (pending != null) ? pending : FailureReasons.of(method, config); - String gapReason = (daily.getGapReason() != null) - ? daily.getGapReason() : GapReason.of(reasonCode); - - return new Failure(reasonCode, gapReason, FailureEvidence.of(reasonCode, evidence).summary()); + FailureExplainer.Explanation e = failureExplainer.explain(daily, config); + return new Failure(e.reasonCode(), e.gapReason(), e.summary()); } /** 실패(예정) 한 건의 설명 세 값. 진행중이면 전부 null 이다. */ diff --git a/src/main/resources/db/migration/V21__appeal_failure_snapshot.sql b/src/main/resources/db/migration/V21__appeal_failure_snapshot.sql new file mode 100644 index 00000000..fe7f4b2f --- /dev/null +++ b/src/main/resources/db/migration/V21__appeal_failure_snapshot.sql @@ -0,0 +1,20 @@ +-- ====================================================================== +-- 이의 접수 당시의 실패 설명을 이의 행에 남긴다 — 관리자 이의 내역(Backend #361) +-- +-- 이의는 확정 전(실패 예정)에 받고 접수 즉시 인용돼 판정 행의 failureReason 이 지워진다. +-- verification_failure_details 는 확정된 실패에만 생기므로 인용된 이의에는 행이 없다. +-- 그래서 「무엇에 이의를 냈는지」가 어디에도 남지 않았다 — 유저가 신청 화면에서 본 설명을 그대로 적어 둔다. +-- 이 파일 이전에 접수된 이의는 NULL 이다(되살릴 원천이 없다). +-- +-- acceptedAt 인덱스: 관리자 목록이 전체 이의를 최신순 커서로 읽는다. 기존 인덱스는 userId 가 앞이라 쓸 수 없다. +-- ====================================================================== + +ALTER TABLE `verification_appeals` + ADD COLUMN `failureReason` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL + COMMENT '접수 당시 실패 사유 코드. 인용되면 판정 행의 사유가 지워져 여기만 남는다', + ADD COLUMN `failureSummary` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL + COMMENT '접수 당시 유저에게 보인 판정 근거 한 줄', + ADD COLUMN `failureExpected` json DEFAULT NULL COMMENT '접수 당시 기준값', + ADD COLUMN `failureActual` json DEFAULT NULL COMMENT '접수 당시 실제값', + ADD KEY `idx_verification_appeals_accepted` (`acceptedAt`, `id`), + ALGORITHM = INPLACE, LOCK = NONE; diff --git a/src/test/java/com/ruleup/ruleup_backend/admin/AdminAppealIT.java b/src/test/java/com/ruleup/ruleup_backend/admin/AdminAppealIT.java new file mode 100644 index 00000000..9419a359 --- /dev/null +++ b/src/test/java/com/ruleup/ruleup_backend/admin/AdminAppealIT.java @@ -0,0 +1,215 @@ +package com.ruleup.ruleup_backend.admin; + +import com.ruleup.ruleup_backend.TestcontainersConfiguration; +import com.ruleup.ruleup_backend.challenge.ChallengeApiSupport; +import com.ruleup.ruleup_backend.common.DbTime; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * 관리자 이의 내역 — Backend #361. 목록(최신순·필터·커서) · 상세(원래 실패 설명·사진·최근 30일 수) · 권한 · 감사 로그. + * + *

테스트 DB 는 스위트 전체가 공유한다. 목록은 이 클래스가 만든 회원으로 걸러 단언한다. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class AdminAppealIT extends ChallengeApiSupport { + + /** 관리자 요청이 쓴 테이블·권한이 관리자 DB 계정의 권한 목록 안에 있는지. */ + @AfterAll + static void adminQueriesStayWithinGrants() throws java.io.IOException { + com.ruleup.ruleup_backend.admin.access.AdminSqlCapture.assertWithinGrants(); + } + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + @Autowired WebApplicationContext wac; + @Autowired JdbcTemplate jdbcTemplate; + + private MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build(); + } + + @Override + protected MockMvc mvc() { return mvc; } + + @Override + protected JdbcTemplate jdbc() { return jdbcTemplate; } + + @Test + @DisplayName("일반 회원은 목록·상세 모두 403") + void membersAreForbidden() throws Exception { + Member normal = member(uniq("apn")); + assertThat(getAuth("/api/v1/admin/appeals", normal.token()).getResponse().getStatus()).isEqualTo(403); + assertThat(getAuth("/api/v1/admin/appeals/" + UUID.randomUUID(), normal.token()).getResponse().getStatus()) + .isEqualTo(403); + } + + @Test + @DisplayName("목록은 최신순이고 회원·방·사진·기간으로 거르며, 커서로 끝까지 빠짐없이 넘긴다") + void listFiltersAndPages() throws Exception { + Member op = operator("apo"); + Member who = member(uniq("apu")); + UUID roomA = insertChallenge(who.id(), "EXERCISE", "ACTIVE", "GROUP"); + UUID roomB = insertChallenge(who.id(), "STUDY", "ACTIVE", "GROUP"); + Instant now = Instant.now(); + // 같은 시각 두 건 — 커서가 (시각, id) 로 이어받지 않으면 하나를 건너뛴다. + UUID oldest = appeal(who.id(), roomA, now.minus(Duration.ofDays(3)), null, "INSUFFICIENT_DWELL"); + UUID tieA = appeal(who.id(), roomA, now.minus(Duration.ofHours(2)), "/files/a.jpg", "INSUFFICIENT_STEPS"); + UUID tieB = appeal(who.id(), roomB, now.minus(Duration.ofHours(2)), null, null); + UUID newest = appeal(who.id(), roomB, now.minus(Duration.ofMinutes(5)), null, "WOKE_UP_LATE"); + + String base = "/api/v1/admin/appeals?userId=" + who.id(); + MvcResult all = getAuth(base, op.token()); + assertThat(all.getResponse().getStatus()).isEqualTo(200); + assertThat((Integer) read(all, "$.data.totalCount")).isEqualTo(4); + List ids = read(all, "$.data.items[*].appealId"); + assertThat(ids.getFirst()).isEqualTo(newest.toString()); + assertThat(ids.getLast()).isEqualTo(oldest.toString()); + assertThat(ids.subList(1, 3)).containsExactlyInAnyOrder(tieA.toString(), tieB.toString()); + assertThat((String) read(all, "$.data.items[0].failureReason")).isEqualTo("WOKE_UP_LATE"); + assertThat((String) read(all, "$.data.items[0].nickname")).isNotBlank(); + assertThat((String) read(all, "$.data.items[0].challengeCategory")).isEqualTo("STUDY"); + + // 한 장에 한 건씩 넘겨도 네 건을 정확히 한 번씩 본다. + List paged = new ArrayList<>(); + String cursor = null; + do { + MvcResult page = getAuth(base + "&size=1" + (cursor == null ? "" : "&cursor=" + enc(cursor)), op.token()); + paged.addAll(read(page, "$.data.items[*].appealId")); + cursor = read(page, "$.data.nextCursor"); + } while (cursor != null); + assertThat(paged).containsExactlyElementsOf(ids); + + assertThat(idsOf(getAuth(base + "&challengeId=" + roomA, op.token()))) + .containsExactlyInAnyOrder(oldest.toString(), tieA.toString()); + assertThat(idsOf(getAuth(base + "&hasImage=true", op.token()))).containsExactly(tieA.toString()); + assertThat(idsOf(getAuth(base + "&hasImage=false", op.token()))).hasSize(3); + + LocalDate today = LocalDate.now(KST); + String twoDaysAgo = today.minusDays(2).toString(); + assertThat(idsOf(getAuth(base + "&from=" + twoDaysAgo + "&to=" + today, op.token()))) + .as("사흘 전 건은 기간 밖").doesNotContain(oldest.toString()).hasSize(3); + + Integer audited = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM admin_audit_logs WHERE operator_id = ?" + + " AND action = 'APPEAL_QUEUE_VIEW' AND result = 'ALLOWED'", Integer.class, bytes(op.id())); + assertThat(audited).isGreaterThan(0); + } + + @Test + @DisplayName("상세는 사유 전문·원래 실패 설명·사진·최근 30일 이의 수를 내리고 APPEAL_VIEW 로 남는다") + void detailShowsTheOriginalFailure() throws Exception { + Member op = operator("apd"); + Member who = member(uniq("apw")); + UUID room = insertChallenge(who.id(), "EXERCISE", "ACTIVE", "GROUP"); + Instant now = Instant.now(); + appeal(who.id(), room, now.minus(Duration.ofDays(40)), null, "INSUFFICIENT_DWELL"); // 30일 밖 + appeal(who.id(), room, now.minus(Duration.ofDays(10)), null, null); + UUID target = appeal(who.id(), room, now.minus(Duration.ofHours(1)), "/files/proof.jpg", "INSUFFICIENT_DWELL"); + + MvcResult res = getAuth("/api/v1/admin/appeals/" + target, op.token()); + assertThat(res.getResponse().getStatus()).isEqualTo(200); + assertThat((String) read(res, "$.data.reason")).startsWith("지하철 구간에서 GPS가 끊겼어요"); + assertThat((String) read(res, "$.data.failure.reasonCode")).isEqualTo("INSUFFICIENT_DWELL"); + assertThat((String) read(res, "$.data.failure.summary")).isEqualTo("체류 10분, 목표 30분"); + assertThat((Integer) read(res, "$.data.failure.expected.goalMinutes")).isEqualTo(30); + assertThat((Integer) read(res, "$.data.failure.actual.dwellMinutes")).isEqualTo(10); + assertThat((Integer) read(res, "$.data.recentAppealCount30d")).as("40일 전 건은 빠진다").isEqualTo(2); + assertThat((List) read(res, "$.data.imageUrls")).hasSize(1); + assertThat((String) read(res, "$.data.imageUrlsExpireAt")).isNotBlank(); + assertThat((String) read(res, "$.data.accountStatus")).isEqualTo("ACTIVE"); + + Integer audited = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM admin_audit_logs WHERE operator_id = ?" + + " AND action = 'APPEAL_VIEW' AND target_type = 'APPEAL' AND target_id = ? AND result = 'ALLOWED'", + Integer.class, bytes(op.id()), bytes(target)); + assertThat(audited).isEqualTo(1); + } + + @Test + @DisplayName("실패 설명이 없는 옛 접수는 failure 가 null 이다") + void legacyAppealHasNoFailure() throws Exception { + Member op = operator("apl"); + Member who = member(uniq("apx")); + UUID room = insertChallenge(who.id(), "EXERCISE", "ACTIVE", "GROUP"); + UUID legacy = appeal(who.id(), room, Instant.now(), null, null); + + MvcResult res = getAuth("/api/v1/admin/appeals/" + legacy, op.token()); + assertThat(res.getResponse().getStatus()).isEqualTo(200); + assertThat((Object) read(res, "$.data.failure")).isNull(); + assertThat((List) read(res, "$.data.imageUrls")).isEmpty(); + } + + @Test + @DisplayName("잘못된 입력은 400, 없는 이의는 404") + void badInput() throws Exception { + Member op = operator("apb"); + expectError(getAuth("/api/v1/admin/appeals?from=2026-13-01", op.token()), 400, "INVALID_REQUEST"); + expectError(getAuth("/api/v1/admin/appeals?from=2026-10-02&to=2026-10-01", op.token()), 400, "INVALID_REQUEST"); + expectError(getAuth("/api/v1/admin/appeals?userId=not-a-uuid", op.token()), 400, "INVALID_REQUEST"); + expectError(getAuth("/api/v1/admin/appeals?cursor=%21%21", op.token()), 400, "INVALID_REQUEST"); + expectError(getAuth("/api/v1/admin/appeals/" + UUID.randomUUID(), op.token()), 404, "APPEAL_NOT_FOUND"); + } + + // ===== 픽스처 ===== + + private Member operator(String tag) throws Exception { + Member op = member(uniq(tag)); + jdbcTemplate.update("UPDATE users SET role = 'OPERATOR' WHERE id = ?", bytes(op.id())); + return op; + } + + /** 인용된 이의 한 건. reasonCode 가 null 이면 V21 이전 접수처럼 실패 설명이 없다. */ + private UUID appeal(UUID userId, UUID challengeId, Instant acceptedAt, String imageUrl, String reasonCode) { + UUID dailyId = UUID.randomUUID(); + LocalDate targetDate = LocalDate.ofInstant(acceptedAt, KST).minusDays(1); + jdbcTemplate.update("INSERT INTO VerificationDaily (id, challengeMemberId, challengeId, userId, targetDate, status, verifiedVia)" + + " VALUES (?, ?, ?, ?, ?, 'SUCCESS', 'APPEAL')", + bytes(dailyId), bytes(UUID.randomUUID()), bytes(challengeId), bytes(userId), targetDate); + UUID id = UUID.randomUUID(); + jdbcTemplate.update("INSERT INTO verification_appeals (id, verificationDailyId, challengeId, challengeMemberId, userId," + + " targetDate, reason, imageUrl, acceptedAt, failureReason, failureSummary, failureExpected, failureActual)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + bytes(id), bytes(dailyId), bytes(challengeId), bytes(UUID.randomUUID()), bytes(userId), targetDate, + "지하철 구간에서 GPS가 끊겼어요. 실제로는 한 시간 있었습니다.", imageUrl, DbTime.utc(acceptedAt), + reasonCode, + reasonCode == null ? null : "체류 10분, 목표 30분", + reasonCode == null ? null : "{\"goalMinutes\":30}", + reasonCode == null ? null : "{\"dwellMinutes\":10}"); + return id; + } + + private List idsOf(MvcResult res) throws Exception { + assertThat(res.getResponse().getStatus()).isEqualTo(200); + return read(res, "$.data.items[*].appealId"); + } + + private static String enc(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAppealIT.java b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAppealIT.java index 0032de34..6ab55cd8 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAppealIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/VerificationAppealIT.java @@ -218,6 +218,24 @@ void acceptedImmediately() throws Exception { bytes(f.owner().id()), "%" + appealId)).isEqualTo(1); } + @Test + @DisplayName("접수 당시 실패 설명이 이의에 남는다 — 인용으로 판정 행의 사유가 지워져도 무엇에 이의했는지 볼 수 있다") + void failureExplanationIsKeptOnTheAppeal() throws Exception { + FailedVerification f = failedVerification("appeal-snap"); + + appeal(f.owner().token(), f.verificationId(), REASON, null); + + Map row = jdbc().queryForMap( + "SELECT a.failureReason, a.failureSummary, a.failureExpected, a.failureActual, d.failureReason AS dailyReason" + + " FROM verification_appeals a JOIN VerificationDaily d ON d.id = a.verificationDailyId" + + " WHERE a.verificationDailyId = ?", bytes(f.verificationId())); + assertThat(row.get("dailyReason")).as("인용하면 판정 행의 사유는 지워진다").isNull(); + assertThat(row.get("failureReason")).isEqualTo("INSUFFICIENT_DWELL"); + assertThat((String) row.get("failureSummary")).contains("체류").contains("10"); + assertThat(String.valueOf(row.get("failureExpected"))).contains("goalMinutes"); + assertThat(String.valueOf(row.get("failureActual"))).contains("dwellMinutes"); + } + @Test @DisplayName("인용된 인증은 실패 공유 대상에서 빠진다") void acceptedAppealRemovesTheFailureFromTheFeed() throws Exception {