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 @@ -22,7 +22,7 @@
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/clubs")
@Tag(name = "club", description = "동호회 관련 API")
@Tag(name = "club", description = "동호회 관리 관련 API")
public class ClubController {

private final ClubServiceImpl clubService;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package com.be.sportizebe.domain.club.repository;

import com.be.sportizebe.domain.club.entity.Club;
import com.be.sportizebe.domain.club.entity.ClubMember;
import com.be.sportizebe.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ClubMemberRepository extends JpaRepository<ClubMember, Long> {

// 특정 사용자가 특정 동호회에 이미 가입했는지 확인
boolean existsByClubAndUser(Club club, User user);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.be.sportizebe.domain.notification.controller;

import com.be.sportizebe.domain.notification.dto.response.JoinClubRequestResponse;
import com.be.sportizebe.domain.notification.service.JoinClubRequestService;
import com.be.sportizebe.global.cache.dto.UserAuthInfo;
import com.be.sportizebe.global.response.BaseResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/clubs")
@Tag(name = "join-request", description = "동호회 가입 관련 API")
public class JoinClubRequestController {

private final JoinClubRequestService joinClubRequestService;

@PostMapping("/{clubId}/join")
@Operation(summary = "가입 신청", description = "동호회에 가입을 신청합니다. 동호회장에게 알림이 전송됩니다.")
public ResponseEntity<BaseResponse<JoinClubRequestResponse>> requestJoin(
@Parameter(description = "동호회 ID") @PathVariable Long clubId,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
JoinClubRequestResponse response = joinClubRequestService.requestJoin(clubId, userAuthInfo.getId());
return ResponseEntity.status(HttpStatus.CREATED)
.body(BaseResponse.success("가입 신청 완료", response));
}

@DeleteMapping("/join-requests/{requestId}")
@Operation(summary = "가입 신청 취소", description = "본인의 가입 신청을 취소합니다.")
public ResponseEntity<BaseResponse<Void>> cancelRequest(
@Parameter(description = "가입 신청 ID") @PathVariable Long requestId,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
joinClubRequestService.cancelRequest(requestId, userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("가입 신청 취소 완료", null));
}

@PostMapping("/join-requests/{requestId}/approve")
@Operation(summary = "가입 승인", description = "가입 신청을 승인합니다. 동호회장만 가능합니다.")
public ResponseEntity<BaseResponse<JoinClubRequestResponse>> approveRequest(
@Parameter(description = "가입 신청 ID") @PathVariable Long requestId,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
JoinClubRequestResponse response = joinClubRequestService.approveRequest(requestId, userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("가입 승인 완료", response));
}

@PostMapping("/join-requests/{requestId}/reject")
@Operation(summary = "가입 거절", description = "가입 신청을 거절합니다. 동호회장만 가능합니다.")
public ResponseEntity<BaseResponse<JoinClubRequestResponse>> rejectRequest(
@Parameter(description = "가입 신청 ID") @PathVariable Long requestId,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
JoinClubRequestResponse response = joinClubRequestService.rejectRequest(requestId, userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("가입 거절 완료", response));
}

@GetMapping("/{clubId}/join-requests")
@Operation(summary = "대기 중인 가입 신청 목록", description = "동호회의 대기 중인 가입 신청 목록을 조회합니다. 동호회장만 가능합니다.")
public ResponseEntity<BaseResponse<List<JoinClubRequestResponse>>> getPendingRequests(
@Parameter(description = "동호회 ID") @PathVariable Long clubId,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
List<JoinClubRequestResponse> response = joinClubRequestService.getPendingRequests(clubId, userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("가입 신청 목록 조회 성공", response));
}

@GetMapping("/my-join-requests")
@Operation(summary = "내 가입 신청 목록", description = "내가 신청한 가입 신청 목록을 조회합니다.")
public ResponseEntity<BaseResponse<List<JoinClubRequestResponse>>> getMyRequests(
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
List<JoinClubRequestResponse> response = joinClubRequestService.getMyRequests(userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("내 가입 신청 목록 조회 성공", response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.be.sportizebe.domain.notification.controller;

import com.be.sportizebe.domain.notification.dto.response.NotificationResponse;
import com.be.sportizebe.domain.notification.service.NotificationServiceImpl;
import com.be.sportizebe.domain.user.entity.User;
import com.be.sportizebe.domain.user.exception.UserErrorCode;
import com.be.sportizebe.domain.user.repository.UserRepository;
import com.be.sportizebe.global.cache.dto.UserAuthInfo;
import com.be.sportizebe.global.exception.CustomException;
import com.be.sportizebe.global.response.BaseResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/notifications")
@Tag(name = "notification", description = "알림 API")
public class NotificationController {

private final NotificationServiceImpl notificationService;
private final UserRepository userRepository;
Comment on lines +27 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

인터페이스 주입 권장 및 계층 분리

  1. NotificationServiceImpl 대신 NotificationService 인터페이스를 주입하여 의존성 역전 원칙(DIP)을 준수하세요.
  2. UserRepository를 컨트롤러에서 직접 사용하는 것은 계층 분리 원칙에 어긋납니다. 사용자 조회 로직은 서비스 레이어에서 처리하는 것이 좋습니다.
♻️ 제안된 수정
-  private final NotificationServiceImpl notificationService;
-  private final UserRepository userRepository;
+  private final NotificationService notificationService;

서비스 메서드 시그니처를 User 대신 Long userId를 받도록 변경하고, 서비스 내부에서 사용자 조회를 처리하세요.

🤖 Prompt for AI Agents
In
`@src/main/java/com/be/sportizebe/domain/notification/controller/NotificationController.java`
around lines 27 - 28, Replace the concrete NotificationServiceImpl field in
NotificationController with the NotificationService interface and remove direct
use of UserRepository from the controller; instead update the service API (e.g.,
change methods like sendNotification(User user, ...) to sendNotification(Long
userId, ...)) and move the user lookup logic into NotificationServiceImpl so the
controller only passes the userId to NotificationService and delegates
repository access to the service layer.


@GetMapping
@Operation(summary = "알림 목록 조회", description = "나의 모든 알림을 조회합니다.")
public ResponseEntity<BaseResponse<List<NotificationResponse>>> getNotifications(
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
User user = findUserById(userAuthInfo.getId());
List<NotificationResponse> response = notificationService.getNotifications(user);
return ResponseEntity.ok(BaseResponse.success("알림 목록 조회 성공", response));
}

@GetMapping("/unread")
@Operation(summary = "읽지 않은 알림 조회", description = "읽지 않은 알림 목록을 조회합니다.")
public ResponseEntity<BaseResponse<List<NotificationResponse>>> getUnreadNotifications(
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
User user = findUserById(userAuthInfo.getId());
List<NotificationResponse> response = notificationService.getUnreadNotifications(user);
return ResponseEntity.ok(BaseResponse.success("읽지 않은 알림 조회 성공", response));
}

@GetMapping("/unread/count")
@Operation(summary = "읽지 않은 알림 개수", description = "읽지 않은 알림 개수를 조회합니다.")
public ResponseEntity<BaseResponse<Long>> getUnreadCount(
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
User user = findUserById(userAuthInfo.getId());
long count = notificationService.getUnreadCount(user);
return ResponseEntity.ok(BaseResponse.success("읽지 않은 알림 개수 조회 성공", count));
}

@PatchMapping("/{notificationId}/read")
@Operation(summary = "알림 읽음 처리", description = "알림을 읽음 처리합니다.")
public ResponseEntity<BaseResponse<Void>> markAsRead(
@Parameter(description = "알림 ID") @PathVariable Long notificationId,
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
notificationService.markAsRead(notificationId, userAuthInfo.getId());
return ResponseEntity.ok(BaseResponse.success("알림 읽음 처리 완료", null));
}
Comment thread
imjuyongp marked this conversation as resolved.

private User findUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.be.sportizebe.domain.notification.dto.response;

import com.be.sportizebe.domain.notification.entity.JoinClubRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

import java.time.LocalDateTime;

@Builder
@Schema(title = "JoinClubRequestResponse DTO", description = "가입 신청 응답")
public record JoinClubRequestResponse(
@Schema(description = "가입 신청 ID") Long id,
@Schema(description = "신청자 ID") Long userId,
@Schema(description = "신청자 닉네임") String userNickname,
@Schema(description = "신청자 프로필 이미지") String userProfileImage,
@Schema(description = "동호회 ID") Long clubId,
@Schema(description = "동호회 이름") String clubName,
@Schema(description = "신청 상태") JoinClubRequest.JoinClubRequestStatus status,
@Schema(description = "신청 일시") LocalDateTime createdAt
) {
public static JoinClubRequestResponse from(JoinClubRequest request) {
return JoinClubRequestResponse.builder()
.id(request.getId())
.userId(request.getUser().getId())
.userNickname(request.getUser().getNickname())
.userProfileImage(request.getUser().getProfileImage())
.clubId(request.getClub().getId())
.clubName(request.getClub().getName())
.status(request.getStatus())
.createdAt(request.getCreatedAt())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.be.sportizebe.domain.notification.dto.response;

import com.be.sportizebe.domain.notification.entity.Notification;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

import java.time.LocalDateTime;

@Builder
@Schema(title = "NotificationResponse DTO", description = "알림 응답")
public record NotificationResponse(
@Schema(description = "알림 ID") Long id,
@Schema(description = "알림 타입") Notification.NotificationType type,
@Schema(description = "알림 메시지") String message,
@Schema(description = "읽음 여부") Boolean isRead,
@Schema(description = "관련 가입 신청 ID") Long joinRequestId,
@Schema(description = "관련 대상 ID (댓글, 채팅 등)") Long targetId,
@Schema(description = "알림 생성 일시") LocalDateTime createdAt
) {
public static NotificationResponse from(Notification notification) {
return NotificationResponse.builder()
.id(notification.getId())
.type(notification.getType())
.message(notification.getMessage())
.isRead(notification.getIsRead())
.joinRequestId(notification.getJoinClubRequest() != null
? notification.getJoinClubRequest().getId() : null)
.targetId(notification.getTargetId())
.createdAt(notification.getCreatedAt())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.be.sportizebe.domain.notification.exception;

import com.be.sportizebe.global.exception.model.BaseErrorCode;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
@AllArgsConstructor
public enum JoinClubRequestErrorCode implements BaseErrorCode {
JOIN_REQUEST_NOT_FOUND("JOIN_001", "가입 신청을 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
JOIN_REQUEST_ALREADY_EXISTS("JOIN_002", "이미 가입 신청한 동호회입니다.", HttpStatus.CONFLICT),
ALREADY_CLUB_MEMBER("JOIN_003", "이미 가입된 동호회입니다.", HttpStatus.CONFLICT),
JOIN_REQUEST_NOT_PENDING("JOIN_004", "대기 중인 가입 신청이 아닙니다.", HttpStatus.BAD_REQUEST),
CANNOT_JOIN_OWN_CLUB("JOIN_005", "자신이 만든 동호회에는 가입 신청할 수 없습니다.", HttpStatus.BAD_REQUEST),
CLUB_FULL("JOIN_006", "동호회 정원이 가득 찼습니다.", HttpStatus.BAD_REQUEST);

private final String code;
private final String message;
private final HttpStatus status;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.be.sportizebe.domain.notification.repository;

import com.be.sportizebe.domain.club.entity.Club;
import com.be.sportizebe.domain.notification.entity.JoinClubRequest;
import com.be.sportizebe.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface JoinClubRequestRepository extends JpaRepository<JoinClubRequest, Long> {

// 특정 사용자의 특정 동호회 가입 신청 조회
Optional<JoinClubRequest> findByUserAndClub(User user, Club club);

// 특정 사용자가 특정 동호회에 이미 신청했는지 확인
boolean existsByUserAndClub(User user, Club club);

// 특정 동호회의 대기 중인 가입 신청 목록
List<JoinClubRequest> findByClubAndStatus(Club club, JoinClubRequest.JoinClubRequestStatus status);

// 특정 사용자의 가입 신청 목록
List<JoinClubRequest> findByUser(User user);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.be.sportizebe.domain.notification.repository;

import com.be.sportizebe.domain.notification.entity.Notification;
import com.be.sportizebe.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface NotificationRepository extends JpaRepository<Notification, Long> {

// 특정 사용자의 알림 목록 (최신순)
List<Notification> findByReceiverOrderByCreatedAtDesc(User receiver);

// 특정 사용자의 읽지 않은 알림 목록
List<Notification> findByReceiverAndIsReadFalseOrderByCreatedAtDesc(User receiver);

// 특정 사용자의 읽지 않은 알림 개수
long countByReceiverAndIsReadFalse(User receiver);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.be.sportizebe.domain.notification.service;

import com.be.sportizebe.domain.notification.dto.response.JoinClubRequestResponse;

import java.util.List;

public interface JoinClubRequestService {

// 가입 신청
JoinClubRequestResponse requestJoin(Long clubId, Long userId);

// 가입 신청 취소
void cancelRequest(Long requestId, Long userId);

// 가입 승인 (동호회장만)
JoinClubRequestResponse approveRequest(Long requestId, Long leaderId);

// 가입 거절 (동호회장만)
JoinClubRequestResponse rejectRequest(Long requestId, Long leaderId);

// 동호회의 대기 중인 가입 신청 목록 조회 (동호회장만)
List<JoinClubRequestResponse> getPendingRequests(Long clubId, Long leaderId);

// 내 가입 신청 목록 조회
List<JoinClubRequestResponse> getMyRequests(Long userId);
}
Loading