Skip to content

Commit eedc112

Browse files
authored
feat: 학교 이메일 인증으로 HomeUniversity 자동 매핑
* feat: 학교별 이메일 인증으로 HomeUniversity 자동 매핑 (#751) - HomeUniversity에 emailDomain 컬럼 추가 (V50 마이그레이션) - HomeUniversityRepository에 findByEmailDomain 메서드 추가 - EmailService 구현 (JavaMailSender 기반 인증 코드 발송) - SchoolEmailService 구현 (인증 코드 발급/확인, Redis TTL 5분) - SiteUser.verifySchool() 도메인 메서드 추가 - MyPageController에 POST /my/school-email, POST /my/school-email/confirm 엔드포인트 추가 - 어드민 HomeUniversity DTO에 emailDomain 필드 반영 * refactor: EmailService를 common/mail/MailService로 이동 email 발송은 공통 인프라 관심사이므로 별도 email 패키지 대신 common/mail 패키지로 이동 * refactor: 학교 이메일 인증 API 응답 body 제거 클라이언트가 이미 알고 있는 이메일을 응답으로 돌려줄 필요가 없으므로 SchoolEmailResponse 제거 및 반환 타입을 void로 변경 * refactor: Redis 저장 실패 시 CustomException으로 처리 RuntimeException 대신 SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED ErrorCode를 사용하여 예외 처리를 명확하게 표현 * refactor: Redis 인증 정보 역직렬화 실패 시 CORRUPTED 예외로 처리 데이터가 존재하지만 파싱 실패인 경우 REQUEST_NOT_FOUND 대신 SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED로 명확하게 구분 * test: 학교 이메일 인증 테스트를 인하대학교(inha.edu) 기반으로 변경 * style: 테스트 Given-When-Then 주석 소문자로 통일 * feat: data.sql에 HomeUniversity email_domain 데이터 추가 * fix: CodeRabbit 리뷰 반영 - emailDomain 검증 강화 및 트랜잭션 범위 축소 - V50 마이그레이션에 기존 대학 email_domain 백필 UPDATE 추가 - AdminHomeUniversityService에 emailDomain 중복 검증 추가 - SchoolEmailService에서 이메일 발송 실패 시 Redis 보상 삭제 추가 - requestSchoolEmailVerification에서 불필요한 @transactional 제거 - extractEmailDomain에서 도메인 소문자 정규화 적용 - AdminHomeUniversity DTO의 emailDomain 검증을 @pattern으로 강화 * revert: V50 마이그레이션에서 DML 제거 * refactor: @Email 검증으로 보장된 중복 atIndex 검사 제거 * refactor: develop rebase 충돌 해결 및 테스트 코드 적용 * fix: emailDomain @SiZe 검증 추가
1 parent 1bb075c commit eedc112

22 files changed

Lines changed: 462 additions & 20 deletions

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ dependencies {
6565
testAnnotationProcessor 'org.projectlombok:lombok'
6666
testImplementation 'org.awaitility:awaitility:4.2.0'
6767

68+
// Mail
69+
implementation 'org.springframework.boot:spring-boot-starter-mail'
70+
6871
// Etc
6972
implementation platform('software.amazon.awssdk:bom:2.41.4')
7073
implementation 'software.amazon.awssdk:s3'

src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityCreateRequest.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import jakarta.validation.constraints.Min;
44
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Pattern;
56
import jakarta.validation.constraints.Size;
67

78
public record AdminHomeUniversityCreateRequest(
@@ -10,7 +11,14 @@ public record AdminHomeUniversityCreateRequest(
1011
String name,
1112

1213
@Min(value = 1, message = "최대 지망 수는 1 이상이어야 합니다")
13-
int maxChoiceCount
14+
int maxChoiceCount,
15+
16+
@Size(max = 100, message = "이메일 도메인은 100자 이하여야 합니다")
17+
@Pattern(
18+
regexp = "^[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)+$",
19+
message = "올바른 이메일 도메인 형식이 아닙니다 (예: inha.edu, inu.ac.kr)"
20+
)
21+
String emailDomain
1422
) {
1523

1624
}

src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityResponse.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
public record AdminHomeUniversityResponse(
66
long id,
77
String name,
8-
int maxChoiceCount
8+
int maxChoiceCount,
9+
String emailDomain
910
) {
1011

1112
public static AdminHomeUniversityResponse from(HomeUniversity homeUniversity) {
1213
return new AdminHomeUniversityResponse(
1314
homeUniversity.getId(),
1415
homeUniversity.getName(),
15-
homeUniversity.getMaxChoiceCount()
16+
homeUniversity.getMaxChoiceCount(),
17+
homeUniversity.getEmailDomain()
1618
);
1719
}
1820
}

src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityUpdateRequest.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import jakarta.validation.constraints.Min;
44
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Pattern;
56
import jakarta.validation.constraints.Size;
67

78
public record AdminHomeUniversityUpdateRequest(
@@ -10,7 +11,15 @@ public record AdminHomeUniversityUpdateRequest(
1011
String name,
1112

1213
@Min(value = 1, message = "최대 지망 수는 1 이상이어야 합니다")
13-
int maxChoiceCount
14+
int maxChoiceCount,
15+
16+
@Size(max = 100, message = "이메일 도메인은 100자 이하여야 합니다")
17+
@Pattern(
18+
regexp = "^[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)+$",
19+
message = "올바른 이메일 도메인 형식이 아닙니다 (예: inha.edu, inu.ac.kr)"
20+
)
21+
String emailDomain
22+
1423
) {
1524

1625
}

src/main/java/com/example/solidconnection/admin/university/service/AdminHomeUniversityService.java

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.example.solidconnection.admin.university.service;
22

33
import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_ALREADY_EXISTS;
4+
import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS;
45
import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_HAS_REFERENCES;
56
import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_NOT_FOUND;
67

@@ -48,7 +49,8 @@ public AdminHomeUniversityResponse getHomeUniversity(Long id) {
4849
)
4950
public AdminHomeUniversityResponse createHomeUniversity(AdminHomeUniversityCreateRequest request) {
5051
validateNameNotExists(request.name());
51-
HomeUniversity homeUniversity = new HomeUniversity(null, request.name(), request.maxChoiceCount());
52+
validateEmailDomainNotExists(request.emailDomain());
53+
HomeUniversity homeUniversity = new HomeUniversity(null, request.name(), request.maxChoiceCount(), request.emailDomain());
5254
return AdminHomeUniversityResponse.from(homeUniversityRepository.save(homeUniversity));
5355
}
5456

@@ -59,6 +61,16 @@ private void validateNameNotExists(String name) {
5961
});
6062
}
6163

64+
private void validateEmailDomainNotExists(String emailDomain) {
65+
if (emailDomain == null || emailDomain.isBlank()) {
66+
return;
67+
}
68+
homeUniversityRepository.findByEmailDomain(emailDomain)
69+
.ifPresent(existing -> {
70+
throw new CustomException(HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS);
71+
});
72+
}
73+
6274
@Transactional
6375
@DefaultCacheOut(
6476
key = {"univApplyInfoTextSearch", "university:recommend:general"},
@@ -69,7 +81,8 @@ public AdminHomeUniversityResponse updateHomeUniversity(Long id, AdminHomeUniver
6981
HomeUniversity homeUniversity = homeUniversityRepository.findById(id)
7082
.orElseThrow(() -> new CustomException(HOME_UNIVERSITY_NOT_FOUND));
7183
validateNameNotDuplicated(request.name(), id);
72-
homeUniversity.update(request.name(), request.maxChoiceCount());
84+
validateEmailDomainNotDuplicated(request.emailDomain(), id);
85+
homeUniversity.update(request.name(), request.maxChoiceCount(), request.emailDomain());
7386
return AdminHomeUniversityResponse.from(homeUniversity);
7487
}
7588

@@ -82,6 +95,18 @@ private void validateNameNotDuplicated(String name, Long excludeId) {
8295
});
8396
}
8497

98+
private void validateEmailDomainNotDuplicated(String emailDomain, Long excludeId) {
99+
if (emailDomain == null || emailDomain.isBlank()) {
100+
return;
101+
}
102+
homeUniversityRepository.findByEmailDomain(emailDomain)
103+
.ifPresent(existing -> {
104+
if (!existing.getId().equals(excludeId)) {
105+
throw new CustomException(HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS);
106+
}
107+
});
108+
}
109+
85110
@Transactional
86111
@DefaultCacheOut(
87112
key = {"univApplyInfoTextSearch", "university:recommend:general"},

src/main/java/com/example/solidconnection/common/exception/ErrorCode.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public enum ErrorCode {
4747
HOST_UNIVERSITY_HAS_REFERENCES(HttpStatus.CONFLICT.value(), "해당 파견 대학을 참조하는 대학 지원 정보가 존재합니다."),
4848
HOME_UNIVERSITY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "협정 대학교를 찾을 수 없습니다."),
4949
HOME_UNIVERSITY_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 존재하는 협정 대학입니다."),
50+
HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 사용 중인 이메일 도메인입니다."),
5051
HOME_UNIVERSITY_HAS_REFERENCES(HttpStatus.CONFLICT.value(), "해당 협정 대학을 참조하는 데이터가 존재합니다."),
5152
COUNTRY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "국가를 찾을 수 없습니다."),
5253
COUNTRY_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 국가를 찾을 수 없습니다."),
@@ -78,6 +79,14 @@ public enum ErrorCode {
7879
SIGN_IN_FAILED(HttpStatus.UNAUTHORIZED.value(), "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요."),
7980
OAUTH_USER_CANNOT_CHANGE_PASSWORD(HttpStatus.BAD_REQUEST.value(), "소셜 로그인 사용자는 비밀번호를 변경할 수 없습니다."),
8081

82+
// school email verification
83+
SCHOOL_EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST.value(), "이미 학교 이메일 인증이 완료되었습니다."),
84+
SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 학교 이메일 도메인입니다."),
85+
SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "학교 이메일 인증 요청을 찾을 수 없습니다. 인증 코드 발송을 다시 요청해주세요."),
86+
SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT(HttpStatus.BAD_REQUEST.value(), "인증 코드가 일치하지 않습니다."),
87+
SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "학교 이메일 인증 정보 저장에 실패했습니다."),
88+
SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "학교 이메일 인증 정보가 손상되었습니다. 인증 코드 발송을 다시 요청해주세요."),
89+
8190
// s3
8291
S3_SERVICE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 서비스 에러 발생"),
8392
S3_CLIENT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 클라이언트 에러 발생"),
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.example.solidconnection.common.mail;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.mail.SimpleMailMessage;
5+
import org.springframework.mail.javamail.JavaMailSender;
6+
import org.springframework.stereotype.Service;
7+
8+
@Service
9+
@RequiredArgsConstructor
10+
public class MailService {
11+
12+
private final JavaMailSender javaMailSender;
13+
14+
public void sendVerificationEmail(String to, String verificationCode) {
15+
SimpleMailMessage message = new SimpleMailMessage();
16+
message.setTo(to);
17+
message.setSubject("[Solid Connect] 학교 이메일 인증");
18+
message.setText("인증 코드: " + verificationCode + "\n\n인증 코드는 5분간 유효합니다.");
19+
javaMailSender.send(message);
20+
}
21+
}

