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
@@ -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
Expand Up @@ -18,7 +18,7 @@ public class Club extends BaseEntity {
@Column(name = "name", length = 200, nullable = false, unique = true)
private String name;

@Column(name = "club_number", length = 100, nullable = false)
@Column(name = "club_number", length = 100, nullable = false, unique = true)
private String clubNumber;

@Builder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
import com.WhoIsRoom.WhoIs_Server.domain.club.model.Club;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface ClubRepository extends JpaRepository<Club, Long> {
Optional<Club> findByClubNumber(String clubNumber);
}
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);
}
Comment on lines +71 to +89
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

동아리 가입 로직은 잘 구현되었으나, 중복 가입 방지를 확인해주세요.

ifPresent를 활용한 중복 체크는 깔끔합니다. 새 멤버의 isExistfalse로 초기화하는 것도 합리적이네요.

다만 멤버십 존재 여부를 확인(line 78)한 후 저장(line 88)하기 전에 동시 요청이 들어오면 중복 멤버십이 생성될 수 있습니다. Member 테이블에 (user_id, club_id) 유니크 제약이 있는지 확인해주세요. DB 레벨 제약이 있으면 예외가 발생할 테니 처리 로직 추가도 고려해보세요.


🏁 Script executed:

#!/bin/bash
# Description: Check if Member entity has unique constraint on user and club
# Expected: @Table annotation with uniqueConstraints or @UniqueConstraint on user+club

ast-grep --pattern $'@Entity
$$$
class Member {
  $$$
}'

rg -A 10 "class Member" --type java | rg -C 5 "unique|Unique|UNIQUE"

Length of output: 0


Member 엔티티의 유니크 제약 여부 확인을 위해 아래 스크립트를 실행해주세요.


🏁 Script executed:

#!/bin/bash
set -eux
# 1. Member.java 파일 경로 찾기
FILE=$(fd Member.java)
echo "Found Member entity at: $FILE"

# 2. 상단 어노테이션 확인 (200줄까지 출력)
sed -n '1,200p' "$FILE" | grep -C3 "@Table"

Length of output: 566


Member 엔티티에 user_id+club_id 복합 유니크 제약 추가
현재 @Table(name = "members")(user_id, club_id) 복합 유니크 제약이 없어 동시 요청 시 중복 가입이 발생할 수 있습니다. JPA의 @Table(uniqueConstraints = …) 또는 DB 마이그레이션을 통해 해당 제약을 추가해주세요.

🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/club/service/ClubService.java
around lines 71-89: the Member entity/table currently lacks a (user_id, club_id)
unique constraint which allows duplicate memberships on concurrent requests; add
the composite unique constraint to the Member entity (e.g., annotate the Member
@Table with uniqueConstraints = @UniqueConstraint(columnNames =
{"user_id","club_id"})) and create a DB migration to apply the same unique index
at the database level; additionally, update transactional error handling so a
unique constraint violation
(DataIntegrityViolationException/ConstraintViolationException) is caught and
translated to BusinessException(ErrorCode.ALREADY_MEMBER) to maintain consistent
behavior.


@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
Expand Up @@ -35,7 +35,7 @@ public Member(User user, Club club, Boolean isExist) {
this.isExist = isExist;
}

public void setExist(){
this.isExist = true;
public void setExist(boolean isExist){
this.isExist = isExist;
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package com.WhoIsRoom.WhoIs_Server.domain.member.repository;

import com.WhoIsRoom.WhoIs_Server.domain.club.model.Club;
import com.WhoIsRoom.WhoIs_Server.domain.member.model.Member;
import com.WhoIsRoom.WhoIs_Server.domain.user.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

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

public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUserId(Long userId);
Expand All @@ -18,4 +21,8 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("delete from Member m where m.user.id = :userId and m.club.id in :clubIds")
void deleteByUserIdAndClubIdIn(@Param("userId") Long userId, @Param("clubIds") Collection<Long> clubIds);

Optional<Member> findByUserAndClub(User user, Club club);
List<Member> findByUser(User user);
List<Member> findAllByClubAndIsExistTrue(Club club);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);

Optional<User> findByNickName(String nickname);
boolean existsByNickName(String nickName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

HTTP 상태 코드 불일치 및 시맨틱 문제가 있습니다.

다음 이슈들을 확인해주세요:

  1. MEMBER_NOT_FOUND (Line 30): 내부 code는 400이지만 HTTP 상태는 FORBIDDEN(403)을 사용하고 있습니다. 동아리 회원이 아닌 경우는 권한 문제(403)보다는 NOT_FOUND(404) 또는 BAD_REQUEST(400)가 더 적절해 보입니다.

  2. ALREADY_MEMBER (Line 31): 내부 code는 401이지만 HTTP 상태는 BAD_REQUEST(400)를 사용하고 있습니다. 일반적으로 401은 인증(authentication) 관련 상태 코드로 예약되어 있습니다.

일관성을 위해 다음과 같이 수정하는 것을 권장합니다:

 // 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
In
src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/response/ErrorCode.java
around lines 29-31, the numeric internal codes and HttpStatus values are
inconsistent and semantically incorrect: MEMBER_NOT_FOUND uses internal code 400
but HttpStatus.FORBIDDEN (403) — change both to a matching NOT_FOUND/404 (or at
least make internal code 404 and HttpStatus.NOT_FOUND) so the error represents a
missing member; ALREADY_MEMBER uses internal code 401 while
HttpStatus.BAD_REQUEST (400) — change both to BAD_REQUEST/400 (or at minimum set
internal code to 400 and keep HttpStatus.BAD_REQUEST) because 401 is reserved
for authentication; update the enum entries so internal code and
HttpStatus.value() match and reflect the recommended semantics.


// Attendance
ATTENDANCE_NOT_FOUND(500, HttpStatus.BAD_REQUEST.value(), "출근 기록이 없습니다."),
ALREADY_CHECKED_IN(501, HttpStatus.BAD_REQUEST.value(), "이미 출근 중입니다."),
Comment on lines +33 to +35
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

내부 에러 코드 범위 선택에 주의가 필요합니다.

Attendance 관련 에러 코드로 500번대를 사용하고 있는데, 이는 일반적으로 서버 에러를 의미하는 HTTP 5xx 상태 코드와 혼동될 수 있습니다.

  • ATTENDANCE_NOT_FOUND(500, ...): 출근 기록이 없는 것은 클라이언트 측 문제(BAD_REQUEST)인데 내부 코드가 500입니다.
  • ALREADY_CHECKED_IN(501, ...): 이미 출근 중인 것도 클라이언트 측 문제인데 내부 코드가 501입니다.

모니터링 시스템이나 로그 분석 시 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Attendance
ATTENDANCE_NOT_FOUND(500, HttpStatus.BAD_REQUEST.value(), "출근 기록이 없습니다."),
ALREADY_CHECKED_IN(501, HttpStatus.BAD_REQUEST.value(), "이미 출근 중입니다."),
// Attendance
- ATTENDANCE_NOT_FOUND(500, HttpStatus.BAD_REQUEST.value(), "출근 기록이 없습니다."),
ATTENDANCE_NOT_FOUND(450, HttpStatus.BAD_REQUEST.value(), "출근 기록이 없습니다."),
ALREADY_CHECKED_IN(451, HttpStatus.BAD_REQUEST.value(), "이미 출근 중입니다."),


// Auth
SECURITY_UNAUTHORIZED(600,HttpStatus.UNAUTHORIZED.value(), "인증 정보가 유효하지 않습니다"),
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ spring:
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: create
ddl-auto: update
properties:
hibernate:
format_sql: true
Expand Down