diff --git a/src/main/java/inha/gdgoc/GdgocApplication.java b/src/main/java/inha/gdgoc/GdgocApplication.java index cda46ef3..3a23a77f 100644 --- a/src/main/java/inha/gdgoc/GdgocApplication.java +++ b/src/main/java/inha/gdgoc/GdgocApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class GdgocApplication { public static void main(String[] args) { diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/member/controller/RecruitMemberMemoAdminController.java b/src/main/java/inha/gdgoc/domain/admin/recruit/member/controller/RecruitMemberMemoAdminController.java new file mode 100644 index 00000000..5748380b --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/member/controller/RecruitMemberMemoAdminController.java @@ -0,0 +1,65 @@ +package inha.gdgoc.domain.admin.recruit.member.controller; + +import static inha.gdgoc.domain.admin.recruit.member.controller.message.RecruitMemberMemoAdminMessage.MEMBER_MEMO_NOTIFICATION_ENQUEUED; +import static inha.gdgoc.domain.admin.recruit.member.controller.message.RecruitMemberMemoAdminMessage.MEMBER_MEMO_NOTIFICATION_FAILED_RETRIED; +import static inha.gdgoc.domain.admin.recruit.member.controller.message.RecruitMemberMemoAdminMessage.MEMBER_MEMO_NOTIFICATION_TEMPLATE_RETRIEVED; + +import inha.gdgoc.domain.admin.recruit.member.dto.request.RecruitMemberMemoOpeningNotificationRequest; +import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoFailedRetryResponse; +import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoOpeningNotificationEnqueueResponse; +import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoNotificationTemplateResponse; +import inha.gdgoc.domain.admin.recruit.member.service.RecruitMemberMemoAdminService; +import inha.gdgoc.global.dto.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/admin/recruit/member/memo/notifications") +public class RecruitMemberMemoAdminController { + + private static final String LEAD_OR_HR_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD)," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).of(" + + "T(inha.gdgoc.domain.user.enums.UserRole).CORE," + + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; + + private final RecruitMemberMemoAdminService adminService; + + @Operation(summary = "신입생 지원 오픈 알림 메일 기본 문구 조회", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize(LEAD_OR_HR_RULE) + @GetMapping("/template") + public ResponseEntity> getTemplate() { + RecruitMemberMemoNotificationTemplateResponse response = adminService.getTemplate(); + return ResponseEntity.ok(ApiResponse.ok(MEMBER_MEMO_NOTIFICATION_TEMPLATE_RETRIEVED, response)); + } + + @Operation(summary = "신입생 지원 오픈 알림 메일 큐 적재", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize(LEAD_OR_HR_RULE) + @PostMapping("/opening") + public ResponseEntity> enqueueOpening( + @Valid @RequestBody RecruitMemberMemoOpeningNotificationRequest request + ) { + RecruitMemberMemoOpeningNotificationEnqueueResponse response = adminService.enqueueOpeningNotifications(request); + return ResponseEntity.ok(ApiResponse.ok(MEMBER_MEMO_NOTIFICATION_ENQUEUED, response)); + } + + @Operation(summary = "신입생 지원 오픈 알림 메일 실패 건 재시도", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize(LEAD_OR_HR_RULE) + @PostMapping("/retry-failed") + public ResponseEntity> retryFailed() { + RecruitMemberMemoFailedRetryResponse response = adminService.retryFailedNotifications(); + return ResponseEntity.ok(ApiResponse.ok(MEMBER_MEMO_NOTIFICATION_FAILED_RETRIED, response)); + } +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/member/controller/message/RecruitMemberMemoAdminMessage.java b/src/main/java/inha/gdgoc/domain/admin/recruit/member/controller/message/RecruitMemberMemoAdminMessage.java new file mode 100644 index 00000000..ec27595e --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/member/controller/message/RecruitMemberMemoAdminMessage.java @@ -0,0 +1,11 @@ +package inha.gdgoc.domain.admin.recruit.member.controller.message; + +public final class RecruitMemberMemoAdminMessage { + + public static final String MEMBER_MEMO_NOTIFICATION_ENQUEUED = "신입생 지원 알림 메일 발송 작업을 큐잉했습니다."; + public static final String MEMBER_MEMO_NOTIFICATION_TEMPLATE_RETRIEVED = "신입생 지원 알림 기본 문구를 조회했습니다."; + public static final String MEMBER_MEMO_NOTIFICATION_FAILED_RETRIED = "신입생 지원 알림 실패 건을 재시도 큐에 반영했습니다."; + + private RecruitMemberMemoAdminMessage() { + } +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/request/RecruitMemberMemoOpeningNotificationRequest.java b/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/request/RecruitMemberMemoOpeningNotificationRequest.java new file mode 100644 index 00000000..fd08a09c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/request/RecruitMemberMemoOpeningNotificationRequest.java @@ -0,0 +1,15 @@ +package inha.gdgoc.domain.admin.recruit.member.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RecruitMemberMemoOpeningNotificationRequest( + @NotBlank(message = "메일 제목은 필수입니다.") + @Size(max = 200, message = "메일 제목은 200자 이하여야 합니다.") + String subject, + + @NotBlank(message = "메일 본문은 필수입니다.") + @Size(max = 5000, message = "메일 본문은 5000자 이하여야 합니다.") + String body +) { +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/response/RecruitMemberMemoFailedRetryResponse.java b/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/response/RecruitMemberMemoFailedRetryResponse.java new file mode 100644 index 00000000..3e7a0925 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/response/RecruitMemberMemoFailedRetryResponse.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.admin.recruit.member.dto.response; + +public record RecruitMemberMemoFailedRetryResponse( + String semester, + int retriedCount +) { +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/response/RecruitMemberMemoNotificationTemplateResponse.java b/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/response/RecruitMemberMemoNotificationTemplateResponse.java new file mode 100644 index 00000000..b8ad79c2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/response/RecruitMemberMemoNotificationTemplateResponse.java @@ -0,0 +1,10 @@ +package inha.gdgoc.domain.admin.recruit.member.dto.response; + +public record RecruitMemberMemoNotificationTemplateResponse( + String semester, + String defaultSubject, + String defaultBody, + String lastSubject, + String lastBody +) { +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/response/RecruitMemberMemoOpeningNotificationEnqueueResponse.java b/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/response/RecruitMemberMemoOpeningNotificationEnqueueResponse.java new file mode 100644 index 00000000..44eb8522 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/member/dto/response/RecruitMemberMemoOpeningNotificationEnqueueResponse.java @@ -0,0 +1,21 @@ +package inha.gdgoc.domain.admin.recruit.member.dto.response; + +import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationEnqueueResult; + +public record RecruitMemberMemoOpeningNotificationEnqueueResponse( + String semester, + int distinctTargetCount, + int enqueuedCount, + int alreadyProcessedCount +) { + public static RecruitMemberMemoOpeningNotificationEnqueueResponse from( + RecruitMemberMemoNotificationEnqueueResult result + ) { + return new RecruitMemberMemoOpeningNotificationEnqueueResponse( + result.semester(), + result.distinctTargetCount(), + result.enqueuedCount(), + result.alreadyProcessedCount() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/member/service/RecruitMemberMemoAdminService.java b/src/main/java/inha/gdgoc/domain/admin/recruit/member/service/RecruitMemberMemoAdminService.java new file mode 100644 index 00000000..94770c6b --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/member/service/RecruitMemberMemoAdminService.java @@ -0,0 +1,50 @@ +package inha.gdgoc.domain.admin.recruit.member.service; + +import inha.gdgoc.domain.admin.recruit.member.dto.request.RecruitMemberMemoOpeningNotificationRequest; +import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoFailedRetryResponse; +import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoOpeningNotificationEnqueueResponse; +import inha.gdgoc.domain.admin.recruit.member.dto.response.RecruitMemberMemoNotificationTemplateResponse; +import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationEnqueueResult; +import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationRetryResult; +import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationService; +import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationTemplateInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RecruitMemberMemoAdminService { + + private final RecruitMemberMemoNotificationService notificationService; + + @Transactional(readOnly = true) + public RecruitMemberMemoNotificationTemplateResponse getTemplate() { + RecruitMemberMemoNotificationTemplateInfo info = notificationService.getTemplateInfoForCurrentSemester(); + return new RecruitMemberMemoNotificationTemplateResponse( + info.semester(), + info.defaultSubject(), + info.defaultBody(), + info.lastSubject(), + info.lastBody() + ); + } + + @Transactional + public RecruitMemberMemoOpeningNotificationEnqueueResponse enqueueOpeningNotifications( + RecruitMemberMemoOpeningNotificationRequest request + ) { + RecruitMemberMemoNotificationEnqueueResult result = + notificationService.enqueueOpeningNotificationsForCurrentSemester( + request.subject(), + request.body() + ); + return RecruitMemberMemoOpeningNotificationEnqueueResponse.from(result); + } + + @Transactional + public RecruitMemberMemoFailedRetryResponse retryFailedNotifications() { + RecruitMemberMemoNotificationRetryResult result = notificationService.retryFailedForCurrentSemester(); + return new RecruitMemberMemoFailedRetryResponse(result.semester(), result.retriedCount()); + } +} diff --git a/src/main/java/inha/gdgoc/domain/admin/user/controller/UserAdminController.java b/src/main/java/inha/gdgoc/domain/admin/user/controller/UserAdminController.java index 3e9cb60e..f15b1d37 100644 --- a/src/main/java/inha/gdgoc/domain/admin/user/controller/UserAdminController.java +++ b/src/main/java/inha/gdgoc/domain/admin/user/controller/UserAdminController.java @@ -43,6 +43,10 @@ public class UserAdminController { "@accessGuard.check(authentication," + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))"; + private static final String CORE_OR_HIGHER_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).CORE))"; private final UserAdminService userAdminService; @@ -63,7 +67,7 @@ public ResponseEntity, PageMeta>> list( } @Operation(summary = "사용자 역할/팀 수정", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize(LEAD_OR_HIGHER_RULE) + @PreAuthorize(CORE_OR_HIGHER_RULE) @PatchMapping("/{userId}/role-team") public ResponseEntity> updateRoleTeam( @AuthenticationPrincipal CustomUserDetails me, diff --git a/src/main/java/inha/gdgoc/domain/admin/user/service/UserAdminService.java b/src/main/java/inha/gdgoc/domain/admin/user/service/UserAdminService.java index 40daf625..d802c4b8 100644 --- a/src/main/java/inha/gdgoc/domain/admin/user/service/UserAdminService.java +++ b/src/main/java/inha/gdgoc/domain/admin/user/service/UserAdminService.java @@ -80,11 +80,28 @@ private Pageable rewriteSort(Pageable pageable) { @Transactional public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, UpdateUserRoleTeamRequest req) { - User editorUser = getEditor(editor); + Long editorUserId = editor.getUserId(); + UserRole editorRole; + TeamType editorTeam; + + if (editorUserId == null) { + editorRole = editor.getRole(); + editorTeam = editor.getTeam(); + if (editorRole != UserRole.ADMIN) { + throw new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER); + } + } else { + User editorUser = getEditor(editor); + editorRole = editorUser.getUserRole(); + editorTeam = editorUser.getTeam(); + } + User target = userRepository.findById(targetUserId) .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); - UserRole editorRole = editorUser.getUserRole(); + if (editorUserId != null && Objects.equals(editorUserId, target.getId())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신의 정보는 수정할 수 없습니다."); + } UserRole targetCurrentRole = target.getUserRole(); UserRole newRole = (req.role() != null ? req.role() : targetCurrentRole); @@ -101,41 +118,13 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat switch (editorRole) { case ADMIN -> { - if (editorUser.getId().equals(target.getId()) && newRole.rank() < UserRole.ADMIN.rank()) { - throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신을 강등할 수 없습니다."); - } } case ORGANIZER -> { if (targetCurrentRole == UserRole.ADMIN) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "ADMIN 사용자는 수정할 수 없습니다."); } } - case LEAD -> { - if (editor.getTeam() == null) { - throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD 토큰에 팀 정보가 없습니다."); - } - if (!(targetCurrentRole == UserRole.MEMBER || targetCurrentRole == UserRole.CORE)) { - throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 수정할 수 있습니다."); - } - if (!(newRole == UserRole.MEMBER || newRole == UserRole.CORE)) { - throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE로만 변경할 수 있습니다."); - } - - if (editor.getTeam() == TeamType.HR) { - if (editorUser.getId().equals(target.getId())) { - if (req.team() != null && !Objects.equals(req.team(), target.getTeam())) { - throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-LEAD도 자기 자신의 팀은 변경할 수 없습니다."); - } - } - } else { - if (target.getTeam() != editor.getTeam()) { - throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 수정할 수 없습니다."); - } - if (req.team() != null && !Objects.equals(req.team(), editor.getTeam())) { - throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 팀을 변경할 수 없습니다."); - } - } - } + case LEAD, CORE -> validateLeadAndCorePolicy(editorRole, editorTeam, target, req, targetCurrentRole, newRole); default -> throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER); } @@ -234,6 +223,48 @@ private User getEditor(CustomUserDetails editor) { .orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER)); } + private void validateLeadAndCorePolicy( + UserRole editorRole, + TeamType editorTeam, + User target, + UpdateUserRoleTeamRequest req, + UserRole targetCurrentRole, + UserRole newRole + ) { + if (editorTeam == null) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, editorRole + " 토큰에 팀 정보가 없습니다."); + } + + if (editorRole == UserRole.LEAD) { + if (!(targetCurrentRole == UserRole.MEMBER || targetCurrentRole == UserRole.CORE)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 수정할 수 있습니다."); + } + if (!(newRole == UserRole.MEMBER || newRole == UserRole.CORE)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE로만 변경할 수 있습니다."); + } + } + + if (editorRole == UserRole.CORE) { + if (!(targetCurrentRole == UserRole.GUEST || targetCurrentRole == UserRole.MEMBER)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "CORE는 GUEST/MEMBER만 수정할 수 있습니다."); + } + if (!(newRole == UserRole.GUEST || newRole == UserRole.MEMBER)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "CORE는 GUEST/MEMBER로만 변경할 수 있습니다."); + } + } + + if (editorTeam == TeamType.HR) { + return; + } + + if (!Objects.equals(target.getTeam(), editorTeam)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 수정할 수 없습니다."); + } + if (req.team() != null && !Objects.equals(req.team(), editorTeam)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, editorRole + "는 팀을 변경할 수 없습니다."); + } + } + private void targetChange(User target, UserRole newRole, TeamType newTeam) { target.changeRole(newRole); if (!isTeamAssignableRole(newRole)) { diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 05f2c3d8..0cdc1aed 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -8,7 +8,6 @@ import inha.gdgoc.domain.auth.dto.request.SignupRequest; import inha.gdgoc.domain.auth.dto.request.TokenRefreshRequest; import inha.gdgoc.domain.auth.dto.response.AccessTokenResponse; -import inha.gdgoc.domain.auth.dto.response.AuthUserResponse; import inha.gdgoc.domain.auth.dto.response.CheckPhoneNumberResponse; import inha.gdgoc.domain.auth.dto.response.CheckStudentIdResponse; import inha.gdgoc.domain.auth.exception.AuthErrorCode; @@ -48,6 +47,17 @@ public ResponseEntity login(@RequestBody LoginRequest request) { } } + @PostMapping("/admin/login") + public ResponseEntity adminLogin(@RequestBody LoginRequest request) { + try { + Object response = authService.adminLogin(request.getAdminId(), request.getPassword()); + return ResponseEntity.ok().body(ApiResponse.ok(LOGIN_SUCCESS, response)); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error(AuthErrorCode.INVALID_TOKEN.getStatus().value(), e.getMessage(), null)); + } + } + // 2. 회원가입 (추가 정보 입력) @PostMapping("/signup") public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { @@ -84,7 +94,7 @@ public ResponseEntity refreshAccessToken(@Valid @RequestBody TokenRefreshRequ return ResponseEntity.ok() .body(ApiResponse.ok( ACCESS_TOKEN_REFRESH_SUCCESS, - new AccessTokenResponse(result.accessToken(), AuthUserResponse.from(result.user())) + new AccessTokenResponse(result.accessToken(), result.user()) )); } catch (Exception e) { log.error("Token refresh failed", e); diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java index 750b0408..b0538b8d 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java @@ -7,4 +7,6 @@ @NoArgsConstructor public class LoginRequest { private String idToken; -} \ No newline at end of file + private String adminId; + private String password; +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/AuthUserResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/AuthUserResponse.java index 0349293b..41d7c1ea 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/AuthUserResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/AuthUserResponse.java @@ -31,4 +31,16 @@ public static AuthUserResponse from(User user) { .image(user.getImage()) .build(); } + + public static AuthUserResponse admin(String loginId) { + return AuthUserResponse.builder() + .id(null) + .name(loginId) + .email(null) + .userRole(UserRole.ADMIN) + .team(null) + .membershipStatus(null) + .image(null) + .build(); + } } diff --git a/src/main/java/inha/gdgoc/domain/auth/entity/AdminCredential.java b/src/main/java/inha/gdgoc/domain/auth/entity/AdminCredential.java new file mode 100644 index 00000000..d5f9db0b --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/entity/AdminCredential.java @@ -0,0 +1,52 @@ +package inha.gdgoc.domain.auth.entity; + +import inha.gdgoc.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "admin_credentials") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdminCredential extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "login_id", nullable = false, unique = true, length = 100) + private String loginId; + + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + @Builder + public AdminCredential( + String loginId, + String passwordHash, + boolean enabled + ) { + this.loginId = loginId; + this.passwordHash = passwordHash; + this.enabled = enabled; + } + + public void updatePasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public void updateEnabled(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/src/main/java/inha/gdgoc/domain/auth/repository/AdminCredentialRepository.java b/src/main/java/inha/gdgoc/domain/auth/repository/AdminCredentialRepository.java new file mode 100644 index 00000000..b965c1c4 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/repository/AdminCredentialRepository.java @@ -0,0 +1,11 @@ +package inha.gdgoc.domain.auth.repository; + +import inha.gdgoc.domain.auth.entity.AdminCredential; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AdminCredentialRepository extends JpaRepository { + Optional findByLoginId(String loginId); +} diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AdminCredentialInitializer.java b/src/main/java/inha/gdgoc/domain/auth/service/AdminCredentialInitializer.java new file mode 100644 index 00000000..c01e5850 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/service/AdminCredentialInitializer.java @@ -0,0 +1,56 @@ +package inha.gdgoc.domain.auth.service; + +import inha.gdgoc.domain.auth.entity.AdminCredential; +import inha.gdgoc.domain.auth.repository.AdminCredentialRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Slf4j +@Configuration +@RequiredArgsConstructor +public class AdminCredentialInitializer { + + private final AdminCredentialRepository adminCredentialRepository; + private final PasswordEncoder passwordEncoder; + + @Value("${app.admin.login-id:}") + private String adminLoginId; + + @Value("${app.admin.password:}") + private String adminPassword; + + @Bean + public ApplicationRunner initAdminCredentialRunner() { + return args -> initializeIfConfigured(); + } + + @Transactional + protected void initializeIfConfigured() { + if (!StringUtils.hasText(adminLoginId) || !StringUtils.hasText(adminPassword)) { + log.info("Admin credential initialization skipped (app.admin.login-id/password not configured)."); + return; + } + + String encodedPassword = passwordEncoder.encode(adminPassword); + adminCredentialRepository.findByLoginId(adminLoginId.trim()).ifPresentOrElse( + credential -> { + credential.updatePasswordHash(encodedPassword); + credential.updateEnabled(true); + }, + () -> adminCredentialRepository.save(AdminCredential.builder() + .loginId(adminLoginId.trim()) + .passwordHash(encodedPassword) + .enabled(true) + .build()) + ); + + log.info("Admin credential initialized for loginId={}", adminLoginId.trim()); + } +} diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index 3cdded81..312ddfa3 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -12,6 +12,8 @@ import inha.gdgoc.domain.auth.dto.response.LoginSuccessResponse; import inha.gdgoc.domain.auth.dto.response.SignupNeededResponse; import inha.gdgoc.domain.auth.dto.response.TokenDto; +import inha.gdgoc.domain.auth.entity.AdminCredential; +import inha.gdgoc.domain.auth.repository.AdminCredentialRepository; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; @@ -29,6 +31,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.*; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,9 +46,11 @@ public class AuthService { private static final String SESSION_VALUE_DELIMITER = "::"; private final UserRepository userRepository; + private final AdminCredentialRepository adminCredentialRepository; private final TokenProvider tokenProvider; private final StringRedisTemplate redisTemplate; private final AccessGuard accessGuard; + private final PasswordEncoder passwordEncoder; @Value("${google.client-id}") private String googleClientId; @@ -147,15 +152,27 @@ public boolean hasRequiredAccess(CustomUserDetails me, UserRole role, TeamType r public RefreshResult refresh(String refreshToken) { RefreshSession session = resolveRefreshSession(refreshToken); + if (session.principalType() == PrincipalType.ADMIN) { + AdminCredential credential = adminCredentialRepository + .findById(session.principalId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 관리자 계정입니다.")); + if (!credential.isEnabled()) { + throw new IllegalArgumentException("비활성화된 관리자 계정입니다."); + } + String accessToken = tokenProvider.createAdminAccessToken( + credential.getId(), + credential.getLoginId(), + session.sessionId() + ); + return new RefreshResult(accessToken, AuthUserResponse.admin(credential.getLoginId())); + } - User user = - userRepository - .findById(session.userId()) + User user = userRepository + .findById(session.principalId()) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); - // Access Token만 새로 발급 (Refresh Token은 그대로 유지하거나, 정책에 따라 재발급 가능) String accessToken = tokenProvider.createAccessToken(user, session.sessionId()); - return new RefreshResult(accessToken, user); + return new RefreshResult(accessToken, AuthUserResponse.from(user)); } // 로그아웃 @@ -177,14 +194,25 @@ public Long getAuthenticationUserId(Authentication authentication) { private TokenDto generateTokens(User user) { String sessionId = UUID.randomUUID().toString(); - // Access Token 생성 (JWT) String accessToken = tokenProvider.createAccessToken(user, sessionId); - - // Refresh Token 생성 (Random UUID) String refreshToken = tokenProvider.createRefreshToken(); + storeRefreshSession(refreshToken, new RefreshSession(sessionId, PrincipalType.USER, user.getId())); - storeRefreshSession(refreshToken, new RefreshSession(sessionId, user.getId())); + return new TokenDto(accessToken, refreshToken); + } + private TokenDto generateAdminTokens(AdminCredential credential) { + String sessionId = UUID.randomUUID().toString(); + String accessToken = tokenProvider.createAdminAccessToken( + credential.getId(), + credential.getLoginId(), + sessionId + ); + String refreshToken = tokenProvider.createRefreshToken(); + storeRefreshSession( + refreshToken, + new RefreshSession(sessionId, PrincipalType.ADMIN, credential.getId()) + ); return new TokenDto(accessToken, refreshToken); } @@ -235,35 +263,52 @@ private RefreshSession resolveRefreshSession(String refreshToken) { .findByOauthSubject(storedValue) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); - RefreshSession upgraded = new RefreshSession(UUID.randomUUID().toString(), user.getId()); + RefreshSession upgraded = new RefreshSession(UUID.randomUUID().toString(), PrincipalType.USER, user.getId()); storeRefreshSession(refreshToken, upgraded); return upgraded; } private RefreshSession decodeSessionValue(String storedValue) { - String[] parts = storedValue.split(SESSION_VALUE_DELIMITER, 2); - if (parts.length != 2) { + String[] parts = storedValue.split(SESSION_VALUE_DELIMITER); + if (parts.length < 2) { throw new IllegalArgumentException("잘못된 세션 정보입니다."); } try { - Long userId = Long.parseLong(parts[1]); - return new RefreshSession(parts[0], userId); + if (parts.length == 2) { + Long userId = Long.parseLong(parts[1]); + return new RefreshSession(parts[0], PrincipalType.USER, userId); + } + + PrincipalType type = PrincipalType.valueOf(parts[1]); + Long principalId = Long.parseLong(parts[2]); + return new RefreshSession(parts[0], type, principalId); } catch (NumberFormatException e) { throw new IllegalArgumentException("잘못된 세션 정보입니다.", e); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("잘못된 세션 타입 정보입니다.", e); } } private String encodeSessionValue(RefreshSession session) { - return session.sessionId() + SESSION_VALUE_DELIMITER + session.userId(); + return session.sessionId() + + SESSION_VALUE_DELIMITER + + session.principalType().name() + + SESSION_VALUE_DELIMITER + + session.principalId(); } private String refreshTokenKey(String refreshToken) { return REFRESH_TOKEN_PREFIX + refreshToken; } - private record RefreshSession(String sessionId, Long userId) {} + private enum PrincipalType { + USER, + ADMIN + } - public record RefreshResult(String accessToken, User user) {} + private record RefreshSession(String sessionId, PrincipalType principalType, Long principalId) {} + + public record RefreshResult(String accessToken, AuthUserResponse user) {} private GoogleUserInfo buildGoogleUserInfo(GoogleIdToken.Payload payload) { String fullName = (String) payload.get("name"); @@ -313,4 +358,25 @@ private boolean hasText(String value) { } private record NameParts(String familyName, String givenName) {} + + @Transactional(readOnly = true) + public LoginSuccessResponse adminLogin(String adminId, String password) { + if (!hasText(adminId) || !hasText(password)) { + throw new IllegalArgumentException("관리자 아이디/비밀번호를 입력해 주세요."); + } + + var credential = adminCredentialRepository.findByLoginId(adminId.trim()) + .orElseThrow(() -> new IllegalArgumentException("관리자 계정을 찾을 수 없습니다.")); + + if (!credential.isEnabled()) { + throw new IllegalArgumentException("비활성화된 관리자 계정입니다."); + } + + if (!passwordEncoder.matches(password, credential.getPasswordHash())) { + throw new IllegalArgumentException("관리자 아이디 또는 비밀번호가 올바르지 않습니다."); + } + + TokenDto tokens = generateAdminTokens(credential); + return LoginSuccessResponse.of(tokens, AuthUserResponse.admin(credential.getLoginId())); + } } diff --git a/src/main/java/inha/gdgoc/domain/auth/service/MailService.java b/src/main/java/inha/gdgoc/domain/auth/service/MailService.java index 15668078..a2f57ed4 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/MailService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/MailService.java @@ -32,4 +32,17 @@ public String sendAuthCode(String toEmail) { return code; } + + public void sendPlainMail(String toEmail, String subject, String text) { + sendPlainMail(toEmail, subject, text, sender); + } + + public void sendPlainMail(String toEmail, String subject, String text, String from) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(toEmail); + message.setFrom(from); + message.setSubject(subject); + message.setText(text); + mailSender.send(message); + } } diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/RecruitMemberSummaryResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/RecruitMemberSummaryResponse.java index 8b078eda..1e89610b 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/RecruitMemberSummaryResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/RecruitMemberSummaryResponse.java @@ -8,24 +8,16 @@ public record RecruitMemberSummaryResponse( String phoneNumber, String major, String studentId, - String admissionSemester, Boolean isPayed ) { public static RecruitMemberSummaryResponse from(RecruitMember recruitMember) { - String semester = null; - if (recruitMember.getAdmissionSemester() != null) { - String enumName = recruitMember.getAdmissionSemester().name(); - semester = enumName.substring(1).replace('_', '-'); - } - return new RecruitMemberSummaryResponse( recruitMember.getId(), recruitMember.getName(), recruitMember.getPhoneNumber(), recruitMember.getMajor(), recruitMember.getStudentId(), - semester, recruitMember.getIsPayed() ); } diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/SpecifiedMemberResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/SpecifiedMemberResponse.java index 11e912aa..ac82f7bb 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/SpecifiedMemberResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/SpecifiedMemberResponse.java @@ -4,12 +4,20 @@ import inha.gdgoc.domain.recruit.member.entity.Answer; import inha.gdgoc.domain.recruit.member.entity.RecruitMember; import java.util.List; +import java.time.Instant; public record SpecifiedMemberResponse( String name, + String enrolledClassification, + String phoneNumber, + String email, + String gender, + String birth, String major, String studentId, boolean isPayed, + Instant createdAt, + Instant updatedAt, AnswersResponse answers ) { @@ -20,9 +28,16 @@ public static SpecifiedMemberResponse from( ) { return new SpecifiedMemberResponse( member.getName(), + member.getEnrolledClassification() != null ? member.getEnrolledClassification().name() : null, + member.getPhoneNumber(), + member.getEmail(), + member.getGender() != null ? member.getGender().name() : null, + member.getBirth() != null ? member.getBirth().toString() : null, member.getMajor(), member.getStudentId(), Boolean.TRUE.equals(member.getIsPayed()), + member.getCreatedAt(), + member.getUpdatedAt(), AnswersResponse.from(answers, objectMapper) ); } diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/notification/entity/RecruitMemberMemoNotification.java b/src/main/java/inha/gdgoc/domain/recruit/member/notification/entity/RecruitMemberMemoNotification.java new file mode 100644 index 00000000..585b65d6 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/notification/entity/RecruitMemberMemoNotification.java @@ -0,0 +1,87 @@ +package inha.gdgoc.domain.recruit.member.notification.entity; + +import inha.gdgoc.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Duration; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "recruit_member_memo_notification") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class RecruitMemberMemoNotification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "semester", nullable = false, length = 16) + private String semester; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "subject", nullable = false, length = 200) + private String subject; + + @Column(name = "body", nullable = false, length = 5000) + private String body; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private RecruitMemberMemoNotificationStatus status; + + @Column(name = "attempt_count", nullable = false) + private int attemptCount; + + @Column(name = "next_attempt_at", nullable = false) + private Instant nextAttemptAt; + + @Column(name = "last_error") + private String lastError; + + @Column(name = "sent_at") + private Instant sentAt; + + public void markSent(Instant now) { + this.status = RecruitMemberMemoNotificationStatus.SENT; + this.sentAt = now; + this.nextAttemptAt = now; + this.lastError = null; + } + + public void markFailed(Instant now, int maxAttempts, Duration retryDelay, String errorMessage) { + this.attemptCount += 1; + this.lastError = errorMessage; + + if (this.attemptCount >= maxAttempts) { + this.status = RecruitMemberMemoNotificationStatus.FAILED; + this.nextAttemptAt = now; + return; + } + + this.status = RecruitMemberMemoNotificationStatus.PENDING; + this.nextAttemptAt = now.plus(retryDelay); + } + + public void retry(Instant now) { + this.status = RecruitMemberMemoNotificationStatus.PENDING; + this.attemptCount = 0; + this.nextAttemptAt = now; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/notification/entity/RecruitMemberMemoNotificationStatus.java b/src/main/java/inha/gdgoc/domain/recruit/member/notification/entity/RecruitMemberMemoNotificationStatus.java new file mode 100644 index 00000000..5970fdae --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/notification/entity/RecruitMemberMemoNotificationStatus.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.recruit.member.notification.entity; + +public enum RecruitMemberMemoNotificationStatus { + PENDING, + SENT, + FAILED +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/notification/repository/RecruitMemberMemoNotificationRepository.java b/src/main/java/inha/gdgoc/domain/recruit/member/notification/repository/RecruitMemberMemoNotificationRepository.java new file mode 100644 index 00000000..34f62dbb --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/notification/repository/RecruitMemberMemoNotificationRepository.java @@ -0,0 +1,41 @@ +package inha.gdgoc.domain.recruit.member.notification.repository; + +import inha.gdgoc.domain.recruit.member.notification.entity.RecruitMemberMemoNotification; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface RecruitMemberMemoNotificationRepository extends JpaRepository { + + @Query( + value = """ + SELECT * + FROM recruit_member_memo_notification + WHERE status = 'PENDING' + AND next_attempt_at <= NOW() + ORDER BY next_attempt_at ASC, id ASC + LIMIT :batchSize + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + List findPendingBatchForUpdate(@Param("batchSize") int batchSize); + + Optional findTopBySemesterOrderByCreatedAtDesc(String semester); + + @Modifying + @Query( + """ + UPDATE RecruitMemberMemoNotification n + SET n.status = inha.gdgoc.domain.recruit.member.notification.entity.RecruitMemberMemoNotificationStatus.PENDING, + n.attemptCount = 0, + n.nextAttemptAt = :now + WHERE n.semester = :semester + AND n.status = inha.gdgoc.domain.recruit.member.notification.entity.RecruitMemberMemoNotificationStatus.FAILED + """ + ) + int retryFailedBySemester(@Param("semester") String semester, @Param("now") java.time.Instant now); +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/notification/scheduler/RecruitMemberMemoNotificationScheduler.java b/src/main/java/inha/gdgoc/domain/recruit/member/notification/scheduler/RecruitMemberMemoNotificationScheduler.java new file mode 100644 index 00000000..1ce2c3c6 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/notification/scheduler/RecruitMemberMemoNotificationScheduler.java @@ -0,0 +1,24 @@ +package inha.gdgoc.domain.recruit.member.notification.scheduler; + +import inha.gdgoc.domain.recruit.member.notification.service.RecruitMemberMemoNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RecruitMemberMemoNotificationScheduler { + + private final RecruitMemberMemoNotificationService notificationService; + + @Scheduled(fixedDelayString = "${app.recruit.member.memo.notification.fixed-delay-ms:60000}") + public void processPendingNotifications() { + try { + notificationService.processPendingNotifications(); + } catch (Exception ex) { + log.error("recruit-member-memo notification scheduler failed", ex); + } + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationEnqueueResult.java b/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationEnqueueResult.java new file mode 100644 index 00000000..7faf6351 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationEnqueueResult.java @@ -0,0 +1,9 @@ +package inha.gdgoc.domain.recruit.member.notification.service; + +public record RecruitMemberMemoNotificationEnqueueResult( + String semester, + int distinctTargetCount, + int enqueuedCount, + int alreadyProcessedCount +) { +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationRetryResult.java b/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationRetryResult.java new file mode 100644 index 00000000..11de8d5d --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationRetryResult.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.recruit.member.notification.service; + +public record RecruitMemberMemoNotificationRetryResult( + String semester, + int retriedCount +) { +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationService.java b/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationService.java new file mode 100644 index 00000000..c0a16cf2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationService.java @@ -0,0 +1,184 @@ +package inha.gdgoc.domain.recruit.member.notification.service; + +import inha.gdgoc.domain.auth.service.MailService; +import inha.gdgoc.domain.recruit.member.enums.AdmissionSemester; +import inha.gdgoc.domain.recruit.member.notification.entity.RecruitMemberMemoNotification; +import inha.gdgoc.domain.recruit.member.notification.repository.RecruitMemberMemoNotificationRepository; +import inha.gdgoc.global.util.SemesterCalculator; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +public class RecruitMemberMemoNotificationService { + + private static final String DISTINCT_TARGET_COUNT_SQL = """ + SELECT COUNT(*) + FROM ( + SELECT LOWER(email) AS email + FROM recruit_member_memo + WHERE privacy_agreement = true + AND freshman_memo_agreement = true + GROUP BY LOWER(email) + ) t + """; + + private static final String ENQUEUE_SQL = """ + INSERT INTO recruit_member_memo_notification + (semester, email, subject, body, status, attempt_count, next_attempt_at, created_at, updated_at) + SELECT ?, LOWER(email), ?, ?, 'PENDING', 0, NOW(), NOW(), NOW() + FROM recruit_member_memo + WHERE privacy_agreement = true + AND freshman_memo_agreement = true + GROUP BY LOWER(email) + ON CONFLICT (semester, email) DO NOTHING + """; + + private final RecruitMemberMemoNotificationRepository notificationRepository; + private final MailService mailService; + private final SemesterCalculator semesterCalculator; + private final JdbcTemplate jdbcTemplate; + private final int maxAttempts; + private final int batchSize; + private final String recruitFrom; + private final String defaultSender; + + public RecruitMemberMemoNotificationService( + RecruitMemberMemoNotificationRepository notificationRepository, + MailService mailService, + SemesterCalculator semesterCalculator, + JdbcTemplate jdbcTemplate, + @Value("${app.recruit.member.memo.notification.max-attempts:3}") int maxAttempts, + @Value("${app.recruit.member.memo.notification.batch-size:100}") int batchSize, + @Value("${app.mail.recruit-from:}") String recruitFrom, + @Value("${spring.mail.username}") String defaultSender + ) { + this.notificationRepository = notificationRepository; + this.mailService = mailService; + this.semesterCalculator = semesterCalculator; + this.jdbcTemplate = jdbcTemplate; + this.maxAttempts = maxAttempts; + this.batchSize = batchSize; + this.recruitFrom = recruitFrom; + this.defaultSender = defaultSender; + } + + @Transactional + public RecruitMemberMemoNotificationEnqueueResult enqueueOpeningNotificationsForCurrentSemester( + String subject, + String body + ) { + AdmissionSemester currentSemester = semesterCalculator.currentSemester(); + String semester = currentSemester.name(); + String trimmedSubject = subject.trim(); + String trimmedBody = body.trim(); + + int distinctTargetCount = Optional.ofNullable( + jdbcTemplate.queryForObject(DISTINCT_TARGET_COUNT_SQL, Integer.class) + ).orElse(0); + + int enqueuedCount = jdbcTemplate.update(ENQUEUE_SQL, semester, trimmedSubject, trimmedBody); + int alreadyProcessedCount = Math.max(distinctTargetCount - enqueuedCount, 0); + + log.info( + "recruit-member-memo enqueue done. semester={}, distinctTargets={}, enqueued={}, alreadyProcessed={}", + semester, + distinctTargetCount, + enqueuedCount, + alreadyProcessedCount + ); + + return new RecruitMemberMemoNotificationEnqueueResult( + semester, + distinctTargetCount, + enqueuedCount, + alreadyProcessedCount + ); + } + + @Transactional(readOnly = true) + public RecruitMemberMemoNotificationTemplateInfo getTemplateInfoForCurrentSemester() { + String semester = semesterCalculator.currentSemester().name(); + Optional latest = notificationRepository.findTopBySemesterOrderByCreatedAtDesc( + semester + ); + + return new RecruitMemberMemoNotificationTemplateInfo( + semester, + RecruitMemberMemoNotificationTemplate.OPENING_SUBJECT, + RecruitMemberMemoNotificationTemplate.OPENING_BODY, + latest.map(RecruitMemberMemoNotification::getSubject).orElse(null), + latest.map(RecruitMemberMemoNotification::getBody).orElse(null) + ); + } + + @Transactional + public RecruitMemberMemoNotificationRetryResult retryFailedForCurrentSemester() { + String semester = semesterCalculator.currentSemester().name(); + int retriedCount = notificationRepository.retryFailedBySemester(semester, Instant.now()); + return new RecruitMemberMemoNotificationRetryResult(semester, retriedCount); + } + + @Transactional + public void processPendingNotifications() { + List notifications = notificationRepository.findPendingBatchForUpdate(batchSize); + if (notifications.isEmpty()) { + return; + } + + Instant now = Instant.now(); + String effectiveFrom = recruitFrom == null || recruitFrom.isBlank() ? defaultSender : recruitFrom; + for (RecruitMemberMemoNotification notification : notifications) { + try { + mailService.sendPlainMail( + notification.getEmail(), + notification.getSubject(), + notification.getBody(), + effectiveFrom + ); + notification.markSent(now); + } catch (Exception ex) { + int nextAttemptCount = notification.getAttemptCount() + 1; + notification.markFailed( + now, + maxAttempts, + retryDelay(nextAttemptCount), + compactErrorMessage(ex) + ); + log.warn( + "recruit-member-memo send failed. id={}, email={}, attempt={}/{}, error={}", + notification.getId(), + notification.getEmail(), + nextAttemptCount, + maxAttempts, + ex.getMessage() + ); + } + } + + notificationRepository.saveAll(notifications); + } + + private Duration retryDelay(int attemptCount) { + return switch (attemptCount) { + case 1 -> Duration.ofMinutes(1); + case 2 -> Duration.ofMinutes(5); + default -> Duration.ofMinutes(30); + }; + } + + private String compactErrorMessage(Exception ex) { + String message = ex.getMessage(); + if (message == null || message.isBlank()) { + return ex.getClass().getSimpleName(); + } + return message.length() > 1000 ? message.substring(0, 1000) : message; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationTemplate.java b/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationTemplate.java new file mode 100644 index 00000000..fe5e3f2f --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationTemplate.java @@ -0,0 +1,23 @@ +package inha.gdgoc.domain.recruit.member.notification.service; + +public final class RecruitMemberMemoNotificationTemplate { + + public static final String OPENING_SUBJECT = "[GDGoC INHA] 2026학년도 신입생 정식 지원 안내"; + public static final String OPENING_BODY = """ + 안녕하세요, GDGoC INHA입니다. + + 먼저 인하대학교 입학을 진심으로 축하드립니다! + 그동안 학번이 나오지 않아 지원을 기다려 주셨던 신입생분들께 반가운 소식을 전해드립니다. + + 어제부로 신입생 학번이 발급됨에 따라, 이제 정식으로 GDGoC INHA 지원이 가능해졌습니다. 알림 신청을 통해 보여주신 여러분의 열정을 지원서에 가득 담아주세요! + + 지원 링크: https://gdgocinha.com/recruit/member + + 기술을 통해 함께 성장하고, 더 나은 가치를 만들어갈 여러분의 지원을 기다리겠습니다. + + 여러분과 함께 성장해나가는 커뮤니티, GDGoC INHA 운영진 드림 + """; + + private RecruitMemberMemoNotificationTemplate() { + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationTemplateInfo.java b/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationTemplateInfo.java new file mode 100644 index 00000000..ce91f0af --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationTemplateInfo.java @@ -0,0 +1,10 @@ +package inha.gdgoc.domain.recruit.member.notification.service; + +public record RecruitMemberMemoNotificationTemplateInfo( + String semester, + String defaultSubject, + String defaultBody, + String lastSubject, + String lastBody +) { +} diff --git a/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java b/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java index c02719e5..1840c619 100644 --- a/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java +++ b/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java @@ -7,7 +7,7 @@ public enum TeamType { HQ("HQ"), HR("HR"), - PR_DESIGN("PR/DESIGN"), + PR_DESIGN("PR_DESIGN"), TECH("TECH"), BD("BD"); @@ -23,8 +23,8 @@ public static TeamType from(String raw) { case "HR" -> HR; case "TECH" -> TECH; case "BD" -> BD; - case "PR/DESIGN" -> PR_DESIGN; - default -> throw new IllegalArgumentException("Unknown team: " + raw); + case "PR_DESIGN" -> PR_DESIGN; + default -> throw new IllegalArgumentException("Unknown team: " + raw); }; } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java index 72955383..76a6b895 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -36,6 +36,9 @@ public class TokenProvider { private final JwtProperties jwtProperties; private final UserRepository userRepository; private static final String CLAIM_USER_ID = "uid"; + private static final String CLAIM_ADMIN_ID = "aid"; + private static final String CLAIM_ROLE = "role"; + private static final String CLAIM_TEAM = "team"; private static final String CLAIM_SESSION_ID = "sid"; private SecretKey cachedSigningKey; @@ -64,6 +67,26 @@ public String createAccessToken(User user, String sessionId) { .compact(); } + public String createAdminAccessToken(Long adminCredentialId, String loginId, String sessionId) { + Date now = new Date(); + Date validity = new Date(now.getTime() + jwtProperties.getAccessTokenValidity()); + + var builder = Jwts.builder() + .issuer(jwtProperties.getSelfIssuer()) + .audience().add(jwtProperties.getAudience()).and() + .issuedAt(now) + .expiration(validity) + .subject("admin:" + loginId) + .id(UUID.randomUUID().toString()) + .claim(CLAIM_ADMIN_ID, adminCredentialId) + .claim(CLAIM_ROLE, UserRole.ADMIN.name()) + .claim(CLAIM_SESSION_ID, sessionId); + + return builder + .signWith(signingKey()) + .compact(); + } + // Refresh Token 생성 (Random UUID) // JWT가 아니라, 단순 랜덤 문자열로 생성하여 Redis 저장용으로 씁니다. @@ -75,7 +98,6 @@ public String createRefreshToken() { public Authentication getAuthentication(String token) { Claims claims = getClaims(token); - Long userId = extractUserId(claims); String sessionId = claims.get(CLAIM_SESSION_ID, String.class); if (sessionId == null || sessionId.isBlank()) { log.warn("JWT 검증 실패: sessionId(sid) 클레임이 누락되었습니다."); @@ -84,23 +106,47 @@ public Authentication getAuthentication(String token) { validateAudienceClaim(claims.get(Claims.AUDIENCE)); - User user = userRepository.findById(userId) - .orElseThrow(() -> { - log.warn("JWT 검증 실패: ID가 {}인 유저를 찾을 수 없습니다.", userId); - return new BusinessException(INVALID_JWT_REQUEST); - }); + Number userIdNumber = claims.get(CLAIM_USER_ID, Number.class); + Number adminIdNumber = claims.get(CLAIM_ADMIN_ID, Number.class); + + Long userId = null; + String username; + UserRole userRole; + TeamType team = null; + + if (userIdNumber != null) { + userId = userIdNumber.longValue(); + final Long resolvedUserId = userId; + User user = userRepository.findById(resolvedUserId) + .orElseThrow(() -> { + log.warn("JWT 검증 실패: ID가 {}인 유저를 찾을 수 없습니다.", resolvedUserId); + return new BusinessException(INVALID_JWT_REQUEST); + }); + username = user.getEmail(); + userRole = user.getUserRole(); + team = user.getTeam(); + } else if (adminIdNumber != null) { + String roleRaw = claims.get(CLAIM_ROLE, String.class); + userRole = roleRaw != null ? UserRole.valueOf(roleRaw) : UserRole.ADMIN; + String teamRaw = claims.get(CLAIM_TEAM, String.class); + if (teamRaw != null && !teamRaw.isBlank()) { + team = TeamType.valueOf(teamRaw); + } + username = claims.getSubject(); + } else { + log.warn("JWT 검증 실패: uid/aid 클레임이 모두 누락되었습니다."); + throw new BusinessException(INVALID_JWT_REQUEST); + } - UserRole userRole = user.getUserRole(); Set authorities = new HashSet<>(); authorities.add(new SimpleGrantedAuthority("ROLE_" + userRole.name())); - TeamType team = user.getTeam(); if (team != null) { authorities.add(new SimpleGrantedAuthority("TEAM_" + team.name())); } CustomUserDetails userDetails = - new CustomUserDetails(userId, user.getEmail(), sessionId, authorities, userRole, team); + new CustomUserDetails(userId, username, sessionId, authorities, userRole, team); return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); } @@ -155,15 +201,6 @@ private SecretKey buildSigningKey(String rawSecret) { return Keys.hmacShaKeyFor(candidateKey); } - private Long extractUserId(Claims claims) { - Number idNum = claims.get(CLAIM_USER_ID, Number.class); - if (idNum == null) { - log.warn("JWT 검증 실패: userId(uid) 클레임이 누락되었습니다."); - throw new BusinessException(INVALID_JWT_REQUEST); - } - return idNum.longValue(); - } - private void validateAudienceClaim(Object audienceClaim) { if (audienceClaim == null) { log.warn("JWT 검증 실패: audience(aud) 클레임이 누락되었습니다."); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 9a9ab2db..3ff2b842 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -62,6 +62,11 @@ springdoc: app: s3: bucket: ${AWS_TEST_RESOURCE_BUCKET} + admin: + login-id: ${ADMIN_LOGIN_ID} + password: ${ADMIN_LOGIN_PASSWORD} + mail: + recruit-from: ${APP_MAIL_RECRUIT_FROM:} google: client-id: ${GOOGLE_CLIENT_ID} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 64b37826..7a8a483b 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -62,6 +62,11 @@ springdoc: app: s3: bucket: ${AWS_TEST_RESOURCE_BUCKET} + admin: + login-id: ${ADMIN_LOGIN_ID} + password: ${ADMIN_LOGIN_PASSWORD} + mail: + recruit-from: ${APP_MAIL_RECRUIT_FROM:} google: client-id: ${GOOGLE_CLIENT_ID} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 394fad5f..0544a313 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -62,6 +62,11 @@ springdoc: app: s3: bucket: ${AWS_RESOURCE_BUCKET} + admin: + login-id: ${ADMIN_LOGIN_ID} + password: ${ADMIN_LOGIN_PASSWORD} + mail: + recruit-from: ${APP_MAIL_RECRUIT_FROM:} google: client-id: ${GOOGLE_CLIENT_ID} diff --git a/src/main/resources/db/migration/V20260218__create_admin_credentials.sql b/src/main/resources/db/migration/V20260218__create_admin_credentials.sql new file mode 100644 index 00000000..a8cff8f1 --- /dev/null +++ b/src/main/resources/db/migration/V20260218__create_admin_credentials.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS admin_credentials ( + id BIGSERIAL PRIMARY KEY, + login_id VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_admin_credentials_login_id + ON admin_credentials(login_id); + +DO $$ +DECLARE + constraint_name text; +BEGIN + IF to_regclass('public.admin_credentials') IS NULL THEN + RETURN; + END IF; + + FOR constraint_name IN + SELECT c.conname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'public' + AND t.relname = 'admin_credentials' + AND c.conkey IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM unnest(c.conkey) AS colnum(attnum) + JOIN pg_attribute a + ON a.attrelid = t.oid + AND a.attnum = colnum.attnum + WHERE a.attname = 'user_id' + ) + LOOP + EXECUTE format( + 'ALTER TABLE public.admin_credentials DROP CONSTRAINT IF EXISTS %I', + constraint_name + ); + END LOOP; +END $$; + +ALTER TABLE IF EXISTS public.admin_credentials + DROP COLUMN IF EXISTS user_id; diff --git a/src/main/resources/db/migration/V20260220__create_recruit_member_memo_notification.sql b/src/main/resources/db/migration/V20260220__create_recruit_member_memo_notification.sql new file mode 100644 index 00000000..f83d7c33 --- /dev/null +++ b/src/main/resources/db/migration/V20260220__create_recruit_member_memo_notification.sql @@ -0,0 +1,50 @@ +CREATE TABLE IF NOT EXISTS recruit_member_memo_notification ( + id BIGSERIAL PRIMARY KEY, + semester VARCHAR(16) NOT NULL, + email VARCHAR(255) NOT NULL, + subject VARCHAR(200), + body TEXT, + status VARCHAR(20) NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_error TEXT, + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_recruit_member_memo_notification_semester_email + ON recruit_member_memo_notification (semester, email); + +CREATE INDEX IF NOT EXISTS idx_recruit_member_memo_notification_status_next_attempt + ON recruit_member_memo_notification (status, next_attempt_at); + +ALTER TABLE recruit_member_memo_notification + ADD COLUMN IF NOT EXISTS subject VARCHAR(200), + ADD COLUMN IF NOT EXISTS body TEXT; + +UPDATE recruit_member_memo_notification +SET + subject = COALESCE(subject, '[GDGoC INHA] 2026학년도 신입생 정식 지원 안내'), + body = COALESCE( + body, + $$안녕하세요, GDGoC INHA입니다. + +먼저 인하대학교 입학을 진심으로 축하드립니다! +그동안 학번이 나오지 않아 지원을 기다려 주셨던 신입생분들께 반가운 소식을 전해드립니다. + +어제부로 신입생 학번이 발급됨에 따라, 이제 정식으로 GDGoC INHA 지원이 가능해졌습니다. 알림 신청을 통해 보여주신 여러분의 열정을 지원서에 가득 담아주세요! + +지원 링크: https://gdgocinha.com/recruit/member + +기술을 통해 함께 성장하고, 더 나은 가치를 만들어갈 여러분의 지원을 기다리겠습니다. + +여러분과 함께 성장해나가는 커뮤니티, GDGoC INHA 운영진 드림 +$$ + ) +WHERE subject IS NULL + OR body IS NULL; + +ALTER TABLE recruit_member_memo_notification + ALTER COLUMN subject SET NOT NULL, + ALTER COLUMN body SET NOT NULL; diff --git a/src/test/java/inha/gdgoc/domain/admin/user/service/UserAdminServiceTest.java b/src/test/java/inha/gdgoc/domain/admin/user/service/UserAdminServiceTest.java new file mode 100644 index 00000000..491021cb --- /dev/null +++ b/src/test/java/inha/gdgoc/domain/admin/user/service/UserAdminServiceTest.java @@ -0,0 +1,135 @@ +package inha.gdgoc.domain.admin.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import inha.gdgoc.domain.admin.user.dto.request.UpdateUserRoleTeamRequest; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.repository.UserRepository; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import inha.gdgoc.global.exception.BusinessException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +@ExtendWith(MockitoExtension.class) +class UserAdminServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserAdminService userAdminService; + + @Test + void updateRoleAndTeam_blocksSelfEdit() { + User editor = createUser(1L, UserRole.CORE, TeamType.HR); + when(userRepository.findById(1L)).thenReturn(Optional.of(editor)); + + assertThatThrownBy(() -> userAdminService.updateRoleAndTeam( + principal(1L, UserRole.CORE, TeamType.HR), + 1L, + new UpdateUserRoleTeamRequest(UserRole.MEMBER, null) + )).isInstanceOf(BusinessException.class); + } + + @Test + void updateRoleAndTeam_allowsHrCoreToPromoteGuestToMember() { + User editor = createUser(1L, UserRole.CORE, TeamType.HR); + User target = createUser(2L, UserRole.GUEST, TeamType.TECH); + + when(userRepository.findById(1L)).thenReturn(Optional.of(editor)); + when(userRepository.findById(2L)).thenReturn(Optional.of(target)); + + userAdminService.updateRoleAndTeam( + principal(1L, UserRole.CORE, TeamType.HR), + 2L, + new UpdateUserRoleTeamRequest(UserRole.MEMBER, null) + ); + + assertThat(target.getUserRole()).isEqualTo(UserRole.MEMBER); + assertThat(target.getTeam()).isNull(); + verify(userRepository).save(target); + } + + @Test + void updateRoleAndTeam_blocksNonHrCoreEditingOtherTeam() { + User editor = createUser(1L, UserRole.CORE, TeamType.TECH); + User target = createUser(2L, UserRole.GUEST, TeamType.BD); + + when(userRepository.findById(1L)).thenReturn(Optional.of(editor)); + when(userRepository.findById(2L)).thenReturn(Optional.of(target)); + + assertThatThrownBy(() -> userAdminService.updateRoleAndTeam( + principal(1L, UserRole.CORE, TeamType.TECH), + 2L, + new UpdateUserRoleTeamRequest(UserRole.MEMBER, null) + )).isInstanceOf(BusinessException.class); + } + + @Test + void updateRoleAndTeam_allowsNonHrLeadMemberToCoreInSameTeam() { + User editor = createUser(1L, UserRole.LEAD, TeamType.TECH); + User target = createUser(2L, UserRole.MEMBER, TeamType.TECH); + + when(userRepository.findById(1L)).thenReturn(Optional.of(editor)); + when(userRepository.findById(2L)).thenReturn(Optional.of(target)); + + userAdminService.updateRoleAndTeam( + principal(1L, UserRole.LEAD, TeamType.TECH), + 2L, + new UpdateUserRoleTeamRequest(UserRole.CORE, TeamType.TECH) + ); + + assertThat(target.getUserRole()).isEqualTo(UserRole.CORE); + assertThat(target.getTeam()).isEqualTo(TeamType.TECH); + verify(userRepository).save(target); + } + + private CustomUserDetails principal(Long userId, UserRole role, TeamType team) { + return new CustomUserDetails( + userId, + "test@inha.edu", + "session", + List.of(new SimpleGrantedAuthority("ROLE_" + role.name())), + role, + team + ); + } + + private User createUser(Long id, UserRole role, TeamType team) { + User user = User.builder() + .name("홍길동") + .major("컴퓨터공학과") + .studentId("12201234") + .phoneNumber("01012345678") + .email("hong@inha.edu") + .userRole(role) + .team(team) + .image(null) + .social(null) + .careers(null) + .build(); + setId(user, id); + return user; + } + + private void setId(Object target, Long id) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(target, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationServiceTest.java b/src/test/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationServiceTest.java new file mode 100644 index 00000000..d5680c8d --- /dev/null +++ b/src/test/java/inha/gdgoc/domain/recruit/member/notification/service/RecruitMemberMemoNotificationServiceTest.java @@ -0,0 +1,176 @@ +package inha.gdgoc.domain.recruit.member.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import inha.gdgoc.domain.auth.service.MailService; +import inha.gdgoc.domain.recruit.member.enums.AdmissionSemester; +import inha.gdgoc.domain.recruit.member.notification.entity.RecruitMemberMemoNotification; +import inha.gdgoc.domain.recruit.member.notification.entity.RecruitMemberMemoNotificationStatus; +import inha.gdgoc.domain.recruit.member.notification.repository.RecruitMemberMemoNotificationRepository; +import inha.gdgoc.global.util.SemesterCalculator; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; + +@ExtendWith(MockitoExtension.class) +class RecruitMemberMemoNotificationServiceTest { + + @Mock + private RecruitMemberMemoNotificationRepository notificationRepository; + + @Mock + private MailService mailService; + + @Mock + private SemesterCalculator semesterCalculator; + + @Mock + private JdbcTemplate jdbcTemplate; + + @Test + void enqueueOpeningNotifications_returnsExpectedCounts() { + RecruitMemberMemoNotificationService service = new RecruitMemberMemoNotificationService( + notificationRepository, + mailService, + semesterCalculator, + jdbcTemplate, + 3, + 100, + "recruit@gdgocinha.com", + "sender@test.com" + ); + + when(semesterCalculator.currentSemester()).thenReturn(AdmissionSemester.Y26_1); + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class))).thenReturn(5); + when(jdbcTemplate.update(anyString(), eq("Y26_1"), eq("subject"), eq("body"))).thenReturn(3); + + RecruitMemberMemoNotificationEnqueueResult result = + service.enqueueOpeningNotificationsForCurrentSemester("subject", "body"); + + assertThat(result.semester()).isEqualTo("Y26_1"); + assertThat(result.distinctTargetCount()).isEqualTo(5); + assertThat(result.enqueuedCount()).isEqualTo(3); + assertThat(result.alreadyProcessedCount()).isEqualTo(2); + } + + @Test + void processPendingNotifications_marksSentAndFailed() { + RecruitMemberMemoNotificationService service = new RecruitMemberMemoNotificationService( + notificationRepository, + mailService, + semesterCalculator, + jdbcTemplate, + 3, + 100, + "recruit@gdgocinha.com", + "sender@test.com" + ); + + RecruitMemberMemoNotification success = RecruitMemberMemoNotification.builder() + .id(1L) + .semester("Y26_1") + .email("ok@test.com") + .subject("s") + .body("b") + .status(RecruitMemberMemoNotificationStatus.PENDING) + .attemptCount(0) + .nextAttemptAt(Instant.now()) + .build(); + + RecruitMemberMemoNotification fail = RecruitMemberMemoNotification.builder() + .id(2L) + .semester("Y26_1") + .email("fail@test.com") + .subject("s") + .body("b") + .status(RecruitMemberMemoNotificationStatus.PENDING) + .attemptCount(2) + .nextAttemptAt(Instant.now()) + .build(); + + when(notificationRepository.findPendingBatchForUpdate(anyInt())).thenReturn(List.of(success, fail)); + doThrow(new RuntimeException("smtp error")) + .when(mailService) + .sendPlainMail(eq("fail@test.com"), anyString(), anyString(), anyString()); + + service.processPendingNotifications(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(notificationRepository).saveAll(captor.capture()); + + List saved = captor.getValue(); + assertThat(saved).hasSize(2); + assertThat(saved.get(0).getStatus()).isEqualTo(RecruitMemberMemoNotificationStatus.SENT); + assertThat(saved.get(0).getSentAt()).isNotNull(); + assertThat(saved.get(1).getStatus()).isEqualTo(RecruitMemberMemoNotificationStatus.FAILED); + assertThat(saved.get(1).getAttemptCount()).isEqualTo(3); + assertThat(saved.get(1).getLastError()).contains("smtp error"); + } + + @Test + void getTemplateInfoForCurrentSemester_prefersLastMessage() { + RecruitMemberMemoNotificationService service = new RecruitMemberMemoNotificationService( + notificationRepository, + mailService, + semesterCalculator, + jdbcTemplate, + 3, + 100, + "recruit@gdgocinha.com", + "sender@test.com" + ); + RecruitMemberMemoNotification latest = RecruitMemberMemoNotification.builder() + .semester("Y26_1") + .email("a@test.com") + .subject("latest-subject") + .body("latest-body") + .status(RecruitMemberMemoNotificationStatus.SENT) + .attemptCount(0) + .nextAttemptAt(Instant.now()) + .build(); + + when(semesterCalculator.currentSemester()).thenReturn(AdmissionSemester.Y26_1); + when(notificationRepository.findTopBySemesterOrderByCreatedAtDesc("Y26_1")) + .thenReturn(Optional.of(latest)); + + RecruitMemberMemoNotificationTemplateInfo info = service.getTemplateInfoForCurrentSemester(); + + assertThat(info.semester()).isEqualTo("Y26_1"); + assertThat(info.lastSubject()).isEqualTo("latest-subject"); + assertThat(info.lastBody()).isEqualTo("latest-body"); + } + + @Test + void retryFailedForCurrentSemester_returnsRetriedCount() { + RecruitMemberMemoNotificationService service = new RecruitMemberMemoNotificationService( + notificationRepository, + mailService, + semesterCalculator, + jdbcTemplate, + 3, + 100, + "recruit@gdgocinha.com", + "sender@test.com" + ); + when(semesterCalculator.currentSemester()).thenReturn(AdmissionSemester.Y26_1); + when(notificationRepository.retryFailedBySemester(eq("Y26_1"), org.mockito.ArgumentMatchers.any())) + .thenReturn(4); + + RecruitMemberMemoNotificationRetryResult result = service.retryFailedForCurrentSemester(); + + assertThat(result.semester()).isEqualTo("Y26_1"); + assertThat(result.retriedCount()).isEqualTo(4); + } +}