src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
import com.example.solidconnection.siteuser.dto.LocationUpdateRequest;
66
import com.example.solidconnection.siteuser.dto.MyPageResponse;
77
import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest;
8+
import com.example.solidconnection.siteuser.dto.SchoolEmailConfirmRequest;
9+
import com.example.solidconnection.siteuser.dto.SchoolEmailRequest;
810
import com.example.solidconnection.siteuser.service.MyPageService;
11+
import com.example.solidconnection.siteuser.service.SchoolEmailService;
912
import jakarta.validation.Valid;
1013
import lombok.RequiredArgsConstructor;
1114
import org.springframework.http.ResponseEntity;
1215
import org.springframework.web.bind.annotation.GetMapping;
1316
import org.springframework.web.bind.annotation.PatchMapping;
17+
import org.springframework.web.bind.annotation.PostMapping;
1418
import org.springframework.web.bind.annotation.RequestBody;
1519
import org.springframework.web.bind.annotation.RequestMapping;
1620
import org.springframework.web.bind.annotation.RequestParam;
@@ -23,6 +27,7 @@
2327
class MyPageController {
2428

2529
private final MyPageService myPageService;
30+
private final SchoolEmailService schoolEmailService;
2631

2732
@GetMapping
2833
public ResponseEntity<MyPageResponse> getMyPageInfo(
@@ -59,4 +64,22 @@ public ResponseEntity<Void> updateLocation(
5964
myPageService.updateLocation(siteUserId, request);
6065
return ResponseEntity.ok().build();
6166
}
67+
68+
@PostMapping("/school-email")
69+
public ResponseEntity<Void> requestSchoolEmailVerification(
70+
@AuthorizedUser long siteUserId,
71+
@RequestBody @Valid SchoolEmailRequest request
72+
) {
73+
schoolEmailService.requestSchoolEmailVerification(siteUserId, request.schoolEmail());
74+
return ResponseEntity.ok().build();
75+
}
76+
77+
@PostMapping("/school-email/confirm")
78+
public ResponseEntity<Void> confirmSchoolEmail(
79+
@AuthorizedUser long siteUserId,
80+
@RequestBody @Valid SchoolEmailConfirmRequest request
81+
) {
82+
schoolEmailService.confirmSchoolEmail(siteUserId, request.code());
83+
return ResponseEntity.ok().build();
84+
}
6285
}

src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ public void updateUserStatus(UserStatus status) {
159159
this.userStatus = status;
160160
}
161161

162+
public void verifySchool(Long homeUniversityId) {
163+
this.homeUniversityId = homeUniversityId;
164+
}
165+
162166
public void becomeMentor() {
163167
this.role = Role.MENTOR;
164168
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.example.solidconnection.siteuser.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
public record SchoolEmailConfirmRequest(
6+
@NotBlank(message = "인증 코드는 필수입니다")
7+
String code
8+
) {
9+
10+
}

0 commit comments

Comments
 (0)