Skip to content

Commit 2500e13

Browse files
authored
Merge pull request #222 from GDGoCINHA/develop
Merge branch 'develop'
2 parents 6c7ddd4 + e6c0dc3 commit 2500e13

23 files changed

+1000
-119
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ HELP.md
44
**/.env.*
55
*.env
66
.env
7+
.DS_Store
78
.env.properties
89
!.env.example
910
**/.env.example

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
import java.security.NoSuchAlgorithmException;
3333
import java.util.Map;
3434
import java.util.Optional;
35+
36+
import jakarta.validation.Valid;
3537
import lombok.RequiredArgsConstructor;
3638
import lombok.extern.slf4j.Slf4j;
3739
import org.springframework.http.ResponseEntity;
@@ -92,11 +94,11 @@ public ResponseEntity<?> refreshAccessToken(
9294

9395
@PostMapping("/login")
9496
public ResponseEntity<ApiResponse<LoginResponse, Void>> login(
95-
@RequestBody UserLoginRequest userLoginRequest,
97+
@Valid @RequestBody UserLoginRequest req,
9698
HttpServletResponse response
9799
) throws NoSuchAlgorithmException, InvalidKeyException {
98-
LoginResponse loginResponse = authService.loginWithPassword(userLoginRequest, response);
99-
100+
String email = req.email().trim();
101+
LoginResponse loginResponse = authService.loginWithPassword(email, req.password(), response);
100102
return ResponseEntity.ok(ApiResponse.ok(LOGIN_WITH_PASSWORD_SUCCESS, loginResponse));
101103
}
102104

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

Lines changed: 32 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,23 @@
11
package inha.gdgoc.domain.auth.service;
22

3-
import inha.gdgoc.global.config.jwt.TokenProvider;
4-
import inha.gdgoc.domain.auth.dto.request.UserLoginRequest;
53
import inha.gdgoc.domain.auth.dto.response.LoginResponse;
64
import inha.gdgoc.domain.auth.enums.LoginType;
75
import inha.gdgoc.domain.user.entity.User;
86
import inha.gdgoc.domain.user.repository.UserRepository;
7+
import inha.gdgoc.global.config.jwt.TokenProvider;
98
import jakarta.servlet.http.HttpServletResponse;
10-
import java.security.InvalidKeyException;
11-
import java.security.NoSuchAlgorithmException;
129
import lombok.RequiredArgsConstructor;
1310
import lombok.extern.slf4j.Slf4j;
1411
import org.springframework.beans.factory.annotation.Value;
15-
import org.springframework.http.HttpEntity;
16-
import org.springframework.http.HttpHeaders;
17-
import org.springframework.http.HttpMethod;
18-
import org.springframework.http.MediaType;
19-
import org.springframework.http.ResponseCookie;
20-
import org.springframework.http.ResponseEntity;
12+
import org.springframework.http.*;
2113
import org.springframework.security.core.Authentication;
2214
import org.springframework.stereotype.Service;
2315
import org.springframework.util.LinkedMultiValueMap;
2416
import org.springframework.util.MultiValueMap;
2517
import org.springframework.web.client.RestTemplate;
2618

19+
import java.security.InvalidKeyException;
20+
import java.security.NoSuchAlgorithmException;
2721
import java.time.Duration;
2822
import java.util.Map;
2923
import java.util.Optional;
@@ -36,6 +30,9 @@
3630
public class AuthService {
3731

3832
private final RefreshTokenService refreshTokenService;
33+
private final UserRepository userRepository;
34+
private final RestTemplate restTemplate = new RestTemplate();
35+
private final TokenProvider tokenProvider;
3936

4037
@Value("${google.client-id}")
4138
private String clientId;
@@ -46,10 +43,6 @@ public class AuthService {
4643
@Value("${google.redirect-uri}")
4744
private String redirectUri;
4845

49-
private final UserRepository userRepository;
50-
private final RestTemplate restTemplate = new RestTemplate();
51-
private final TokenProvider tokenProvider;
52-
5346
public Map<String, Object> processOAuthLogin(String code, HttpServletResponse response) {
5447
// 1. code → access token 요청
5548
HttpHeaders headers = new HttpHeaders();
@@ -63,11 +56,7 @@ public Map<String, Object> processOAuthLogin(String code, HttpServletResponse re
6356
params.add("grant_type", "authorization_code");
6457

6558
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(params, headers);
66-
ResponseEntity<Map> tokenResponse = restTemplate.postForEntity(
67-
"https://oauth2.googleapis.com/token",
68-
tokenRequest,
69-
Map.class
70-
);
59+
ResponseEntity<Map> tokenResponse = restTemplate.postForEntity("https://oauth2.googleapis.com/token", tokenRequest, Map.class);
7160

7261
String googleAccessToken = (String) tokenResponse.getBody().get("access_token");
7362

@@ -76,12 +65,7 @@ public Map<String, Object> processOAuthLogin(String code, HttpServletResponse re
7665
userInfoHeaders.setBearerAuth(googleAccessToken);
7766
HttpEntity<Void> userInfoRequest = new HttpEntity<>(userInfoHeaders);
7867

79-
ResponseEntity<Map> userInfoResponse = restTemplate.exchange(
80-
"https://www.googleapis.com/oauth2/v2/userinfo",
81-
HttpMethod.GET,
82-
userInfoRequest,
83-
Map.class
84-
);
68+
ResponseEntity<Map> userInfoResponse = restTemplate.exchange("https://www.googleapis.com/oauth2/v2/userinfo", HttpMethod.GET, userInfoRequest, Map.class);
8569

8670
// 3. Google에서 가져온 이름, 이메일로 가입된 정보가 없으면 회원가입, 있으면 로그인
8771
Map userInfo = userInfoResponse.getBody();
@@ -90,66 +74,54 @@ public Map<String, Object> processOAuthLogin(String code, HttpServletResponse re
9074

9175
Optional<User> foundUser = userRepository.findByEmail(email);
9276
if (foundUser.isEmpty()) {
93-
return Map.of(
94-
"isExists", false,
95-
"email", email,
96-
"name", name
97-
);
77+
return Map.of("isExists", false, "email", email, "name", name);
9878
}
9979

10080
User user = foundUser.get();
10181

10282
String jwtAccessToken = tokenProvider.generateGoogleLoginToken(user, Duration.ofHours(1));
103-
String refreshToken = refreshTokenService.getOrCreateRefreshToken(user, Duration.ofDays(1),
104-
LoginType.GOOGLE_LOGIN);
83+
String refreshToken = refreshTokenService.getOrCreateRefreshToken(user, Duration.ofDays(1), LoginType.GOOGLE_LOGIN);
10584

10685
ResponseCookie refreshCookie = ResponseCookie.from("refresh_token", refreshToken)
107-
.httpOnly(true)
108-
.secure(true)
109-
.sameSite("None")
110-
.domain(".gdgocinha.com")
111-
.path("/")
112-
.maxAge(Duration.ofDays(1))
113-
.build();
86+
.httpOnly(true)
87+
.secure(true)
88+
.sameSite("None")
89+
.domain(".gdgocinha.com")
90+
.path("/")
91+
.maxAge(Duration.ofDays(1))
92+
.build();
11493

11594
// Set-Cookie 헤더로 추가
116-
log.info("Response Cookie에 저장된 Refresh Token: {}", refreshCookie.toString());
95+
log.info("Response Cookie에 저장된 Refresh Token: {}", refreshCookie);
11796
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
11897

119-
return Map.of(
120-
"isExists", true,
121-
"access_token", jwtAccessToken
122-
);
98+
return Map.of("isExists", true, "access_token", jwtAccessToken);
12399
}
124100

125-
public LoginResponse loginWithPassword(UserLoginRequest userLoginRequest,
126-
HttpServletResponse response)
127-
throws NoSuchAlgorithmException, InvalidKeyException {
128-
Optional<User> user = userRepository.findByEmail(userLoginRequest.email());
101+
public LoginResponse loginWithPassword(String email, String password, HttpServletResponse response) throws NoSuchAlgorithmException, InvalidKeyException {
102+
Optional<User> user = userRepository.findByEmail(email);
129103
if (user.isEmpty()) {
130104
return new LoginResponse(false, null);
131105
}
132106

133107
User foundUser = user.get();
134-
String hashedInputPassword = encrypt(userLoginRequest.password(), foundUser.getSalt());
108+
String hashedInputPassword = encrypt(password, foundUser.getSalt());
135109
if (!foundUser.getPassword().equals(hashedInputPassword)) {
136110
return new LoginResponse(false, null);
137111
}
138112

139113
String accessToken = tokenProvider.generateSelfSignupToken(foundUser, Duration.ofHours(1));
140-
String refreshToken = refreshTokenService.getOrCreateRefreshToken(foundUser,
141-
Duration.ofDays(1),
142-
LoginType.SELF_SIGNUP);
114+
String refreshToken = refreshTokenService.getOrCreateRefreshToken(foundUser, Duration.ofDays(1), LoginType.SELF_SIGNUP);
143115

144116
ResponseCookie refreshCookie = ResponseCookie.from("refresh_token", refreshToken)
145-
.httpOnly(true)
146-
.secure(true)
147-
.sameSite("None")
148-
.path("/")
149-
.maxAge(Duration.ofDays(1))
150-
.build();
151-
152-
log.info("Response Cookie에 저장된 Refresh Token: {}", refreshCookie.toString());
117+
.httpOnly(true)
118+
.secure(true)
119+
.sameSite("None")
120+
.path("/")
121+
.maxAge(Duration.ofDays(1))
122+
.build();
123+
124+
log.info("Response Cookie에 저장된 Refresh Token: {}", refreshCookie);
153125
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
154126

155127
return new LoginResponse(true, accessToken);
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package inha.gdgoc.domain.core.attendance.controller;
2+
3+
import inha.gdgoc.domain.core.attendance.controller.message.CoreAttendanceMessage;
4+
import inha.gdgoc.domain.core.attendance.dto.request.CreateDateRequest;
5+
import inha.gdgoc.domain.core.attendance.dto.request.SetAttendanceRequest;
6+
import inha.gdgoc.domain.core.attendance.dto.response.DateListResponse;
7+
import inha.gdgoc.domain.core.attendance.dto.response.DaySummaryResponse;
8+
import inha.gdgoc.domain.core.attendance.dto.response.TeamResponse;
9+
import inha.gdgoc.domain.core.attendance.service.CoreAttendanceService;
10+
import inha.gdgoc.domain.user.enums.TeamType;
11+
import inha.gdgoc.domain.user.enums.UserRole;
12+
import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails;
13+
import inha.gdgoc.global.dto.response.ApiResponse;
14+
import inha.gdgoc.global.dto.response.PageMeta;
15+
import jakarta.validation.Valid;
16+
import lombok.RequiredArgsConstructor;
17+
import org.springframework.data.domain.PageImpl;
18+
import org.springframework.data.domain.PageRequest;
19+
import org.springframework.data.domain.Sort;
20+
import org.springframework.format.annotation.DateTimeFormat;
21+
import org.springframework.http.ResponseEntity;
22+
import org.springframework.security.access.prepost.PreAuthorize;
23+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
24+
import org.springframework.web.bind.annotation.*;
25+
26+
import java.time.LocalDate;
27+
import java.util.List;
28+
import java.util.Map;
29+
30+
@RestController
31+
@RequestMapping("/api/v1/core-attendance/meetings")
32+
@RequiredArgsConstructor
33+
@PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')")
34+
public class CoreAttendanceController {
35+
36+
private final CoreAttendanceService service;
37+
38+
/* ===== helpers ===== */
39+
private static TeamType requiredTeamFrom(CustomUserDetails me) {
40+
if (me.getTeam() == null) throw new IllegalArgumentException("LEAD 권한 토큰에 team 정보가 없습니다.");
41+
return me.getTeam();
42+
}
43+
44+
private static ResponseEntity<ApiResponse<Map<String, Object>, Void>> okUpdated(long updated, List<Long> ignored) {
45+
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.ATTENDANCE_ALL_SET_SUCCESS, Map.of("updated", updated, "ignoredUserIds", ignored)));
46+
}
47+
48+
/* ===== Meetings(날짜) 목록 ===== */
49+
@GetMapping
50+
public ResponseEntity<ApiResponse<DateListResponse, Void>> listDates() {
51+
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_LIST_RETRIEVED_SUCCESS, new DateListResponse(service.getDates())));
52+
}
53+
54+
@PostMapping
55+
public ResponseEntity<ApiResponse<DateListResponse, Void>> createDate(@Valid @RequestBody CreateDateRequest request) {
56+
service.addDate(request.getDate());
57+
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_CREATED_SUCCESS, new DateListResponse(service.getDates())));
58+
}
59+
60+
@DeleteMapping("/{date}")
61+
public ResponseEntity<ApiResponse<DateListResponse, Void>> deleteDate(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
62+
service.deleteDate(date.toString());
63+
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_DELETED_SUCCESS, new DateListResponse(service.getDates())));
64+
}
65+
66+
/* ===== 팀 목록 (리드=본인 팀만 / 관리자=전체) ===== */
67+
@GetMapping("/teams")
68+
public ResponseEntity<ApiResponse<List<TeamResponse>, PageMeta>> getTeams(@AuthenticationPrincipal CustomUserDetails me) {
69+
List<TeamResponse> list = (me.getRole() == UserRole.LEAD) ? service.getTeamsForLead(requiredTeamFrom(me)) : service.getTeamsForOrganizerOrAdmin();
70+
71+
var page = new PageImpl<>(list, PageRequest.of(0, Math.max(1, list.size()), Sort.by(Sort.Direction.DESC, "createdAt")), list.size());
72+
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list, PageMeta.of(page)));
73+
}
74+
75+
/* ===== 특정 날짜의 팀원+현재 출석 상태 조회 (리드=본인 팀만) ===== */
76+
// 프론트가 체크박스 채우기 전에 필요한 목록/상태
77+
@GetMapping("/{date}/members")
78+
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 // 관리자만 사용, 리드는 무시
79+
) {
80+
TeamType effectiveTeam = (me.getRole() == UserRole.LEAD) ? requiredTeamFrom(me) : team;
81+
var list = service.getMembersWithPresence(date.toString(), effectiveTeam);
82+
// list 원소 예시: { "userId": "123", "name": "홍길동", "present": true, "lastModifiedAt": "..." }
83+
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list));
84+
}
85+
86+
/* ===== 특정 날짜 출석 일괄 저장 (멱등 스냅샷) ===== */
87+
// Body: { "userIds": ["1","2",...], "present": true } → presentUserIds만 보내는 구조로도 쉽게 변환 가능
88+
@PutMapping("/{date}/attendance")
89+
public ResponseEntity<ApiResponse<Map<String, Object>, Void>> saveAttendanceSnapshot(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team, // 관리자만 사용, 리드는 무시
90+
@RequestBody @Valid SetAttendanceRequest req) {
91+
var userIds = req.safeUserIds();
92+
93+
if (me.getRole() == UserRole.LEAD) {
94+
TeamType myTeam = requiredTeamFrom(me);
95+
var validation = service.filterUserIdsNotInTeam(myTeam, userIds);
96+
if (validation.validIds().isEmpty()) {
97+
return okUpdated(0L, validation.invalidIds());
98+
}
99+
long updated = service.setAttendance(date.toString(), validation.validIds(), req.presentValue());
100+
return okUpdated(updated, validation.invalidIds());
101+
}
102+
103+
// ORGANIZER / ADMIN
104+
TeamType effectiveTeam = (team != null) ? team : service.inferTeamFromUserIds(userIds)
105+
.orElseThrow(() -> new IllegalArgumentException("userIds로 팀을 추론할 수 없습니다."));
106+
107+
var validation = service.filterUserIdsNotInTeam(effectiveTeam, userIds);
108+
if (validation.validIds().isEmpty()) {
109+
return okUpdated(0L, validation.invalidIds());
110+
}
111+
long updated = service.setAttendance(date.toString(), validation.validIds(), req.presentValue());
112+
return okUpdated(updated, validation.invalidIds());
113+
}
114+
115+
/* ===== 날짜 요약(JSON) ===== */
116+
@GetMapping("/{date}/summary")
117+
public ResponseEntity<ApiResponse<DaySummaryResponse, Void>> summary(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) {
118+
DaySummaryResponse body = (me.getRole() == UserRole.LEAD) ? service.summary(date.toString(), requiredTeamFrom(me)) : service.summary(date.toString(), team);
119+
return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.SUMMARY_RETRIEVED_SUCCESS, body));
120+
}
121+
122+
/* ===== 날짜 요약(CSV) ===== */
123+
@GetMapping(value = "/{date}/summary.csv", produces = "text/csv; charset=UTF-8")
124+
public ResponseEntity<String> summaryCsv(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) {
125+
TeamType effective = (me.getRole() == UserRole.LEAD) ? requiredTeamFrom(me) : team;
126+
String csv = service.buildSummaryCsv(date.toString(), effective); // 서비스에 구현
127+
return ResponseEntity.ok()
128+
.header("Content-Disposition", "attachment; filename=\"attendance-" + date + ".csv\"")
129+
.body(csv);
130+
}
131+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package inha.gdgoc.domain.core.attendance.controller.message;
2+
3+
public class CoreAttendanceMessage {
4+
public static final String DATE_LIST_RETRIEVED_SUCCESS = "성공적으로 출석 날짜 목록을 조회했습니다.";
5+
public static final String DATE_CREATED_SUCCESS = "성공적으로 출석 날짜를 생성했습니다.";
6+
public static final String DATE_DELETED_SUCCESS = "성공적으로 출석 날짜를 삭제했습니다.";
7+
8+
public static final String TEAM_LIST_RETRIEVED_SUCCESS = "성공적으로 팀 목록을 조회했습니다.";
9+
public static final String TEAM_CREATED_SUCCESS = "성공적으로 팀을 생성했습니다.";
10+
public static final String TEAM_UPDATED_SUCCESS = "성공적으로 팀 정보를 수정했습니다.";
11+
public static final String TEAM_DELETED_SUCCESS = "성공적으로 팀을 삭제했습니다.";
12+
13+
public static final String MEMBER_ADDED_SUCCESS = "성공적으로 팀원을 추가했습니다.";
14+
public static final String MEMBER_UPDATED_SUCCESS = "성공적으로 팀원 정보를 수정했습니다.";
15+
public static final String MEMBER_DELETED_SUCCESS = "성공적으로 팀원을 삭제했습니다.";
16+
17+
public static final String ATTENDANCE_SET_SUCCESS = "성공적으로 출석을 체크했습니다.";
18+
public static final String ATTENDANCE_ALL_SET_SUCCESS = "성공적으로 전체 출석 상태를 변경했습니다.";
19+
20+
public static final String SUMMARY_RETRIEVED_SUCCESS = "성공적으로 출석 요약을 조회했습니다.";
21+
public static final String CSV_EXPORTED_SUCCESS = "성공적으로 CSV 파일을 내보냈습니다.";
22+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package inha.gdgoc.domain.core.attendance.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import lombok.Getter;
5+
import lombok.NoArgsConstructor;
6+
import lombok.AllArgsConstructor;
7+
8+
@Getter
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
public class CreateDateRequest {
12+
@NotBlank(message = "날짜는 YYYY-MM-DD 형식이어야 합니다.")
13+
private String date;
14+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package inha.gdgoc.domain.core.attendance.dto.request;
2+
3+
import jakarta.validation.constraints.NotNull;
4+
import java.util.List;
5+
import java.util.Objects;
6+
7+
public record SetAttendanceRequest(
8+
@NotNull List<Long> userIds,
9+
@NotNull Boolean present
10+
) {
11+
public List<Long> safeUserIds() {
12+
return userIds == null ? List.of() : userIds.stream().filter(Objects::nonNull).toList();
13+
}
14+
public boolean presentValue() {
15+
return Boolean.TRUE.equals(present);
16+
}
17+
}

0 commit comments

Comments
 (0)