diff --git a/backend/src/main/java/org/sejongisc/backend/activity/entity/ActivityLog.java b/backend/src/main/java/org/sejongisc/backend/activity/entity/ActivityLog.java index 4d1acad9..84d184cc 100644 --- a/backend/src/main/java/org/sejongisc/backend/activity/entity/ActivityLog.java +++ b/backend/src/main/java/org/sejongisc/backend/activity/entity/ActivityLog.java @@ -30,7 +30,7 @@ public class ActivityLog { @Enumerated(EnumType.STRING) @Column(nullable = false) - private ActivityType type; // ATTENDANCE, BOARD, BETTING 등 + private ActivityType activityType; // ATTENDANCE, BOARD, BETTING 등 @Column(nullable = false, length = 30) private String message; // "자유게시판에 글을 게시했어요" @@ -42,10 +42,10 @@ public class ActivityLog { private LocalDateTime createdAt; @Builder - public ActivityLog(UUID userId, String username, ActivityType type, String message, UUID targetId, String boardName) { + public ActivityLog(UUID userId, String username, ActivityType activityType, String message, UUID targetId, String boardName) { this.userId = userId; this.username = username; - this.type = type; + this.activityType = activityType; this.message = message; this.targetId = targetId; this.boardName = boardName; diff --git a/backend/src/main/java/org/sejongisc/backend/activity/event/ActivityEvent.java b/backend/src/main/java/org/sejongisc/backend/activity/event/ActivityEvent.java index b50230ec..53885f65 100644 --- a/backend/src/main/java/org/sejongisc/backend/activity/event/ActivityEvent.java +++ b/backend/src/main/java/org/sejongisc/backend/activity/event/ActivityEvent.java @@ -7,7 +7,7 @@ public record ActivityEvent( UUID userId, String username, - ActivityType type, + ActivityType activityType, String message, UUID targetId, String boardName diff --git a/backend/src/main/java/org/sejongisc/backend/activity/listener/ActivityEventListener.java b/backend/src/main/java/org/sejongisc/backend/activity/listener/ActivityEventListener.java index 24c31cd0..c85fc899 100644 --- a/backend/src/main/java/org/sejongisc/backend/activity/listener/ActivityEventListener.java +++ b/backend/src/main/java/org/sejongisc/backend/activity/listener/ActivityEventListener.java @@ -24,7 +24,7 @@ public void handleActivityEvent(ActivityEvent event) { ActivityLog log = activityLogRepository.save(ActivityLog.builder() .userId(event.userId()) .username(event.username()) - .type(event.type()) + .activityType(event.activityType()) .message(event.message()) .targetId(event.targetId()) .boardName(event.boardName()) diff --git a/backend/src/main/java/org/sejongisc/backend/activity/repository/ActivityLogRepository.java b/backend/src/main/java/org/sejongisc/backend/activity/repository/ActivityLogRepository.java index 1a0fd28f..5d28c3d0 100644 --- a/backend/src/main/java/org/sejongisc/backend/activity/repository/ActivityLogRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/activity/repository/ActivityLogRepository.java @@ -1,6 +1,7 @@ package org.sejongisc.backend.activity.repository; import org.sejongisc.backend.activity.entity.ActivityLog; +import org.sejongisc.backend.activity.entity.ActivityType; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -15,17 +16,20 @@ public interface ActivityLogRepository extends JpaRepository // 이슈 1: 메인 대시보드 실시간 로그 (최신순 20개) List findTop20ByOrderByCreatedAtDesc(); - // 이슈 2: 마이페이지 내 활동 조회 - Slice findByUserIdOrderByCreatedAtDesc(UUID userId, Pageable pageable); + // 마이페이지 내 활동 조회 + @Query("SELECT a FROM ActivityLog a WHERE a.userId = :userId " + + "AND a.activityType IN :activityTypes " + + "ORDER BY a.createdAt DESC") + List findByUserIdAndActivityTypesOrderByCreatedAtDesc(UUID userId, List activityTypes); // 이슈 3-1: 일일 방문자 수 통계 @Query("SELECT COUNT(DISTINCT a.userId) FROM ActivityLog a " + - "WHERE a.type = 'AUTH_LOGIN' AND a.createdAt BETWEEN :start AND :end") + "WHERE a.activityType = 'AUTH_LOGIN' AND a.createdAt BETWEEN :start AND :end") long countDailyUniqueVisitors(LocalDateTime start, LocalDateTime end); // 이슈 3-2: 게시판별 활동량 집계 (게시글+댓글+좋아요) @Query("SELECT a.boardName, COUNT(a) FROM ActivityLog a " + - "WHERE a.type IN ('BOARD_POST', 'BOARD_COMMENT', 'BOARD_LIKE') " + + "WHERE a.activityType IN ('BOARD_POST', 'BOARD_COMMENT', 'BOARD_LIKE') " + "AND a.createdAt BETWEEN :start AND :end " + "GROUP BY a.boardName") List countActivityByBoard(LocalDateTime start, LocalDateTime end); diff --git a/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingAdminController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBettingController.java similarity index 80% rename from backend/src/main/java/org/sejongisc/backend/betting/controller/BettingAdminController.java rename to backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBettingController.java index 37ae0a17..2f83bb97 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingAdminController.java +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBettingController.java @@ -1,16 +1,20 @@ -package org.sejongisc.backend.betting.controller; +package org.sejongisc.backend.admin.controller; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.sejongisc.backend.betting.entity.Scope; import org.sejongisc.backend.betting.service.BettingService; -import org.sejongisc.backend.betting.service.BettingScheduler; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @RequestMapping("/api/admin/bet-rounds") -public class BettingAdminController { +@Tag( + name = "00. 관리자 모의 트레이딩 관리 API", + description = "모의 트레이딩 관리 관련 API 제공" +) +public class AdminBettingController { private final BettingService bettingService; diff --git a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java index 923d4089..c4625e2e 100644 --- a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java @@ -19,7 +19,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/board/admin") +@RequestMapping("/api/admin/board") @Tag( name = "00. 관리자 게시판 관리 API", description = "게시판 생성 및 삭제 관련 API 제공" diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java index 2264f58f..201937d4 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java @@ -75,7 +75,7 @@ public void checkIn(UUID userId, String username, AttendanceRoundQrTokenRequest userId, username, ActivityType.ATTENDANCE, - username + "님이 " + round.getAttendanceSession().getTitle() + " 세션에 출석했습니다.", + round.getAttendanceSession().getTitle() + " 세션에 출석했습니다.", att.getAttendanceId(), null )); diff --git a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java index f0cc39ea..c560c5c5 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java @@ -210,7 +210,7 @@ public UserBetResponse postUserBet(UUID userId, String username, UserBetRequest userId, username, ActivityType.BETTING_JOIN, - username + "님이 모의 트레이딩 " + betRound.getTitle() + "에 참여했습니다.", + "모의 트레이딩 " + betRound.getTitle() + "에 참여했습니다.", savedBet.getUserBetId(), null )); diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java index b5907d87..d8f1dc87 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java @@ -89,7 +89,7 @@ public void createComment(CommentRequest request, UUID userId) { user.getUserId(), user.getName(), ActivityType.BOARD_COMMENT, - user.getName() + "님이 댓글을 달았습니다.", + "[" + post.getTitle() + "]에 댓글을 달았습니다.", post.getPostId(), post.getBoard().getBoardName())); } @@ -191,7 +191,7 @@ public void toggleLike(UUID postId, UUID userId) { user.getUserId(), user.getName(), ActivityType.BOARD_LIKE, - user.getName() + "님이 좋아요를 눌렀습니다.", + "[" + post.getTitle() + "]에 좋아요를 눌렀습니다.", post.getPostId(), post.getBoard().getBoardName())); } diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java index 07747d65..3e5ed7ad 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java @@ -78,7 +78,7 @@ public void savePost(PostRequest request, UUID userId) { userId, user.getName(), ActivityType.BOARD_POST, - user.getName() + "님이 " + "[" + board.getBoardName() + "] 게시판에 새 글을 작성했습니다.", + "[" + post.getTitle() + "]글을 게시했습니다.", post.getPostId(), board.getBoardName() )); diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthController.java b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthController.java index f5d4217f..d325670f 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthController.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthController.java @@ -1,14 +1,19 @@ package org.sejongisc.backend.common.auth.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.common.auth.dto.AuthRequest; import org.sejongisc.backend.common.auth.dto.AuthResponse; +import org.sejongisc.backend.common.auth.dto.SignupRequest; +import org.sejongisc.backend.common.auth.dto.SignupResponse; import org.sejongisc.backend.common.auth.service.AuthService; import org.sejongisc.backend.common.auth.service.RefreshTokenService; +import org.sejongisc.backend.user.dto.PasswordResetConfirmRequest; +import org.sejongisc.backend.user.dto.PasswordResetSendRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; @@ -26,7 +31,14 @@ public class AuthController { private final AuthService authService; private final RefreshTokenService refreshTokenService; - private final AuthCookieHelper cookieHelper; // 주입 + private final AuthCookieHelper cookieHelper; + + @Operation(summary = "회원 가입", description = "회장이 승인하기 전까지 PENDING 상태가 유지되며, 웹사이트를 사용할 수 없습니다.") + @ApiResponse(responseCode = "201", description = "회원가입 성공") + @PostMapping("/signup") + public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(authService.signup(request)); + } @Operation(summary = "일반 로그인 API", description = "") @PostMapping("/login") @@ -73,4 +85,56 @@ public ResponseEntity logout(@CookieValue(value = "access", required = false) .header(HttpHeaders.SET_COOKIE, cookieHelper.deleteCookie("refresh").toString()) .body(Map.of("message", "로그아웃 성공")); } + + @Operation( + summary = "비밀번호 재설정 : 이메일로 인증코드를 전송합니다.", + description = """ + + ## 인증(JWT): **불필요** + + ## 요청 바디 ( `PasswordResetSendRequest` ) + - **`email`**: 가입된 이메일 + - **`studentId`**: 가입된 학번 + + ## 동작 설명 + - 입력한 이메일 + 학번으로 사용자를 확인합니다. + - 일치하는 사용자가 있으면 인증코드를 생성합니다. + - 인증코드를 Redis에 일정 시간 저장합니다. (TTL 적용) + - 해당 이메일로 인증코드를 전송합니다. + + ## 반환값 + - 성공 메시지 (`인증코드를 전송했습니다.`) + """) + @PostMapping("/password/reset/send") + public ResponseEntity sendReset(@RequestBody @Valid PasswordResetSendRequest req){ + authService.passwordResetSendCode(req); + return ResponseEntity.ok(Map.of("message", "인증코드를 전송했습니다.")); + } + + @Operation( + summary = "비밀번호 재설정 : 인증코드와 새 비밀번호를 입력받아, 비밀번호를 변경합니다.", + description = """ + + ## 인증(JWT): **불필요** + + ## 요청 바디 ( `PasswordResetConfirmRequest` ) + - **`email`**: 가입된 이메일 + - **`studentId`**: 가입된 학번 + - **`code`**: 이메일로 받은 인증코드 + - **`newPassword`**: 새 비밀번호 + + ## 동작 설명 + - 이메일 + 학번으로 사용자를 다시 확인합니다. + - Redis에 저장된 인증코드와 입력한 `code`를 비교합니다. + - 인증코드가 일치하면 새 비밀번호를 정책 검증 후 암호화하여 저장합니다. + - 사용한 인증코드는 Redis에서 삭제합니다. (1회성) + + ## 반환값 + - 성공 메시지 (`비밀번호가 변경되었습니다.`) + """) + @PostMapping("/password/reset/confirm") + public ResponseEntity confirmReset(@RequestBody @Valid PasswordResetConfirmRequest req){ + authService.resetPasswordByCode(req); + return ResponseEntity.ok(Map.of("message", "비밀번호가 변경되었습니다.")); + } } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java index de29700b..f3c0b0fd 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java @@ -5,17 +5,32 @@ import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.activity.entity.ActivityType; import org.sejongisc.backend.activity.event.ActivityEvent; +import org.sejongisc.backend.common.annotation.OptimisticRetry; +import org.sejongisc.backend.common.auth.dto.SignupRequest; +import org.sejongisc.backend.common.auth.dto.SignupResponse; import org.sejongisc.backend.common.auth.entity.RefreshToken; -import org.sejongisc.backend.common.auth.repository.RefreshTokenRepository; import org.sejongisc.backend.common.auth.jwt.JwtUtils; +import org.sejongisc.backend.common.auth.repository.RefreshTokenRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.common.redis.RedisKey; +import org.sejongisc.backend.common.redis.RedisService; +import org.sejongisc.backend.point.dto.AccountEntry; +import org.sejongisc.backend.point.entity.Account; +import org.sejongisc.backend.point.entity.AccountName; +import org.sejongisc.backend.point.entity.TransactionReason; +import org.sejongisc.backend.point.service.AccountService; +import org.sejongisc.backend.point.service.PointLedgerService; +import org.sejongisc.backend.user.dto.PasswordResetConfirmRequest; +import org.sejongisc.backend.user.dto.PasswordResetSendRequest; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.common.auth.dto.AuthRequest; import org.sejongisc.backend.common.auth.dto.AuthResponse; import org.sejongisc.backend.user.entity.User; +import org.sejongisc.backend.user.util.PasswordPolicyValidator; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -30,10 +45,51 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final JwtUtils jwtUtils; private final RefreshTokenRepository refreshTokenRepository; - - private final JwtParser jwtParser; + + private final AccountService accountService; + private final PointLedgerService pointLedgerService; private final ApplicationEventPublisher eventPublisher; + private final EmailService emailService; + private final RedisService redisService; + private final RefreshTokenService refreshTokenService; + + @Transactional + @OptimisticRetry + public SignupResponse signup(SignupRequest request) { + if (userRepository.existsByStudentId(request.getStudentId())) { + throw new CustomException(ErrorCode.DUPLICATE_USER); + } + + if (userRepository.existsByPhoneNumber(request.getPhoneNumber())) { + throw new CustomException(ErrorCode.DUPLICATE_PHONE); + } + String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(request.getPassword()); + String encodedPw = passwordEncoder.encode(trimmedPassword); + User user = User.createUserWithSignupAndPending(request, encodedPw); + + try { + User saved = userRepository.save(user); + Account userAccount = accountService.createUserAccount(user.getUserId()); + pointLedgerService.processTransaction( + TransactionReason.SIGNUP_REWARD, + user.getUserId(), + AccountEntry.credit(accountService.getAccountByName(AccountName.SYSTEM_ISSUANCE), 100L), + AccountEntry.debit(userAccount, 100L) + ); + log.info("포인트 계정 생성 및 초기 포인트 지급 완료: {}", user.getEmail()); + eventPublisher.publishEvent(new ActivityEvent( + user.getUserId(), + user.getName(), + ActivityType.SIGNUP, + "일반 회원가입을 신청했습니다.", + null, null)); + return SignupResponse.from(saved); + } catch (DataIntegrityViolationException e) { + throw new CustomException(ErrorCode.DUPLICATE_USER); + } + } + @Transactional public AuthResponse login(AuthRequest request) { User user = userRepository.findByStudentId(request.getStudentId()) @@ -73,7 +129,7 @@ public AuthResponse login(AuthRequest request) { user.getUserId(), user.getName(), ActivityType.AUTH_LOGIN, - user.getName() + "님이 로그인하셨습니다.", + "로그인 했습니다.", null, null )); return AuthResponse.builder() @@ -93,4 +149,77 @@ public void logout(String accessToken) { refreshTokenRepository.deleteByUserId(userId); log.info("로그아웃 완료: userId={}", userId); } + + + public void passwordResetSendCode(PasswordResetSendRequest req) { + String nEmail = validateNotBlank(req.email(), "이메일").trim(); + String nStudentId = validateNotBlank(req.studentId(), "학번").trim(); + + if (!userRepository.existsByEmailAndStudentId(nEmail, nStudentId)) { + log.debug("이메일과 학번 불일치: email={}, studentId={}", nEmail, nStudentId); + throw new CustomException(ErrorCode.USER_NOT_FOUND); + } + emailService.sendResetEmail(nEmail); + } + + @Transactional + public void resetPasswordByCode(PasswordResetConfirmRequest req) { + + String email = validateNotBlank(req.email(), "이메일").trim(); + String studentId = validateNotBlank(req.studentId(), "학번").trim(); + String inputCode = validateNotBlank(req.code(), "인증코드").trim(); + + // 사용자 조회 (이메일+학번 같이 확인하는 게 안전) + User user = userRepository.findByEmailAndStudentId(email, studentId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // Redis에서 인증코드 조회 + String savedCode = redisService.get(RedisKey.PASSWORD_RESET, email, String.class); + + if (savedCode == null) { + throw new CustomException(ErrorCode.RESET_CODE_EXPIRED); + } + + if (!savedCode.equals(inputCode)) { + throw new CustomException(ErrorCode.INVALID_RESET_CODE); + } + + // 새 비밀번호 검증 + 암호화 저장 + String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(req.newPassword()); + user.setPasswordHash(passwordEncoder.encode(trimmedPassword)); + + // 인증코드 1회성 처리 (삭제) + redisService.delete(RedisKey.PASSWORD_RESET, email); + + // 기존 로그인 토큰 무효화 + refreshTokenService.deleteByUserId(user.getUserId()); + } + + + private String validateNotBlank(String value, String fieldName) { + if (value == null || value.trim().isEmpty()) { + log.debug(fieldName); + throw new CustomException(ErrorCode.INVALID_INPUT); + } + return value.trim(); + } + + private String getEmailFromRedis(String token) { + try { + String email = redisService.get(RedisKey.PASSWORD_RESET, token, String.class); + if (email == null) throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND); + return email; + } catch (Exception e) { + log.error("Redis 조회 실패", e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private void deleteResetTokenFromRedis(String token) { + try { + redisService.delete(RedisKey.PASSWORD_RESET, token); + } catch (Exception e) { + log.error("Redis 삭제 실패", e); + } + } } diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java index 1750db5e..07d156f0 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java @@ -16,7 +16,6 @@ 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; @@ -34,7 +33,6 @@ public class EmailService { private final RedisService redisService; private final SpringTemplateEngine templateEngine; private final UserRepository userRepository; - private final RedisTemplate redisTemplate; private final EmailProperties emailProperties; // 메일 발신자 @@ -101,16 +99,31 @@ public void verifyEmail(String email, String code) { if (storedCode == null) throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND); if (!storedCode.equals(code)) throw new CustomException(ErrorCode.EMAIL_CODE_MISMATCH); - // 인증 성공 시 Redis에서 인증 코드 삭제 redisService.delete(RedisKey.EMAIL_VERIFY, email); // 인증 완료 상태(true값) 저장 (24시간 유효) redisService.set(RedisKey.EMAIL_VERIFIED, email, "true"); + } + // 비밀번호 인증 관련 메서드 + public void sendResetEmail(String email) { + if (redisService.hasKey(RedisKey.PASSWORD_RESET, email)) { + redisService.delete(RedisKey.PASSWORD_RESET, email); + } + String code = generateCode(); + redisService.set(RedisKey.PASSWORD_RESET, email, code); + try { + MimeMessage message = createResetMessage(email, code); + mailSender.send(message); + } catch (MessagingException | MailException e) { + redisService.delete(RedisKey.PASSWORD_RESET, email); + throw new MailSendException("failed to send mail", e); + } } + // 이메일 인증 코드 생성 private String generateCode() { String charset = emailProperties.getCode().getCharset(); @@ -125,26 +138,6 @@ private String generateCode() { return sb.toString(); } - // 비밀번호 인증 관련 메서드 - public void sendResetEmail(String email) { - - String code = generateCode(); - 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 | MailException e) { - redisTemplate.delete(key); - throw new MailSendException("failed to send mail", e); - } - } - - private MimeMessage createResetMessage(String email, String code) throws MessagingException { MimeMessage message = mailSender.createMimeMessage(); message.setFrom(new InternetAddress(from)); @@ -159,7 +152,4 @@ private MimeMessage createResetMessage(String email, String code) throws Messagi message.setText(body, "UTF-8", "html"); return message; } - - - } diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java b/backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java index cc28ddf1..ea738553 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java @@ -11,24 +11,12 @@ @Setter @Configuration public class EmailProperties { - private Duration codeExpire; - private Duration verifiedExpire; - private KeyPrefix keyPrefix; private Code code; - @Setter - @Getter - public static class KeyPrefix { - private String verify; - private String verified; - private String reset; - } - @Setter @Getter public static class Code { private String charset; // 문자 세트 private int length; // 기본 길이 } - } diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java index 45b2ab68..2245646f 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java @@ -2,23 +2,18 @@ public class SecurityConstants { public static final String[] WHITELIST_URLS = { - "/api/user/signup", + "/api/auth/signup", "/api/auth/login", - "/api/auth/login/**", - "/api/auth/logout", "/api/auth/reissue", - "/api/user/password/reset/**", "/api/email/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/swagger-resources/**", "/webjars/**", - "/login/**", - //"/oauth2/**", "/favicon.ico", - "/api/user/password/reset/confirm", - "/api/user/password/reset/send", + "/api/auth/password/reset/confirm", + "/api/auth/password/reset/send", "/actuator", "/actuator/**", "/error" diff --git a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java index 18fc97b1..173f7379 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java +++ b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java @@ -6,19 +6,23 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.activity.entity.ActivityLog; import org.sejongisc.backend.common.auth.controller.AuthCookieHelper; import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.sejongisc.backend.common.auth.dto.SignupRequest; import org.sejongisc.backend.common.auth.dto.SignupResponse; +import org.sejongisc.backend.common.auth.service.AuthService; import org.sejongisc.backend.common.auth.service.RefreshTokenService; import org.sejongisc.backend.user.dto.*; import org.sejongisc.backend.user.service.UserService; +import org.springframework.data.domain.Slice; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; import java.util.Map; @RestController @@ -31,13 +35,6 @@ public class UserController { private final UserService userService; private final AuthCookieHelper authCookieHelper; - @Operation(summary = "회원 가입", description = "회장이 승인하기 전까지 PENDING 상태가 유지되며, 웹사이트를 사용할 수 없습니다.") - @ApiResponse(responseCode = "201", description = "회원가입 성공") - @PostMapping("/signup") - public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { - return ResponseEntity.status(HttpStatus.CREATED).body(userService.signup(request)); - } - @Operation(summary = "회원 탈퇴", description = "UserStatus.OUT 으로 변경하여 softDelete 처리 후, 리프레시 토큰을 삭제합니다.") @DeleteMapping("/withdraw") public ResponseEntity withdraw(@AuthenticationPrincipal CustomUserDetails user) { @@ -61,67 +58,15 @@ public ResponseEntity updateUser(@RequestBody @Valid UserUpdateRequest req return ResponseEntity.ok().build(); } - /* - @Operation(summary = "아이디 찾기") - @PostMapping("/id/find") - public ResponseEntity findUserID(@RequestBody @Valid UserIdFindRequest request) { - String email = userService.findEmailByNameAndPhone(request.name(), request.phoneNumber()); - return ResponseEntity.ok(Map.of("email", email)); - } - */ - - @Operation( - summary = "비밀번호 재설정 : 이메일로 인증코드를 전송합니다.", - description = """ - - ## 인증(JWT): **불필요** - - ## 요청 바디 ( `PasswordResetSendRequest` ) - - **`email`**: 가입된 이메일 - - **`studentId`**: 가입된 학번 - - ## 동작 설명 - - 입력한 이메일 + 학번으로 사용자를 확인합니다. - - 일치하는 사용자가 있으면 인증코드를 생성합니다. - - 인증코드를 Redis에 일정 시간 저장합니다. (TTL 적용) - - 해당 이메일로 인증코드를 전송합니다. - - ## 반환값 - - 성공 메시지 (`인증코드를 전송했습니다.`) - """) - @PostMapping("/password/reset/send") - public ResponseEntity sendReset(@RequestBody @Valid PasswordResetSendRequest req){ - userService.passwordResetSendCode(req); - return ResponseEntity.ok(Map.of("message", "인증코드를 전송했습니다.")); + @Operation(summary = "내 출석 로그 조회") + @GetMapping("/logs/attendance") + public ResponseEntity> getAttendanceLogs(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + return ResponseEntity.ok(userService.getAttendanceActivityLog(customUserDetails.getUserId())); } - @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", "비밀번호가 변경되었습니다.")); + @Operation(summary = "내 활동 로그 조회") + @GetMapping("/logs/board") + public ResponseEntity> getBoardLogs(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + return ResponseEntity.ok(userService.getBoardActivityLog(customUserDetails.getUserId())); } } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java index 05176584..f583d33b 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java @@ -2,26 +2,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.activity.entity.ActivityLog; import org.sejongisc.backend.activity.entity.ActivityType; -import org.sejongisc.backend.activity.event.ActivityEvent; -import org.sejongisc.backend.common.auth.dto.SignupRequest; -import org.sejongisc.backend.common.auth.dto.SignupResponse; -import org.sejongisc.backend.common.auth.service.EmailService; +import org.sejongisc.backend.activity.repository.ActivityLogRepository; import org.sejongisc.backend.common.auth.service.RefreshTokenService; -import org.sejongisc.backend.common.annotation.OptimisticRetry; -import org.sejongisc.backend.common.config.EmailProperties; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.common.redis.RedisKey; -import org.sejongisc.backend.common.redis.RedisService; -import org.sejongisc.backend.point.dto.AccountEntry; -import org.sejongisc.backend.point.entity.Account; -import org.sejongisc.backend.point.entity.AccountName; -import org.sejongisc.backend.point.entity.TransactionReason; -import org.sejongisc.backend.point.service.AccountService; -import org.sejongisc.backend.point.service.PointLedgerService; -import org.sejongisc.backend.user.dto.PasswordResetConfirmRequest; -import org.sejongisc.backend.user.dto.PasswordResetSendRequest; import org.sejongisc.backend.user.dto.UserUpdateRequest; import org.sejongisc.backend.user.entity.Grade; import org.sejongisc.backend.user.entity.Role; @@ -29,9 +15,6 @@ import org.sejongisc.backend.user.entity.UserStatus; import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.util.PasswordPolicyValidator; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -47,53 +30,11 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; - private final EmailService emailService; - private final RedisTemplate redisTemplate; - private final RedisService redisService; private final RefreshTokenService refreshTokenService; - private final AccountService accountService; - private final PointLedgerService pointLedgerService; - private final EmailProperties emailProperties; - private final ApplicationEventPublisher eventPublisher; + private final ActivityLogRepository activityLogRepository; // --- 핵심 회원 서비스 --- - @Transactional - @OptimisticRetry - public SignupResponse signup(SignupRequest request) { - if (userRepository.existsByStudentId(request.getStudentId())) { - throw new CustomException(ErrorCode.DUPLICATE_USER); - } - - if (userRepository.existsByPhoneNumber(request.getPhoneNumber())) { - throw new CustomException(ErrorCode.DUPLICATE_PHONE); - } - String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(request.getPassword()); - String encodedPw = passwordEncoder.encode(trimmedPassword); - User user = User.createUserWithSignupAndPending(request, encodedPw); - - try { - User saved = userRepository.save(user); - Account userAccount = accountService.createUserAccount(user.getUserId()); - pointLedgerService.processTransaction( - TransactionReason.SIGNUP_REWARD, - user.getUserId(), - AccountEntry.credit(accountService.getAccountByName(AccountName.SYSTEM_ISSUANCE), 100L), - AccountEntry.debit(userAccount, 100L) - ); - log.info("포인트 계정 생성 및 초기 포인트 지급 완료: {}", user.getEmail()); - eventPublisher.publishEvent(new ActivityEvent( - user.getUserId(), - user.getName(), - ActivityType.SIGNUP, - user.getName() + "님이 일반 회원가입을 신청했습니다.", - null, null)); - return SignupResponse.from(saved); - } catch (DataIntegrityViolationException e) { - throw new CustomException(ErrorCode.DUPLICATE_USER); - } - } - @Transactional public void updateUser(UUID userId, UserUpdateRequest request) { User user = userRepository.findById(userId) @@ -119,61 +60,28 @@ public void updateUser(UUID userId, UserUpdateRequest request) { log.info("회원 정보 수정 완료: userId={}", userId); } - public void passwordResetSendCode(PasswordResetSendRequest req) { - String nEmail = validateNotBlank(req.email(), "이메일").trim(); - String nStudentId = validateNotBlank(req.studentId(), "학번").trim(); + @Transactional(readOnly = true) + public List getAttendanceActivityLog(UUID userId) { + return activityLogRepository.findByUserIdAndActivityTypesOrderByCreatedAtDesc(userId, + List.of(ActivityType.ATTENDANCE)); + } - if (!userRepository.existsByEmailAndStudentId(nEmail, nStudentId)) { - log.debug("이메일과 학번 불일치: email={}, studentId={}", nEmail, nStudentId); - throw new CustomException(ErrorCode.USER_NOT_FOUND); - } - emailService.sendResetEmail(nEmail); + @Transactional(readOnly = true) + public List getBoardActivityLog(UUID userId) { + return activityLogRepository.findByUserIdAndActivityTypesOrderByCreatedAtDesc(userId, + List.of(ActivityType.BOARD_LIKE, ActivityType.BOARD_POST, ActivityType.BOARD_COMMENT)); } @Transactional - public void resetPasswordByCode(PasswordResetConfirmRequest req) { - - String email = validateNotBlank(req.email(), "이메일").trim(); - String studentId = validateNotBlank(req.studentId(), "학번").trim(); - String inputCode = validateNotBlank(req.code(), "인증코드").trim(); - - // 사용자 조회 (이메일+학번 같이 확인하는 게 안전) - User user = userRepository.findByEmailAndStudentId(email, studentId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - - - // Redis에서 인증코드 조회 - String redisKey = emailProperties.getKeyPrefix().getReset() + email; - - String savedCode = redisTemplate.opsForValue().get(redisKey); - - - - if (savedCode == null) { - throw new CustomException(ErrorCode.RESET_CODE_EXPIRED); - } - - if (!savedCode.equals(inputCode)) { - throw new CustomException(ErrorCode.INVALID_RESET_CODE); - } - - // 새 비밀번호 검증 + 암호화 저장 - String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(req.newPassword()); - user.setPasswordHash(passwordEncoder.encode(trimmedPassword)); - - // 인증코드 1회성 처리 (삭제) - redisTemplate.delete(redisKey); - - // 기존 로그인 토큰 무효화 - refreshTokenService.deleteByUserId(user.getUserId()); + public void deleteUserSoftDelete(UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + user.setStatus(UserStatus.OUT); + refreshTokenService.deleteByUserId(userId); + log.info("회원 softdelete 처리 완료: userId={}", userId); } - - @Transactional(readOnly = true) - public List findAllUsersMissingAccount() { - return userRepository.findAllUsersMissingAccount(); - } + // --- Admin Only 메서드 --- @Transactional public void updateUserStatus(UUID userId, UserStatus status) { @@ -200,47 +108,14 @@ public void promoteToSenior(UUID userId) { log.info("선배 등급 전환 완료: userId={}, 학번={}", userId, user.getStudentId()); } - // --- 내부 헬퍼 메서드 --- - private User findUser(UUID userId) { return userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); } - private String validateNotBlank(String value, String fieldName) { - if (value == null || value.trim().isEmpty()) { - log.debug(fieldName); - throw new CustomException(ErrorCode.INVALID_INPUT); - } - return value.trim(); - } - - private String getEmailFromRedis(String token) { - try { - String email = redisService.get(RedisKey.PASSWORD_RESET, token, String.class); - if (email == null) throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND); - return email; - } catch (Exception e) { - log.error("Redis 조회 실패", e); - throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); - } - } - - private void deleteResetTokenFromRedis(String token) { - try { - redisService.delete(RedisKey.PASSWORD_RESET, token); - } catch (Exception e) { - log.error("Redis 삭제 실패", e); - } - } - - @Transactional - public void deleteUserSoftDelete(UUID userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - user.setStatus(UserStatus.OUT); - refreshTokenService.deleteByUserId(userId); - log.info("회원 softdelete 처리 완료: userId={}", userId); + @Transactional(readOnly = true) + public List findAllUsersMissingAccount() { + return userRepository.findAllUsersMissingAccount(); } // ------------------------ (비활성화) OAuth2 관련 로직 ------------------------ diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 8922761a..388c605a 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -11,10 +11,6 @@ email: code: charset: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" length: 6 - key-prefix: - verify: "EMAIL_VERIFY:" - reset: "PASSWORD_RESET:" - code-expire: 180 logging: level: diff --git a/backend/src/test/java/org/sejongisc/backend/auth/controller/AuthControllerTest.java b/backend/src/test/java/org/sejongisc/backend/auth/controller/AuthControllerTest.java index fe138551..6db14f1d 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/controller/AuthControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/controller/AuthControllerTest.java @@ -25,7 +25,6 @@ import org.sejongisc.backend.common.exception.controller.GlobalExceptionHandler; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; -import org.sejongisc.backend.user.service.UserService; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -62,7 +61,6 @@ class AuthControllerTest { @Mock AuthService authService; - @Mock UserService userService; @Mock JwtUtils jwtUtils; @Mock OauthStateService oauthStateService; @Mock RefreshTokenService refreshTokenService; @@ -188,9 +186,8 @@ void googleLogin_success() throws Exception { when(oauthStateService.getStateFromSession(any())).thenReturn("test-state"); when(googleService.getAccessToken("test-code")).thenReturn(tokenResponse); when(googleService.getUserInfo("mock-google-access-token")).thenReturn(userInfo); - when(userService.findOrCreateUser(any())).thenReturn(user); - when(jwtUtils.createToken(user.getUserId(), user.getRole(), user.getEmail())) - .thenReturn("jwt-token"); + //when(userService.findOrCreateUser(any())).thenReturn(user); + when(jwtUtils.createToken(user.getUserId(), user.getRole(), user.getEmail())).thenReturn("jwt-token"); when(jwtUtils.createRefreshToken(user.getUserId())).thenReturn("refresh-token"); mockMvc.perform(post("/api/auth/login/GOOGLE") @@ -307,7 +304,7 @@ void signup_success() throws Exception { .build(); SignupResponse resp = SignupResponse.from(entity); - when(userService.signup(any(SignupRequest.class))).thenReturn(resp); + when(authService.signup(any(SignupRequest.class))).thenReturn(resp); mockMvc.perform(post("/api/auth/signup") .contentType(MediaType.APPLICATION_JSON) @@ -348,9 +345,7 @@ void kakaoLogin_partialCoverage() throws Exception { when(kakaoService.getAccessToken(anyString())).thenReturn(tokenResponse); when(kakaoService.getUserInfo(anyString())).thenReturn(null); // info null - when(userService.findOrCreateUser(any())).thenReturn( - User.builder().userId(UUID.randomUUID()).name("NullInfoUser").role(Role.TEAM_MEMBER).build() - ); + //when(userService.findOrCreateUser(any())).thenReturn(User.builder().userId(UUID.randomUUID()).name("NullInfoUser").role(Role.TEAM_MEMBER).build()); when(jwtUtils.createToken(any(), any(), any())).thenReturn("access-token"); when(jwtUtils.createRefreshToken(any())).thenReturn("refresh-token"); @@ -371,9 +366,7 @@ void githubLogin_partialCoverage() throws Exception { when(githubService.getAccessToken(anyString())).thenReturn(tokenResponse); when(githubService.getUserInfo(anyString())).thenReturn(null); // info null - when(userService.findOrCreateUser(any())).thenReturn( - User.builder().userId(UUID.randomUUID()).name("GH-NullUser").role(Role.TEAM_MEMBER).build() - ); + //when(userService.findOrCreateUser(any())).thenReturn(User.builder().userId(UUID.randomUUID()).name("GH-NullUser").role(Role.TEAM_MEMBER).build()); when(jwtUtils.createToken(any(), any(), any())).thenReturn("gh-token"); when(jwtUtils.createRefreshToken(any())).thenReturn("gh-refresh"); @@ -452,7 +445,7 @@ void withdraw_success() throws Exception { var auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); - doNothing().when(userService).deleteUserWithOauth(userId); + //doNothing().when(userService).deleteUserWithOauth(userId); doNothing().when(refreshTokenService).deleteByUserId(userId); mockMvc.perform(delete("/api/auth/withdraw") @@ -461,7 +454,7 @@ void withdraw_success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("회원 탈퇴가 완료되었습니다.")); - verify(userService, times(1)).deleteUserWithOauth(userId); + //verify(userService, times(1)).deleteUserWithOauth(userId); verify(refreshTokenService, times(1)).deleteByUserId(userId); } diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java index 20e37191..d47e1a8d 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java @@ -50,16 +50,11 @@ void setUp() { // value 객체 필드 세팅 ReflectionTestUtils.setField(emailService, "from", "noreply@test.com"); // EmailProperties 더미 값 세팅 - var keyPrefix = new EmailProperties.KeyPrefix(); - keyPrefix.setVerify("verify:"); - keyPrefix.setVerified("verified:"); var codeConf = new EmailProperties.Code(); codeConf.setCharset("0123456789"); codeConf.setLength(6); - given(props.getKeyPrefix()).willReturn(keyPrefix); given(props.getCode()).willReturn(codeConf); - given(props.getCodeExpire()).willReturn(Duration.ofMinutes(3)); // given(props.getVerifiedExpire()).willReturn(Duration.ofHours(24)); given(redisTemplate.opsForValue()).willReturn(valueOps); } diff --git a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java index 6f425148..8ef3f2c3 100644 --- a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java @@ -15,19 +15,18 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.sejongisc.backend.common.auth.service.AuthService; import org.sejongisc.backend.common.auth.service.EmailService; import org.sejongisc.backend.common.auth.service.RefreshTokenService; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; import org.sejongisc.backend.common.auth.repository.UserOauthAccountRepository; -import org.sejongisc.backend.common.redis.RedisKey; import org.sejongisc.backend.common.redis.RedisService; import org.sejongisc.backend.point.entity.Account; import org.sejongisc.backend.point.service.*; import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.common.auth.dto.SignupRequest; import org.sejongisc.backend.common.auth.dto.SignupResponse; -import org.sejongisc.backend.common.auth.entity.AuthProvider; import org.sejongisc.backend.user.dto.UserUpdateRequest; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; @@ -61,6 +60,7 @@ class UserServiceTest { private PointLedgerService pointLedgerService; @InjectMocks private UserService userService; + @InjectMocks private AuthService authService; @Test @DisplayName("회원가입 성공: 비밀번호 인코딩, 저장, DTO 매핑 확인") @@ -95,7 +95,7 @@ void signup_success() { }); // when - SignupResponse res = userService.signup(req); + SignupResponse res = authService.signup(req); // then assertAll( @@ -129,7 +129,7 @@ void signup_duplicateEmail_throws() { when(userRepository.existsByStudentId(req.getStudentId())).thenReturn(false); // when - CustomException ex = assertThrows(CustomException.class, () -> userService.signup(req)); + CustomException ex = assertThrows(CustomException.class, () -> authService.signup(req)); // then assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.DUPLICATE_EMAIL); @@ -172,7 +172,7 @@ void signup_nullRole_defaultsToMember() { }); // when - SignupResponse res = userService.signup(req); + SignupResponse res = authService.signup(req); // then assertThat(res.getRole()).isEqualTo(Role.TEAM_MEMBER); @@ -195,7 +195,7 @@ void signup_duplicatePhone_throws() { when(userRepository.existsByStudentId(any())).thenReturn(false); // studentId 중복 아님 -> phone 중복으로 간주 // when - CustomException ex = assertThrows(CustomException.class, () -> userService.signup(req)); + CustomException ex = assertThrows(CustomException.class, () -> authService.signup(req)); // then assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.DUPLICATE_PHONE); @@ -226,7 +226,7 @@ void signup_dataIntegrityViolation_throws() { .thenThrow(new org.springframework.dao.DataIntegrityViolationException("constraint")); // when - CustomException ex = assertThrows(CustomException.class, () -> userService.signup(req)); + CustomException ex = assertThrows(CustomException.class, () -> authService.signup(req)); // then assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.DUPLICATE_USER); diff --git a/docker-compose.yml b/docker-compose.yml index 9223932d..ed466884 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,27 +12,31 @@ services: - .env environment: - TZ=Asia/Seoul # 시간대 설정 : 컨테이너 내부 환경변수 - - JAVA_OPTS=-Xmx350M -Xms200M # 자바 힙 메모리를 실행시 200MB, 최대 350MB로 고정 (도커 제한보다 작게) + - JAVA_OPTS=-Xmx2.5G -Xms1G -XX:+UseG1GC -XX:MaxGCPauseMillis=200 # 자바 힙 메모리를 2GB~ 2.5GB로 확장 (8GB 서버 기준) volumes: - /etc/localtime:/etc/localtime:ro # 시간대 설정 : 호스트의 시간 설정 파일 공유 - /etc/timezone:/etc/timezone:ro + - ./logs:/app/logs networks: - sisc-net # 1GB 램 서버 생존을 위한 메모리 제한 (Docker 레벨) deploy: resources: limits: - memory: 512M # 도커가 이 컨테이너에 줄 수 있는 최대 메모리 + memory: 3500M # JVM 힙 외 여유 공간 포함 3.5GB 할당 + reservations: + memory: 1G # 최소 1GB 보장 redis: image: redis:alpine # 가벼운 alpine 버전 사용 container_name: redis restart: always + command: redis-server --maxmemory 180mb --maxmemory-policy allkeys-lru networks: - sisc-net deploy: resources: limits: - memory: 100M # 100MB면 토큰 저장용으로 충분 + memory: 250M # 프론트엔드 (React + Nginx) web: @@ -44,6 +48,10 @@ services: # - "443:443" # HTTPS (인증서 설정 필요 시 주석 해제) networks: - sisc-net + deploy: + resources: + limits: + memory: 512M # 3. AI (deploy.yml 설정으로 개발서버에는 실행 안됨) ai: @@ -57,7 +65,10 @@ services: deploy: resources: limits: - memory: 450M # 도커가 이 컨테이너에 줄 수 있는 최대 메모리 + memory: 2G # AI 모델 로드 시 필요한 메모리 확보 + reservations: + memory: 512M + networks: sisc-net: driver: bridge \ No newline at end of file diff --git a/frontend/src/utils/auth.js b/frontend/src/utils/auth.js index ebb1cd35..2c54de5d 100644 --- a/frontend/src/utils/auth.js +++ b/frontend/src/utils/auth.js @@ -31,7 +31,7 @@ export const signUp = async ( teamName: teamName.trim(), remark: remark, }; - const res = await api.post('/api/user/signup', payload, { signal }); + const res = await api.post('/api/auth/signup', payload, { signal }); return res.data; }; @@ -65,7 +65,7 @@ export const checkVerificationNumber = async ( return res.data; }; export const resetPassword = async ({ email }, signal) => { - const res = await api.post('/api/user/password/reset/send', null, { + const res = await api.post('/api/auth/password/reset/send', null, { params: { email }, signal, });