Skip to content

Commit 9e52b01

Browse files
authored
Merge pull request #286 from CSE-Shaco/develop
refactor/recruit-member-and-recruit-core-repository-cleanup
2 parents 87c5313 + 4f5d49d commit 9e52b01

File tree

24 files changed

+259
-141
lines changed

24 files changed

+259
-141
lines changed

database_schema.sql

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ CREATE TABLE IF NOT EXISTS users (
1919
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
2020
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
2121
);
22+
CREATE INDEX IF NOT EXISTS idx_users_student_id ON users(student_id);
23+
CREATE INDEX IF NOT EXISTS idx_users_phone_number ON users(phone_number);
24+
CREATE INDEX IF NOT EXISTS idx_users_email_lower ON users((lower(email)));
2225

2326
-- 2. 리크루팅 멤버 (recruit_member)
2427
CREATE TABLE IF NOT EXISTS recruit_member (
@@ -33,12 +36,14 @@ CREATE TABLE IF NOT EXISTS recruit_member (
3336
gender VARCHAR(20) NOT NULL,
3437
birth DATE NOT NULL,
3538
major VARCHAR(255) NOT NULL,
36-
double_major VARCHAR(255),
3739
is_payed BOOLEAN NOT NULL DEFAULT FALSE,
3840
admission_semester VARCHAR(10) NOT NULL,
3941
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
4042
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
4143
);
44+
CREATE INDEX IF NOT EXISTS idx_recruit_member_email_lower ON recruit_member((lower(email)));
45+
CREATE INDEX IF NOT EXISTS idx_recruit_member_created_at ON recruit_member(created_at DESC);
46+
CREATE INDEX IF NOT EXISTS idx_recruit_member_name_lower ON recruit_member((lower(name)));
4247

4348
-- 3. 답변 (answer) - recruit_member와 1:N
4449
CREATE TABLE IF NOT EXISTS answer (
@@ -50,6 +55,8 @@ CREATE TABLE IF NOT EXISTS answer (
5055
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
5156
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
5257
);
58+
CREATE INDEX IF NOT EXISTS idx_answer_recruit_member_survey_type
59+
ON answer(recruit_member, survey_type);
5360

5461
-- 4. 코어 멤버 지원 (core_recruit_applications)
5562
CREATE TABLE IF NOT EXISTS core_recruit_applications (
@@ -74,6 +81,10 @@ CREATE TABLE IF NOT EXISTS core_recruit_applications (
7481
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
7582
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
7683
);
84+
CREATE INDEX IF NOT EXISTS idx_core_recruit_user_session
85+
ON core_recruit_applications(user_id, session);
86+
CREATE INDEX IF NOT EXISTS idx_core_recruit_session_status_team_created
87+
ON core_recruit_applications(session, result_status, team, created_at DESC);
7788

7889
-- 5. 스터디 (study)
7990
CREATE TABLE IF NOT EXISTS study (

src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java

Lines changed: 13 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,32 @@
11
package inha.gdgoc.domain.auth.controller;
22

3+
import static inha.gdgoc.domain.auth.controller.message.AuthMessage.*;
4+
5+
import inha.gdgoc.domain.auth.dto.request.CheckPhoneNumberRequest;
6+
import inha.gdgoc.domain.auth.dto.request.CheckStudentIdRequest;
37
import inha.gdgoc.domain.auth.dto.request.LoginRequest;
48
import inha.gdgoc.domain.auth.dto.request.SignupRequest;
59
import inha.gdgoc.domain.auth.dto.request.TokenRefreshRequest;
610
import inha.gdgoc.domain.auth.dto.response.AccessTokenResponse;
711
import inha.gdgoc.domain.auth.dto.response.AuthUserResponse;
812
import inha.gdgoc.domain.auth.dto.response.CheckPhoneNumberResponse;
913
import inha.gdgoc.domain.auth.dto.response.CheckStudentIdResponse;
10-
import inha.gdgoc.domain.auth.dto.response.LoginSuccessResponse;
1114
import inha.gdgoc.domain.auth.exception.AuthErrorCode;
1215
import inha.gdgoc.domain.auth.exception.AuthException;
1316
import inha.gdgoc.domain.auth.service.AuthService;
1417
import inha.gdgoc.domain.user.enums.TeamType;
1518
import inha.gdgoc.domain.user.enums.UserRole;
16-
import inha.gdgoc.global.config.jwt.JwtProperties;
1719
import inha.gdgoc.global.config.jwt.TokenProvider;
1820
import inha.gdgoc.global.dto.response.ApiResponse;
1921
import inha.gdgoc.global.exception.GlobalErrorCode;
20-
import inha.gdgoc.global.security.AccessGuard;
2122
import jakarta.validation.Valid;
22-
import jakarta.validation.constraints.NotBlank;
23-
import jakarta.validation.constraints.Pattern;
2423
import lombok.RequiredArgsConstructor;
2524
import lombok.extern.slf4j.Slf4j;
26-
import org.springframework.beans.factory.annotation.Value;
27-
import org.springframework.http.HttpHeaders;
2825
import org.springframework.http.HttpStatus;
29-
import org.springframework.http.ResponseCookie;
3026
import org.springframework.http.ResponseEntity;
3127
import org.springframework.security.core.annotation.AuthenticationPrincipal;
32-
import org.springframework.web.bind.annotation.*;
3328
import org.springframework.util.StringUtils;
34-
35-
import java.time.Duration;
36-
37-
import static inha.gdgoc.domain.auth.controller.message.AuthMessage.*;
29+
import org.springframework.web.bind.annotation.*;
3830

3931
@Slf4j
4032
@RequestMapping("/api/v1/auth")
@@ -43,8 +35,6 @@
4335
public class AuthController {
4436

4537
private final AuthService authService;
46-
private final AccessGuard accessGuard;
47-
private final JwtProperties jwtProperties;
4838

4939
// 1. 구글 로그인 (ID Token 검증)
5040
@PostMapping("/login")
@@ -70,25 +60,19 @@ public ResponseEntity<?> signup(@Valid @RequestBody SignupRequest request) {
7060
}
7161
}
7262

73-
@GetMapping("/check/student-id")
63+
@PostMapping("/check/student-id")
7464
public ResponseEntity<ApiResponse<CheckStudentIdResponse, Void>> duplicatedStudentIdDetails(
75-
@RequestParam
76-
@NotBlank(message = "학번은 필수 입력 값입니다.")
77-
@Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.")
78-
String studentId
65+
@Valid @RequestBody CheckStudentIdRequest request
7966
) {
80-
CheckStudentIdResponse response = authService.isRegisteredStudentId(studentId);
67+
CheckStudentIdResponse response = authService.isRegisteredStudentId(request.getStudentId());
8168
return ResponseEntity.ok(ApiResponse.ok(STUDENT_ID_DUPLICATION_CHECK_SUCCESS, response));
8269
}
8370

84-
@GetMapping("/check/phone-number")
71+
@PostMapping("/check/phone-number")
8572
public ResponseEntity<ApiResponse<CheckPhoneNumberResponse, Void>> duplicatedPhoneNumberDetails(
86-
@RequestParam
87-
@NotBlank(message = "전화번호는 필수 입력 값입니다.")
88-
@Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 또는 010XXXXXXXX 이어야 합니다.")
89-
String phoneNumber
73+
@Valid @RequestBody CheckPhoneNumberRequest request
9074
) {
91-
CheckPhoneNumberResponse response = authService.isRegisteredPhoneNumber(phoneNumber);
75+
CheckPhoneNumberResponse response = authService.isRegisteredPhoneNumber(request.getPhoneNumber());
9276
return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response));
9377
}
9478

@@ -135,16 +119,7 @@ public ResponseEntity<?> logout(@RequestBody(required = false) TokenRefreshReque
135119
null
136120
));
137121
}
138-
139-
var conditions = new java.util.ArrayList<AccessGuard.AccessCondition>();
140-
conditions.add(AccessGuard.AccessCondition.atLeast(role));
141-
142-
if (requiredTeam != null) {
143-
conditions.add(AccessGuard.AccessCondition.atLeast(UserRole.ORGANIZER));
144-
conditions.add(AccessGuard.AccessCondition.of(UserRole.GUEST, requiredTeam));
145-
}
146-
147-
if (accessGuard.check(me, conditions.toArray(AccessGuard.AccessCondition[]::new))) {
122+
if (authService.hasRequiredAccess(me, role, requiredTeam)) {
148123
return ResponseEntity.ok(ApiResponse.ok("ROLE_OR_TEAM_CHECK_PASSED", null));
149124
}
150125

@@ -154,5 +129,5 @@ public ResponseEntity<?> logout(@RequestBody(required = false) TokenRefreshReque
154129
GlobalErrorCode.FORBIDDEN_USER.getMessage(),
155130
null
156131
));
157-
}
132+
}
158133
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package inha.gdgoc.domain.auth.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Pattern;
5+
import lombok.Getter;
6+
import lombok.Setter;
7+
8+
@Getter
9+
@Setter
10+
public class CheckPhoneNumberRequest {
11+
12+
@NotBlank(message = "전화번호는 필수 입력 값입니다.")
13+
@Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 또는 010XXXXXXXX 이어야 합니다.")
14+
private String phoneNumber;
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package inha.gdgoc.domain.auth.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Pattern;
5+
import lombok.Getter;
6+
import lombok.Setter;
7+
8+
@Getter
9+
@Setter
10+
public class CheckStudentIdRequest {
11+
12+
@NotBlank(message = "학번은 필수 입력 값입니다.")
13+
@Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.")
14+
private String studentId;
15+
}

src/main/java/inha/gdgoc/domain/auth/service/AuthService.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@
1313
import inha.gdgoc.domain.auth.dto.response.SignupNeededResponse;
1414
import inha.gdgoc.domain.auth.dto.response.TokenDto;
1515
import inha.gdgoc.domain.user.entity.User;
16+
import inha.gdgoc.domain.user.enums.TeamType;
17+
import inha.gdgoc.domain.user.enums.UserRole;
1618
import inha.gdgoc.domain.user.repository.UserRepository;
1719
import inha.gdgoc.global.config.jwt.TokenProvider;
20+
import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails;
21+
import inha.gdgoc.global.security.AccessGuard;
1822
import java.io.IOException;
1923
import java.security.GeneralSecurityException;
2024
import java.time.Duration;
@@ -41,6 +45,7 @@ public class AuthService {
4145
private final UserRepository userRepository;
4246
private final TokenProvider tokenProvider;
4347
private final StringRedisTemplate redisTemplate;
48+
private final AccessGuard accessGuard;
4449

4550
@Value("${google.client-id}")
4651
private String googleClientId;
@@ -91,6 +96,9 @@ public LoginSuccessResponse signup(SignupRequest request) {
9196

9297
// 전화번호 정규화 (숫자만 남김)
9398
String cleanPhone = request.getPhoneNumber().replaceAll("[^0-9]", "");
99+
if (userRepository.existsByPhoneNumber(cleanPhone)) {
100+
throw new IllegalArgumentException("이미 존재하는 전화번호입니다.");
101+
}
94102

95103
// 유저 엔티티 생성 및 저장
96104
User newUser =
@@ -125,6 +133,18 @@ public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) {
125133
return new CheckPhoneNumberResponse(exists);
126134
}
127135

136+
public boolean hasRequiredAccess(CustomUserDetails me, UserRole role, TeamType requiredTeam) {
137+
var conditions = new java.util.ArrayList<AccessGuard.AccessCondition>();
138+
conditions.add(AccessGuard.AccessCondition.atLeast(role));
139+
140+
if (requiredTeam != null) {
141+
conditions.add(AccessGuard.AccessCondition.atLeast(UserRole.ORGANIZER));
142+
conditions.add(AccessGuard.AccessCondition.of(UserRole.GUEST, requiredTeam));
143+
}
144+
145+
return accessGuard.check(me, conditions.toArray(AccessGuard.AccessCondition[]::new));
146+
}
147+
128148
public RefreshResult refresh(String refreshToken) {
129149
RefreshSession session = resolveRefreshSession(refreshToken);
130150

src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import inha.gdgoc.domain.core.attendance.dto.response.TeamResponse;
99
import inha.gdgoc.domain.core.attendance.service.CoreAttendanceService;
1010
import inha.gdgoc.domain.user.enums.TeamType;
11-
import inha.gdgoc.domain.user.enums.UserRole;
1211
import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails;
1312
import inha.gdgoc.global.dto.response.ApiResponse;
1413
import inha.gdgoc.global.dto.response.PageMeta;
@@ -44,12 +43,6 @@ public class CoreAttendanceController {
4443

4544
private final CoreAttendanceService service;
4645

47-
/* ===== helpers ===== */
48-
private static TeamType requiredTeamFrom(CustomUserDetails me) {
49-
if (me.getTeam() == null) throw new IllegalArgumentException("LEAD 권한 토큰에 team 정보가 없습니다.");
50-
return me.getTeam();
51-
}
52-
5346
private static ResponseEntity<ApiResponse<Map<String, Object>, Void>> okUpdated(long updated, List<Long> ignored) {
5447
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.ATTENDANCE_ALL_SET_SUCCESS, Map.of("updated", updated, "ignoredUserIds", ignored)));
5548
}
@@ -77,7 +70,9 @@ public ResponseEntity<ApiResponse<DateListResponse, Void>> deleteDate(@PathVaria
7770
/* ===== 팀 목록 (리드=본인 팀만 / 관리자=전체) ===== */
7871
@GetMapping("/teams")
7972
public ResponseEntity<ApiResponse<List<TeamResponse>, PageMeta>> getTeams(@AuthenticationPrincipal CustomUserDetails me) {
80-
List<TeamResponse> list = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? service.getTeamsForLead(requiredTeamFrom(me)) : service.getTeamsForOrganizerOrAdmin();
73+
List<TeamResponse> list = service.isLeadScoped(me.getRole(), me.getTeam())
74+
? service.getTeamsForLead(service.resolveEffectiveTeam(me.getRole(), me.getTeam(), null))
75+
: service.getTeamsForOrganizerOrAdmin();
8176

8277
var page = new PageImpl<>(list, PageRequest.of(0, Math.max(1, list.size()), Sort.by(Sort.Direction.DESC, "createdAt")), list.size());
8378
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list, PageMeta.of(page)));
@@ -88,7 +83,7 @@ public ResponseEntity<ApiResponse<List<TeamResponse>, PageMeta>> getTeams(@Authe
8883
@GetMapping("/{date}/members")
8984
public ResponseEntity<ApiResponse<List<Map<String, Object>>, Void>> membersOfMeeting(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team // 관리자만 사용, 리드는 무시
9085
) {
91-
TeamType effectiveTeam = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? requiredTeamFrom(me) : team;
86+
TeamType effectiveTeam = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team);
9287
var list = service.getMembersWithPresence(date.toString(), effectiveTeam);
9388
// list 원소 예시: { "userId": "123", "name": "홍길동", "present": true, "lastModifiedAt": "..." }
9489
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list));
@@ -99,34 +94,28 @@ public ResponseEntity<ApiResponse<List<Map<String, Object>>, Void>> membersOfMee
9994
@PutMapping("/{date}/attendance")
10095
public ResponseEntity<ApiResponse<Map<String, Object>, Void>> saveAttendanceSnapshot(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestBody @Valid SetAttendanceRequest req) {
10196
var userIds = req.safeUserIds();
102-
103-
// LEAD → 본인 팀 검증
104-
if (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) {
105-
TeamType myTeam = requiredTeamFrom(me);
106-
var validation = service.filterUserIdsNotInTeam(myTeam, userIds);
107-
if (validation.validIds().isEmpty()) {
108-
return okUpdated(0L, validation.invalidIds());
109-
}
110-
long updated = service.setAttendance(date.toString(), validation.validIds(), req.presentValue());
111-
return okUpdated(updated, validation.invalidIds());
112-
}
113-
114-
// ORGANIZER / ADMIN → 팀 추론/검증 없이 바로 업서트
115-
long updated = service.setAttendance(date.toString(), userIds, req.presentValue());
116-
return okUpdated(updated, List.of());
97+
CoreAttendanceService.AttendanceUpdateResult result = service.saveAttendanceSnapshot(
98+
date.toString(),
99+
userIds,
100+
req.presentValue(),
101+
me.getRole(),
102+
me.getTeam()
103+
);
104+
return okUpdated(result.updatedCount(), result.ignoredUserIds());
117105
}
118106

119107
/* ===== 날짜 요약(JSON) ===== */
120108
@GetMapping("/{date}/summary")
121109
public ResponseEntity<ApiResponse<DaySummaryResponse, Void>> summary(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) {
122-
DaySummaryResponse body = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? service.summary(date.toString(), requiredTeamFrom(me)) : service.summary(date.toString(), team);
110+
TeamType effectiveTeam = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team);
111+
DaySummaryResponse body = service.summary(date.toString(), effectiveTeam);
123112
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.SUMMARY_RETRIEVED_SUCCESS, body));
124113
}
125114

126115
/* ===== 날짜 요약(CSV) ===== */
127116
@GetMapping(value = "/{date}/summary.csv", produces = "text/csv; charset=UTF-8")
128117
public ResponseEntity<String> summaryCsv(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) {
129-
TeamType effective = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? requiredTeamFrom(me) : team;
118+
TeamType effective = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team);
130119
String csv = service.buildSummaryCsv(date.toString(), effective);
131120
return ResponseEntity.ok()
132121
.header("Content-Disposition", "attachment; filename=\"attendance-" + date + ".csv\"")
@@ -138,10 +127,7 @@ public ResponseEntity<String> summaryCsvAll(
138127
@AuthenticationPrincipal CustomUserDetails me,
139128
@RequestParam(required = false) TeamType team
140129
) {
141-
// LEAD & not HR → 자신의 팀만
142-
TeamType effective = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR)
143-
? requiredTeamFrom(me)
144-
: team;
130+
TeamType effective = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team);
145131

146132
String csv = service.buildFullMatrixCsv(effective);
147133
return ResponseEntity.ok()

0 commit comments

Comments
 (0)