Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -33,7 +35,7 @@
* 2단계 확인은 형태와 무관하게 필수</b>라 서버가 소유한다. 경로 prefix 전체에 인터셉터가 걸려
* 있어 엔드포인트를 추가해도 권한 검사를 빠뜨릴 자리가 없다.
*/
@Tag(name = "Admin", description = "운영자 백오피스 — 대시보드 · 일별 지표 · 신고 · 제재 · CS · 직권 폐쇄 · 이상탐지 · 공지")
@Tag(name = "Admin", description = "운영자 백오피스 — 대시보드 · 일별 지표 · 신고 · 제재 · CS · 이의 · 직권 폐쇄 · 이상탐지 · 공지")
@SecurityRequirement(name = "bearerAuth")
@RestController
@RequestMapping("/api/v1/admin")
Expand All @@ -46,6 +48,7 @@ public class AdminController {
private final AdminInquiryService inquiryService;
private final AdminDashboardService dashboardService;
private final AdminStatsService statsService;
private final AdminAppealService appealService;

// ===== 대시보드 =====

Expand Down Expand Up @@ -341,6 +344,47 @@ public ApiResponse<AdminInquiryDtos.Detail> 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<AdminAppealDtos.QueueResponse> 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<AdminAppealDtos.Detail> appeal(@AuthenticationPrincipal String userId,
@PathVariable UUID appealId) {
return ApiResponse.ok(appealService.detail(UUID.fromString(userId), appealId));
}

// ===== 이상탐지 =====

@Operation(summary = "이상탐지 신호", description = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public enum AdminAction {
/** 분류 변경 — 유저에게 노출하지 않는 조작이라 기록이 유일한 흔적이다. */
INQUIRY_RECLASSIFY,

// ===== 이의 =====
APPEAL_QUEUE_VIEW,
/** 이의 상세 열람 — 사유 원문과 증빙 사진이 담긴 <b>개인정보 열람</b>이라 목록 조회와 섞지 않는다. */
APPEAL_VIEW,

// ===== 조회 전용 =====
/** 대시보드 — 가드레일 지표를 본 시각이 남아야 "언제부터 알고 있었나"를 답할 수 있다. */
DASHBOARD_VIEW,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>이의는 <b>접수 = 인용</b>이라 상태(계류·기각)가 없다. 운영자가 여기서 하는 일은 판단이 아니라 확인이다 —
* 남용 의심(이상탐지 {@code APPEAL_ABUSE})의 근거를 보고, 「제 이의가 반영됐나요」 문의에 답한다.
*/
public final class AdminAppealDtos {

private AdminAppealDtos() {}

@Schema(name = "AdminAppealQueueResponse", description = "이의 목록 — 최신순. 커서는 불투명 문자열이다.")
public record QueueResponse(List<Item> 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<String> 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<String, Object> expected,
@Schema(description = "실제값") Map<String, Object> actual) {}
}
Loading
Loading