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 @@ -58,7 +58,35 @@ public ResponseEntity<List<AttendanceResponse>> getAttendancesByRound(
* 라운드 내 특정 유저 출석 상태 수정(관리자/OWNER)
* PUT /api/attendance/rounds/{roundId}/users/{userId}
*/
@Operation(summary = "출석 상태 수정", description = "특정 라운드에서 특정 유저의 출석 상태를 수정합니다. (관리자/OWNER)")
@Operation(
summary = "출석 상태 수정",
description = """

## 인증(JWT): **필요**


## 권한
- **세션 관리자 / OWNER**

## 경로 파라미터
- **`roundId`**: 출석 상태를 수정할 라운드 ID (`UUID`)
- **`userId`**: 출석 상태를 수정할 대상 사용자 ID (`UUID`)

## 요청 바디 ( `AttendanceStatusUpdateRequest` )
- **`status`**: 출석 상태 (필수)
- 허용값 예시: `PRESENT`, `LATE`, `ABSENT`, `EXCUSED`
- **`reason`**: 상태 수정 사유 (선택)
- 예: 지각 사유, 공결 사유 등

## 동작 설명
- 특정 라운드에서 특정 사용자의 출석 상태를 수정합니다.
- 요청한 사용자가 해당 세션의 관리자/OWNER인지 검증합니다.
- `status` 값과 `reason` 값을 기반으로 출석 상태를 반영합니다.

## 응답
- **200 OK**
- 수정된 출석 정보 (`AttendanceResponse`)
""")
@PutMapping("/rounds/{roundId}/users/{userId}")
public ResponseEntity<AttendanceResponse> updateAttendanceStatus(
@PathVariable UUID roundId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,31 @@ public class AttendanceRoundController {
/**
* 라운드 생성 (관리자/OWNER) POST /api/attendance/sessions/{sessionId}/rounds
*/
@Operation(summary = "라운드 생성", description = "세션에 새로운 출석 라운드를 생성합니다. (관리자/OWNER)")
@Operation(summary = "라운드 생성",
description = """

## 인증(JWT): **필요**


## 권한
- **세션 관리자 / OWNER**

## 경로 파라미터
- **`sessionId`**: 라운드를 생성할 출석 세션 ID (`UUID`)

## 요청 바디 ( `AttendanceRoundRequest` )
- **`roundDate`**: 라운드 날짜 (`yyyy-MM-dd`)
- **`startAt`**: 출석 시작 시간 (`yyyy-MM-dd'T'HH:mm:ss`)
- **`closeAt`**: 출석 마감 시간 (`yyyy-MM-dd'T'HH:mm:ss`)
- 선택값 (null 가능)
- null이면 서버에서 자동 계산하도록 구현할 수 있습니다.
- **`roundName`**: 라운드 이름 (예: 1주차, OT 출석)
- **`locationName`**: 출석 위치명 (예: 공학관 301호)

## 동작 설명
- 지정한 세션에 새로운 출석 라운드를 생성합니다.
- 요청한 사용자가 해당 세션의 관리자/OWNER인지 검증합니다.
""")
@PostMapping("/sessions/{sessionId}/rounds")
public ResponseEntity<AttendanceRoundResponse> createRound(
@PathVariable UUID sessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import org.sejongisc.backend.common.redis.RedisService;
import org.sejongisc.backend.user.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.mail.MailException;
import org.springframework.mail.MailSendException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
Expand All @@ -32,6 +34,7 @@ public class EmailService {
private final RedisService redisService;
private final SpringTemplateEngine templateEngine;
private final UserRepository userRepository;
private final RedisTemplate<String, String> redisTemplate;
private final EmailProperties emailProperties;

// 메일 발신자
Expand Down Expand Up @@ -124,33 +127,23 @@ private String generateCode() {

// 비밀번호 인증 관련 메서드
public void sendResetEmail(String email) {
if(!userRepository.existsByEmail(email)){
log.debug("Password reset requested for non-existent email {}", email);
return;
}

String code = generateCode();
//비밀번호 재설정 인증 코드 저장
redisService.set(RedisKey.PASSWORD_RESET_EMAIL, email, code);
String key = emailProperties.getKeyPrefix().getReset() + email;
if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
redisTemplate.delete(key);
}
redisTemplate.opsForValue().set(key, code, emailProperties.getCodeExpire());

try {
MimeMessage message = createResetMessage(email, code);
mailSender.send(message);
} catch (MessagingException e) {
} catch (MessagingException | MailException e) {
redisTemplate.delete(key);
throw new MailSendException("failed to send mail", e);
}
}

public void verifyResetEmail(String email, String code) {
//비밀번호 재설정 인증 코드 조회
String stored = redisService.get(RedisKey.PASSWORD_RESET_EMAIL, email, String.class);

if (stored == null) throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND);
if(!stored.equals(code)) throw new CustomException(ErrorCode.EMAIL_CODE_MISMATCH);

//인증코드 삭제
redisService.delete(RedisKey.PASSWORD_RESET_EMAIL, email);
}

private MimeMessage createResetMessage(String email, String code) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class EmailProperties {
public static class KeyPrefix {
private String verify;
private String verified;
private String reset;
}

@Setter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public class SecurityConstants {
"/login/**",
//"/oauth2/**",
"/favicon.ico",
"/api/user/password/reset/confirm",
"/api/user/password/reset/send",
"/actuator",
"/actuator/**",
"/error"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ public enum ErrorCode {

EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST, "24시간 이내에 이미 인증된 이메일입니다."),

RESET_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "비밀번호 재설정 코드가 만료되었습니다."),

INVALID_RESET_CODE(HttpStatus.BAD_REQUEST, "유효하지 않은 비밀번호 재설정 코드입니다."),

// QUANTBOT
EXECUTION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 퀀트봇 실행 내역이 존재하지 않습니다."),

Expand All @@ -97,6 +101,8 @@ public enum ErrorCode {
INVALID_EXCEL_STRUCTURE(HttpStatus.UNPROCESSABLE_ENTITY, "엑셀 양식이 일치하지 않습니다. 필수 컬럼을 확인해주세요."),
EMPTY_FILE(HttpStatus.BAD_REQUEST, "업로드된 파일이 비어있습니다."),



// BETTING

STOCK_NOT_FOUND(HttpStatus.NOT_FOUND, "주식 종목이 존재하지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,58 @@ public ResponseEntity<?> findUserID(@RequestBody @Valid UserIdFindRequest reques
}
*/

// TODO : 비밀번호 재설정 시 학번 입력 고려
@Operation(summary = "비밀번호 재설정 : 이메일로 인증코드를 전송합니다.")
@Operation(
summary = "비밀번호 재설정 : 이메일로 인증코드를 전송합니다.",
description = """

## 인증(JWT): **불필요**

## 요청 바디 ( `PasswordResetSendRequest` )
- **`email`**: 가입된 이메일
- **`studentId`**: 가입된 학번

## 동작 설명
- 입력한 이메일 + 학번으로 사용자를 확인합니다.
- 일치하는 사용자가 있으면 인증코드를 생성합니다.
- 인증코드를 Redis에 일정 시간 저장합니다. (TTL 적용)
- 해당 이메일로 인증코드를 전송합니다.

## 반환값
- 성공 메시지 (`인증코드를 전송했습니다.`)
""")
@PostMapping("/password/reset/send")
public ResponseEntity<?> sendReset(@RequestBody @Valid PasswordResetSendRequest req){
userService.passwordReset(req.email().trim());
userService.passwordResetSendCode(req);
return ResponseEntity.ok(Map.of("message", "인증코드를 전송했습니다."));
}

@Operation(
summary = "비밀번호 재설정 : 인증코드와 새 비밀번호를 입력받아, 비밀번호를 변경합니다.",
description = """

## 인증(JWT): **불필요**


## 요청 파라미터
- **`code`**: 이메일로 받은 인증코드
- **`newPassword`**: 새 비밀번호

## 요청 바디 ( `PasswordResetSendRequest` )
- **`email`**: 가입된 이메일
- **`studentId`**: 가입된 학번

## 동작 설명
- 이메일 + 학번으로 사용자를 다시 확인합니다.
- Redis에 저장된 인증코드와 입력한 `code`를 비교합니다.
- 인증코드가 일치하면 새 비밀번호를 정책 검증 후 암호화하여 저장합니다.
- 사용한 인증코드는 Redis에서 삭제합니다. (1회성)

## 반환값
- 성공 메시지 (`비밀번호가 변경되었습니다.`)
""")
@PostMapping("/password/reset/confirm")
public ResponseEntity<?> confirmReset(@RequestBody @Valid PasswordResetConfirmRequest req){
userService.resetPasswordByCode(req);
return ResponseEntity.ok(Map.of("message", "비밀번호가 변경되었습니다."));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record PasswordResetVerifyRequest(
public record PasswordResetConfirmRequest(

@Schema(
example = "testuser@example.com",
Expand All @@ -21,5 +21,21 @@ public record PasswordResetVerifyRequest(
)
@NotBlank(message = "인증코드는 필수입니다.")
@Size(min = 6, max = 6, message = "인증코드는 6자리여야 합니다.")
String code
String code,

@NotBlank(message = "학번은 필수입니다.")
String studentId,

@NotBlank(message = "새 비밀번호는 필수입니다.")
@Schema(
description = "변경할 비밀번호 (변경 시에만 포함)",
example = "Newpassword123!"
)
@Size(min = 8, message = "비밀번호는 최소 8자 이상 입력해야 합니다.")
String newPassword





) {}
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ public record PasswordResetSendRequest(
)
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
String email
String email,
@NotBlank(message = "학번은 필수입니다.")
String studentId
) { }
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ public interface UserRepository extends JpaRepository<User, UUID> {
boolean existsByEmail(String email);
boolean existsByPhoneNumber(String phoneNumber);
boolean existsByEmailOrStudentId(String email, String studentId);
boolean existsByEmailAndStudentId(String email, String studentId);
boolean existsByStudentId(String studentId);

Optional<User> findUserByEmail(String email);
Optional<User> findByEmailAndStudentId(String email, String studentId);

@Query(
"SELECT u FROM User u " +
Expand Down
Loading