-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] 동아리, 출퇴근 API 구현 #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1fd3cbf
666ed12
05321f0
69fcbea
828d148
29d1fb6
d6024ed
e9ea3fd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.club.controller; | ||
|
|
||
| import com.WhoIsRoom.WhoIs_Server.domain.club.dto.response.ClubPresenceResponse; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.club.dto.response.ClubResponse; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.club.dto.response.MyClubsResponse; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.club.service.ClubService; | ||
| import com.WhoIsRoom.WhoIs_Server.global.common.response.BaseResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.web.bind.annotation.*; | ||
|
|
||
| @Slf4j | ||
| @RestController | ||
| @RequiredArgsConstructor | ||
| @RequestMapping("/api/clubs") | ||
| public class ClubController { | ||
|
|
||
| private final ClubService clubService; | ||
|
|
||
| @PostMapping("/{clubId}/check-in") | ||
| public BaseResponse<Void> checkIn(@PathVariable final Long clubId) { | ||
| clubService.checkIn(clubId); | ||
| return BaseResponse.ok(null); | ||
| } | ||
|
|
||
| @DeleteMapping("/{clubId}/check-out") | ||
| public BaseResponse<Void> checkOut(@PathVariable final Long clubId) { | ||
| clubService.checkOut(clubId); | ||
| return BaseResponse.ok(null); | ||
| } | ||
|
|
||
| @PostMapping("/{clubId}") | ||
| public BaseResponse<Void> joinClub(@PathVariable final Long clubId) { | ||
| clubService.joinClub(clubId); | ||
| return BaseResponse.ok(null); | ||
| } | ||
|
|
||
| @GetMapping | ||
| public BaseResponse<ClubResponse> getClubByClubNumber(@RequestParam String clubNumber) { | ||
| ClubResponse response = clubService.getClubByClubNumber(clubNumber); | ||
| return BaseResponse.ok(response); | ||
| } | ||
|
|
||
| @GetMapping("/my") | ||
| public BaseResponse<MyClubsResponse> getMyClubs() { | ||
| MyClubsResponse response = clubService.getMyClubs(); | ||
| return BaseResponse.ok(response); | ||
| } | ||
|
|
||
| @GetMapping("/{clubId}/presences") | ||
| public BaseResponse<ClubPresenceResponse> getClubPresence(@PathVariable final Long clubId) { | ||
| ClubPresenceResponse response = clubService.getClubPresence(clubId); | ||
| return BaseResponse.ok(response); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.club.dto.response; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| public class ClubPresenceResponse { | ||
| private String clubName; | ||
| private List<PresenceResponse> presentMembers; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.club.dto.response; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| @AllArgsConstructor | ||
| public class ClubResponse { | ||
| private Long clubId; | ||
| private String clubName; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.club.dto.response; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| @AllArgsConstructor | ||
| public class MyClubsResponse { | ||
| private List<ClubResponse> userClubs; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.club.dto.response; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonProperty; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| public class PresenceResponse { | ||
| private String userName; | ||
|
|
||
| @JsonProperty("isMe") | ||
| private boolean me; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| package com.WhoIsRoom.WhoIs_Server.domain.club.service; | ||
|
|
||
| import com.WhoIsRoom.WhoIs_Server.domain.club.dto.response.ClubPresenceResponse; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.club.dto.response.ClubResponse; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.club.dto.response.MyClubsResponse; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.club.dto.response.PresenceResponse; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.club.model.Club; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.club.repository.ClubRepository; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.member.model.Member; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.member.repository.MemberRepository; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.user.model.User; | ||
| import com.WhoIsRoom.WhoIs_Server.domain.user.repository.UserRepository; | ||
| import com.WhoIsRoom.WhoIs_Server.global.common.exception.BusinessException; | ||
| import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.security.core.context.SecurityContextHolder; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class ClubService { | ||
| private final ClubRepository clubRepository; | ||
| private final UserRepository userRepository; | ||
| private final MemberRepository memberRepository; | ||
|
|
||
| @Transactional | ||
| public void checkIn(Long clubId) { | ||
| User user = getCurrentUser(); | ||
|
|
||
| Club club = clubRepository.findById(clubId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.CLUB_NOT_FOUND)); | ||
|
|
||
| Member member = memberRepository.findByUserAndClub(user, club) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); | ||
|
|
||
| if (Boolean.TRUE.equals(member.getIsExist())) { | ||
| throw new BusinessException(ErrorCode.ALREADY_CHECKED_IN); | ||
| } | ||
|
|
||
| member.setExist(true); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void checkOut(Long clubId) { | ||
| User user = getCurrentUser(); | ||
|
|
||
| Club club = clubRepository.findById(clubId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.CLUB_NOT_FOUND)); | ||
|
|
||
| Member member = memberRepository.findByUserAndClub(user, club) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); | ||
|
|
||
| if (Boolean.FALSE.equals(member.getIsExist())) { | ||
| throw new BusinessException(ErrorCode.ATTENDANCE_NOT_FOUND); | ||
| } | ||
|
|
||
| member.setExist(false); | ||
| } | ||
|
|
||
| private User getCurrentUser() { | ||
| String nickname = SecurityContextHolder.getContext().getAuthentication().getName(); | ||
| return userRepository.findByNickName(nickname) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void joinClub(Long clubId) { | ||
| User user = getCurrentUser(); | ||
|
|
||
| Club club = clubRepository.findById(clubId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.CLUB_NOT_FOUND)); | ||
|
|
||
| memberRepository.findByUserAndClub(user, club).ifPresent(member -> { | ||
| throw new BusinessException(ErrorCode.ALREADY_MEMBER); | ||
| }); | ||
|
|
||
| Member member = Member.builder() | ||
| .user(user) | ||
| .club(club) | ||
| .isExist(false) | ||
| .build(); | ||
|
|
||
| memberRepository.save(member); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public ClubResponse getClubByClubNumber(String clubNumber) { | ||
| Club club = clubRepository.findByClubNumber(clubNumber) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.CLUB_NOT_FOUND)); | ||
|
|
||
| return new ClubResponse(club.getId(), club.getName()); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public MyClubsResponse getMyClubs() { | ||
| User user = getCurrentUser(); | ||
|
|
||
| List<Member> members = memberRepository.findByUser(user); | ||
|
|
||
| List<ClubResponse> userClubs = members.stream() | ||
| .map(member -> ClubResponse.builder() | ||
| .clubId(member.getClub().getId()) | ||
| .clubName(member.getClub().getName()) | ||
| .build()) | ||
| .toList(); | ||
|
|
||
| return MyClubsResponse.builder() | ||
| .userClubs(userClubs) | ||
| .build(); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public ClubPresenceResponse getClubPresence(Long clubId) { | ||
| User user = getCurrentUser(); | ||
|
|
||
| Club club = clubRepository.findById(clubId) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.CLUB_NOT_FOUND)); | ||
|
|
||
| memberRepository.findByUserAndClub(user, club) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); | ||
|
|
||
| List<Member> presentMembers = memberRepository.findAllByClubAndIsExistTrue(club); | ||
|
|
||
| List<PresenceResponse> response = presentMembers.stream() | ||
| .map(member -> new PresenceResponse( | ||
| member.getUser().getNickName(), | ||
| member.getUser().getId().equals(user.getId()) | ||
| )) | ||
| .toList(); | ||
|
|
||
| return new ClubPresenceResponse(club.getName(), response); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -20,11 +20,19 @@ public enum ErrorCode{ | |||||||||||||||
|
|
||||||||||||||||
| // User | ||||||||||||||||
| USER_NOT_FOUND(200, HttpStatus.NOT_FOUND.value(), "사용자를 찾을 수 없습니다."), | ||||||||||||||||
| USER_DUPLICATE_EMAIL(201, HttpStatus.BAD_REQUEST.value(), "중복된 이메일의 시용자가 있습니다."), | ||||||||||||||||
| USER_DUPLICATE_EMAIL(201, HttpStatus.BAD_REQUEST.value(), "중복된 이메일의 사용자가 있습니다."), | ||||||||||||||||
| USER_DUPLICATE_NICKNAME(202, HttpStatus.BAD_REQUEST.value(), "중복된 닉네임의 사용자가 있습니다."), | ||||||||||||||||
|
|
||||||||||||||||
| // Club | ||||||||||||||||
| CLUB_NOT_FOUND(300, HttpStatus.NOT_FOUND.value(), "동아리를 찾을 수 없습니다."), | ||||||||||||||||
| CLUB_NOT_FOUND(300, HttpStatus.NOT_FOUND.value(), "해당 동아리가 존재하지 않습니다."), | ||||||||||||||||
|
|
||||||||||||||||
| // Member | ||||||||||||||||
| MEMBER_NOT_FOUND(400, HttpStatus.FORBIDDEN.value(), "해당 동아리의 회원이 아닙니다."), | ||||||||||||||||
| ALREADY_MEMBER(401, HttpStatus.BAD_REQUEST.value(), "이미 동아리에 가입된 사용자입니다."), | ||||||||||||||||
|
Comment on lines
+29
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HTTP 상태 코드 불일치 및 시맨틱 문제가 있습니다. 다음 이슈들을 확인해주세요:
일관성을 위해 다음과 같이 수정하는 것을 권장합니다: // Member
-MEMBER_NOT_FOUND(400, HttpStatus.FORBIDDEN.value(), "해당 동아리의 회원이 아닙니다."),
-ALREADY_MEMBER(401, HttpStatus.BAD_REQUEST.value(), "이미 동아리에 가입된 사용자입니다."),
+MEMBER_NOT_FOUND(400, HttpStatus.NOT_FOUND.value(), "해당 동아리의 회원이 아닙니다."),
+ALREADY_MEMBER(401, HttpStatus.CONFLICT.value(), "이미 동아리에 가입된 사용자입니다."),또는 BAD_REQUEST로 통일: // Member
-MEMBER_NOT_FOUND(400, HttpStatus.FORBIDDEN.value(), "해당 동아리의 회원이 아닙니다."),
+MEMBER_NOT_FOUND(400, HttpStatus.BAD_REQUEST.value(), "해당 동아리의 회원이 아닙니다."),🤖 Prompt for AI Agents |
||||||||||||||||
|
|
||||||||||||||||
| // Attendance | ||||||||||||||||
| ATTENDANCE_NOT_FOUND(500, HttpStatus.BAD_REQUEST.value(), "출근 기록이 없습니다."), | ||||||||||||||||
| ALREADY_CHECKED_IN(501, HttpStatus.BAD_REQUEST.value(), "이미 출근 중입니다."), | ||||||||||||||||
|
Comment on lines
+33
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 내부 에러 코드 범위 선택에 주의가 필요합니다. Attendance 관련 에러 코드로 500번대를 사용하고 있는데, 이는 일반적으로 서버 에러를 의미하는 HTTP 5xx 상태 코드와 혼동될 수 있습니다.
모니터링 시스템이나 로그 분석 시 5xx 코드는 서버 에러로 간주되어 혼란을 줄 수 있습니다. 클라이언트 에러는 4xx 범위의 내부 코드를 사용하는 것을 권장합니다: // Attendance
-ATTENDANCE_NOT_FOUND(500, HttpStatus.BAD_REQUEST.value(), "출근 기록이 없습니다."),
-ALREADY_CHECKED_IN(501, HttpStatus.BAD_REQUEST.value(), "이미 출근 중입니다."),
+ATTENDANCE_NOT_FOUND(500, HttpStatus.BAD_REQUEST.value(), "출근 기록이 없습니다."),
+ALREADY_CHECKED_IN(501, HttpStatus.BAD_REQUEST.value(), "이미 출근 중입니다."),또는 더 명확하게 4xx 범위로 변경: // Attendance
-ATTENDANCE_NOT_FOUND(500, HttpStatus.BAD_REQUEST.value(), "출근 기록이 없습니다."),
-ALREADY_CHECKED_IN(501, HttpStatus.BAD_REQUEST.value(), "이미 출근 중입니다."),
+ATTENDANCE_NOT_FOUND(450, HttpStatus.BAD_REQUEST.value(), "출근 기록이 없습니다."),
+ALREADY_CHECKED_IN(451, HttpStatus.BAD_REQUEST.value(), "이미 출근 중입니다."),📝 Committable suggestion
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| // Auth | ||||||||||||||||
| SECURITY_UNAUTHORIZED(600,HttpStatus.UNAUTHORIZED.value(), "인증 정보가 유효하지 않습니다"), | ||||||||||||||||
|
|
||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
동아리 가입 로직은 잘 구현되었으나, 중복 가입 방지를 확인해주세요.
ifPresent를 활용한 중복 체크는 깔끔합니다. 새 멤버의isExist를false로 초기화하는 것도 합리적이네요.다만 멤버십 존재 여부를 확인(line 78)한 후 저장(line 88)하기 전에 동시 요청이 들어오면 중복 멤버십이 생성될 수 있습니다. Member 테이블에
(user_id, club_id)유니크 제약이 있는지 확인해주세요. DB 레벨 제약이 있으면 예외가 발생할 테니 처리 로직 추가도 고려해보세요.🏁 Script executed:
Length of output: 0
Member 엔티티의 유니크 제약 여부 확인을 위해 아래 스크립트를 실행해주세요.
🏁 Script executed:
Length of output: 566
Member 엔티티에 user_id+club_id 복합 유니크 제약 추가
현재
@Table(name = "members")에(user_id, club_id)복합 유니크 제약이 없어 동시 요청 시 중복 가입이 발생할 수 있습니다. JPA의@Table(uniqueConstraints = …)또는 DB 마이그레이션을 통해 해당 제약을 추가해주세요.🤖 Prompt for AI Agents