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
Expand Up @@ -13,15 +13,19 @@
import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse;
import com.dodo.backend.admin.entity.AdminReportType;
import com.dodo.backend.admin.service.AdminService;
import com.dodo.backend.notification.dto.request.NotificationRequest.NotificationScheduleCreateRequest;
import com.dodo.backend.notification.dto.response.NotificationResponse.NotificationScheduleCreateResponse;
import com.dodo.backend.notification.service.NotificationScheduleService;
import com.dodo.backend.report.entity.ReportStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
Expand Down Expand Up @@ -49,6 +53,7 @@
public class AdminController {

private final AdminService adminService;
private final NotificationScheduleService notificationScheduleService;

/**
* 특정 게시글의 신고 상세 내역을 조회합니다.
Expand Down Expand Up @@ -134,28 +139,36 @@ public ResponseEntity<AdminSimpleResponse> updateUserStatus(
* 게시글을 강제로 삭제합니다.
*
* @param boardId 삭제할 게시글 ID
* @return 응답 본문이 없는 204 응답
* @return 게시글 삭제 성공 메시지
*/
@Operation(summary = "게시글 강제 삭제", description = "관리자가 게시글을 삭제 상태로 변경합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "게시글이 성공적으로 강제 삭제되었습니다.",
content = @Content(schema = @Schema(implementation = AdminSimpleResponse.class)))
})
@DeleteMapping("/boards/{boardId}")
public ResponseEntity<Void> deleteBoard(@PathVariable Long boardId) {
public ResponseEntity<AdminSimpleResponse> deleteBoard(@PathVariable Long boardId) {
log.info("관리자 게시글 강제 삭제 요청 - BoardId: {}", boardId);
adminService.deleteBoard(boardId);
return ResponseEntity.noContent().build();
return ResponseEntity.ok(AdminSimpleResponse.toDto("게시글이 성공적으로 강제 삭제되었습니다."));
}

/**
* 댓글을 강제로 삭제합니다.
*
* @param commentId 삭제할 댓글 ID
* @return 응답 본문이 없는 204 응답
* @return 댓글 삭제 성공 메시지
*/
@Operation(summary = "댓글 강제 삭제", description = "관리자가 댓글을 강제로 삭제합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "댓글이 성공적으로 강제 삭제되었습니다.",
content = @Content(schema = @Schema(implementation = AdminSimpleResponse.class)))
})
@DeleteMapping("/comments/{commentId}")
public ResponseEntity<Void> deleteComment(@PathVariable Long commentId) {
public ResponseEntity<AdminSimpleResponse> deleteComment(@PathVariable Long commentId) {
log.info("관리자 댓글 강제 삭제 요청 - CommentId: {}", commentId);
adminService.deleteComment(commentId);
return ResponseEntity.noContent().build();
return ResponseEntity.ok(AdminSimpleResponse.toDto("댓글이 성공적으로 강제 삭제되었습니다."));
}

/**
Expand Down Expand Up @@ -195,51 +208,78 @@ public ResponseEntity<AdminSimpleResponse> createAnnouncement(
.body(adminService.createAnnouncement(adminId, request));
}

@Operation(summary = "알림 스케줄 등록", description = "관리자가 지정 시간에 발송될 알림 스케줄을 등록합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "알림 스케줄 등록 성공",
content = @Content(schema = @Schema(implementation = NotificationScheduleCreateResponse.class)))
})
@PostMapping("/notification-schedules")
public ResponseEntity<NotificationScheduleCreateResponse> createNotificationSchedule(
@Valid @RequestBody NotificationScheduleCreateRequest request,
@AuthenticationPrincipal UserDetails userDetails
) {
UUID adminId = UUID.fromString(userDetails.getUsername());
log.info("관리자 알림 스케줄 등록 요청 - AdminId: {}, ScheduledAt: {}", adminId, request.getScheduledAt());
return ResponseEntity.ok(notificationScheduleService.createSchedule(adminId, request));
}

/**
* 공지를 삭제합니다.
*
* @param boardId 삭제할 공지 게시글 ID
* @return 응답 본문이 없는 204 응답
* @return 공지 삭제 성공 메시지
*/
@Operation(summary = "공지 삭제", description = "관리자가 공지를 삭제합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "공지가 성공적으로 삭제되었습니다.",
content = @Content(schema = @Schema(implementation = AdminSimpleResponse.class)))
})
@DeleteMapping("/announcements/{boardId}")
public ResponseEntity<Void> deleteAnnouncement(@PathVariable Long boardId) {
public ResponseEntity<AdminSimpleResponse> deleteAnnouncement(@PathVariable Long boardId) {
log.info("관리자 공지 삭제 요청 - BoardId: {}", boardId);
adminService.deleteAnnouncement(boardId);
return ResponseEntity.noContent().build();
return ResponseEntity.ok(AdminSimpleResponse.toDto("공지가 성공적으로 삭제되었습니다."));
}

/**
* 공지를 수정합니다.
*
* @param boardId 수정할 공지 게시글 ID
* @param request 공지 수정 요청
* @return 응답 본문이 없는 204 응답
* @return 공지 수정 성공 메시지
*/
@Operation(summary = "공지 수정", description = "관리자가 공지를 수정합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "공지가 성공적으로 수정되었습니다.",
content = @Content(schema = @Schema(implementation = AdminSimpleResponse.class)))
})
@PatchMapping("/announcements/{boardId}")
public ResponseEntity<Void> updateAnnouncement(
public ResponseEntity<AdminSimpleResponse> updateAnnouncement(
@PathVariable Long boardId,
@RequestBody AnnouncementUpdateRequest request
) {
log.info("관리자 공지 수정 요청 - BoardId: {}", boardId);
adminService.updateAnnouncement(boardId, request);
return ResponseEntity.noContent().build();
return ResponseEntity.ok(AdminSimpleResponse.toDto("공지가 성공적으로 수정되었습니다."));
}

/**
* 공지 목록을 조회합니다.
*
* @param pageable 공지 목록 페이지 요청 정보
* @param page 조회할 페이지 번호
* @param size 페이지당 공지 개수
* @param sort 정렬 조건
* @return 공지 목록 조회 결과
*/
@Operation(summary = "공지 목록 조회", description = "공지 목록을 페이지 단위로 조회합니다.")
@GetMapping("/announcements")
public ResponseEntity<AnnouncementListResponse> getAnnouncementList(
@ParameterObject @PageableDefault(size = 10) Pageable pageable
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "registrationUpdatedAt,desc") String sort
) {
Comment on lines 276 to 280

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

⚠️ API 간 페이지 번호(Page) 인덱스 일관성 결여 (0-based vs 1-based)

현재 AdminController의 공지 목록 조회 API(getAnnouncementList)는 페이지 번호의 기본값으로 0을 사용하며 0-based index로 동작합니다.

반면, NotificationController의 알림 목록 조회 API(getNotifications)는 기본값으로 1을 사용하며 내부적으로 page - 1 처리를 통해 1-based index로 동작하고 있습니다.

💡 개선 제안

동일한 프로젝트 내에서 API마다 페이지 번호 기준이 다르면 프론트엔드 개발 시 혼선이 발생하고 버그의 원인이 되기 쉽습니다. 프로젝트 전체의 페이지 번호 정책을 하나로 통일(예: 모두 1-based 또는 모두 0-based)하는 것을 강력히 권장합니다.

log.info("관리자 공지 목록 조회 요청 - Page: {}, Size: {}", pageable.getPageNumber(), pageable.getPageSize());
return ResponseEntity.ok(adminService.getAnnouncementList(pageable));
log.info("관리자 공지 목록 조회 요청 - Page: {}, Size: {}, Sort: {}", page, size, sort);
return ResponseEntity.ok(adminService.getAnnouncementList(page, size, sort));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ public static AnnouncementItemResponse toDto(Board board, String imageFileUrl) {
public static class AnnouncementListResponse {
private PageInfoResponse pageInfo;
private List<AnnouncementItemResponse> data;
private String message;
}

/**
Expand All @@ -310,8 +311,9 @@ public static class AnnouncementDetailResponse {
private Integer viewCount;
private LocalDateTime boardCreatedAt;
private LocalDateTime boardModifiedAt;
private String message;

public static AnnouncementDetailResponse toDto(Board board, String imageFileUrl) {
public static AnnouncementDetailResponse toDto(Board board, String imageFileUrl, String message) {
return AnnouncementDetailResponse.builder()
.boardId(board.getBoardId())
.boardTitle(board.getBoardTitle())
Expand All @@ -320,6 +322,7 @@ public static AnnouncementDetailResponse toDto(Board board, String imageFileUrl)
.viewCount(board.getViewCount())
.boardCreatedAt(board.getBoardCreatedAt())
.boardModifiedAt(board.getModifiedAt())
.message(message)
.build();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import com.dodo.backend.admin.dto.response.AdminResponse.UserReportDetailResponse;
import com.dodo.backend.admin.entity.AdminReportType;
import com.dodo.backend.report.entity.ReportStatus;
import org.springframework.data.domain.Pageable;

import java.util.UUID;

Expand Down Expand Up @@ -44,7 +43,7 @@ public interface AdminService {

void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request);

AnnouncementListResponse getAnnouncementList(Pageable pageable);
AnnouncementListResponse getAnnouncementList(int page, int size, String sort);

AnnouncementDetailResponse getAnnouncementDetail(Long boardId);
}
64 changes: 55 additions & 9 deletions src/main/java/com/dodo/backend/admin/service/AdminServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
import com.dodo.backend.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -67,6 +69,8 @@ public class AdminServiceImpl implements AdminService {

private static final int MAX_PAGE_SIZE = 100;
private static final String STATUS_UPDATE_SUCCESS_MESSAGE = "성공적으로 상태를 변경했습니다.";
private static final String ANNOUNCEMENT_LIST_SUCCESS_MESSAGE = "공지 목록을 조회했습니다.";
private static final String ANNOUNCEMENT_DETAIL_SUCCESS_MESSAGE = "공지 상세보기에 성공했습니다.";

private final ReportRepository reportRepository;
private final BoardRepository boardRepository;
Expand Down Expand Up @@ -310,24 +314,26 @@ public void updateAnnouncement(Long boardId, AnnouncementUpdateRequest request)
/**
* 공지 목록을 조회합니다.
*
* @param pageable 공지 목록 페이지 요청 정보
* @param page 조회할 페이지 번호
* @param size 페이지당 공지 개수
* @param sort 정렬 조건
* @return 공지 목록 조회 결과
*/
@Transactional(readOnly = true)
@Override
public AnnouncementListResponse getAnnouncementList(Pageable pageable) {
if (pageable == null || pageable.getPageNumber() < 0 || pageable.getPageSize() <= 0 || pageable.getPageSize() > MAX_PAGE_SIZE) {
throw new AdminException(INVALID_REQUEST);
}
public AnnouncementListResponse getAnnouncementList(int page, int size, String sort) {
validateAnnouncementPageRequest(page, size);
Pageable pageable = PageRequest.of(page, size, buildAnnouncementSort(sort));

Page<Board> page = boardRepository.findAllByBoardTypeAndBoardStatus(BoardType.NOTICE, BoardStatus.PUBLISHED, pageable);
List<AnnouncementItemResponse> items = page.getContent().stream()
Page<Board> announcementPage = boardRepository.findAllByBoardTypeAndBoardStatus(BoardType.NOTICE, BoardStatus.PUBLISHED, pageable);
List<AnnouncementItemResponse> items = announcementPage.getContent().stream()
.map(board -> AnnouncementItemResponse.toDto(board, firstImageUrl(board.getBoardId())))
.toList();

return AnnouncementListResponse.builder()
.pageInfo(PageInfoResponse.toDto(page))
.pageInfo(PageInfoResponse.toDto(announcementPage))
.data(items)
.message(ANNOUNCEMENT_LIST_SUCCESS_MESSAGE)
.build();
}

Expand All @@ -341,7 +347,7 @@ public AnnouncementListResponse getAnnouncementList(Pageable pageable) {
@Override
public AnnouncementDetailResponse getAnnouncementDetail(Long boardId) {
Board board = findAnnouncement(boardId);
return AnnouncementDetailResponse.toDto(board, firstImageUrl(boardId));
return AnnouncementDetailResponse.toDto(board, firstImageUrl(boardId), ANNOUNCEMENT_DETAIL_SUCCESS_MESSAGE);
}

private List<Report> findReports(AdminReportType reportType, ReportStatus reportStatus) {
Expand Down Expand Up @@ -485,6 +491,46 @@ private void validatePageRequest(AdminReportType reportType, int page, int size)
}
}

private void validateAnnouncementPageRequest(int page, int size) {
if (page < 0 || size <= 0 || size > MAX_PAGE_SIZE) {
throw new AdminException(INVALID_REQUEST);
}
}

private Sort buildAnnouncementSort(String sort) {
String normalized = sort == null || sort.isBlank() ? "registrationUpdatedAt,desc" : sort;
String[] tokens = normalized.split(",");
if (tokens.length > 2) {
throw new AdminException(INVALID_REQUEST);
}

String property = resolveAnnouncementSortProperty(tokens[0].trim());
Sort.Direction direction = tokens.length == 2
? parseSortDirection(tokens[1].trim())
: Sort.Direction.DESC;

return Sort.by(direction, property);
}

private String resolveAnnouncementSortProperty(String property) {
return switch (property) {
case "registrationCreatedAt" -> "boardCreatedAt";
case "registrationUpdatedAt" -> "modifiedAt";
case "registrationStatus" -> "boardStatus";
default -> throw new AdminException(INVALID_REQUEST);
};
}

private Sort.Direction parseSortDirection(String direction) {
if ("asc".equalsIgnoreCase(direction)) {
return Sort.Direction.ASC;
}
if ("desc".equalsIgnoreCase(direction)) {
return Sort.Direction.DESC;
}
throw new AdminException(INVALID_REQUEST);
}

private void validatePositiveId(Long id) {
if (id == null || id <= 0) {
throw new AdminException(INVALID_REQUEST);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.dodo.backend.auth.controller;

import com.dodo.backend.auth.dto.request.AuthRequest;
import com.dodo.backend.auth.dto.request.AuthRequest.AdminLoginRequest;
import com.dodo.backend.auth.dto.request.AuthRequest.LogoutRequest;
import com.dodo.backend.auth.dto.request.AuthRequest.ReissueRequest;
import com.dodo.backend.auth.dto.request.AuthRequest.SocialLoginRequest;
import com.dodo.backend.auth.dto.response.AuthResponse;
import com.dodo.backend.auth.dto.response.AuthResponse.AdminLoginResponse;
import com.dodo.backend.auth.dto.response.AuthResponse.DeviceAuthResponse;
import com.dodo.backend.auth.dto.response.AuthResponse.SocialLoginResponse;
import com.dodo.backend.auth.dto.response.AuthResponse.SocialRegisterResponse;
Expand Down Expand Up @@ -92,6 +94,26 @@ public ResponseEntity<?> doSocialLogin(@RequestBody @Valid SocialLoginRequest re
return authService.socialLogin(request);
}

@Operation(summary = "관리자 전용 로그인", description = "관리자 이메일과 비밀번호를 검증하고 ADMIN 권한 토큰을 발급합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "관리자 로그인 성공",
content = @Content(schema = @Schema(implementation = AdminLoginResponse.class))),
@ApiResponse(responseCode = "401", description = "이메일 또는 비밀번호 불일치",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "403", description = "정지/휴면/삭제 계정",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "429", description = "요청 횟수 제한 초과",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
@PostMapping("/admin-login")
public ResponseEntity<AdminLoginResponse> adminLogin(@RequestBody @Valid AdminLoginRequest request,
HttpServletRequest httpRequest) {
String clientIp = httpRequest.getRemoteAddr();
log.info("관리자 로그인 요청 수신 - email: {}", request.getEmail());
authService.checkRateLimit(clientIp);
return ResponseEntity.ok(authService.adminLogin(request));
}

/**
* 사용자의 리프레시 토큰을 만료시키고, 현재 사용 중인 액세스 토큰을 블랙리스트에 등록하여 로그아웃을 처리합니다.
* <p>
Expand Down Expand Up @@ -258,4 +280,4 @@ public ResponseEntity<TokenResponse> deviceReissue(@RequestBody @Valid ReissueRe
log.info("장치 토큰 재발급 요청 수신");
return ResponseEntity.ok(authService.deviceReissueToken(request));
}
}
}
18 changes: 17 additions & 1 deletion src/main/java/com/dodo/backend/auth/dto/request/AuthRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ public static class SocialLoginRequest {
private String code;
}

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "관리자 전용 로그인 요청")
public static class AdminLoginRequest {

@Schema(description = "관리자 이메일", example = "admin@dodo.com")
@NotBlank(message = "email은 필수 값입니다.")
private String email;

@Schema(description = "관리자 로그인 비밀번호", example = "admin-password")
@NotBlank(message = "password는 필수 값입니다.")
private String password;
}

/**
* 로그아웃 요청 시 리프레시 토큰을 전달받는 DTO입니다.
*/
Expand Down Expand Up @@ -77,4 +93,4 @@ public static class DeviceAuthRequest {
@Schema(description = "디바이스 고유 ID", example = "ABC123XYZ")
private String deviceId;
}
}
}
Loading
Loading