Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ dependencies {
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.awaitility:awaitility:4.2.0'

// Mail
implementation 'org.springframework.boot:spring-boot-starter-mail'

// Etc
implementation platform('software.amazon.awssdk:bom:2.41.4')
implementation 'software.amazon.awssdk:s3'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package com.example.solidconnection.admin.university.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record AdminHomeUniversityCreateRequest(
@NotBlank(message = "협정 대학명은 필수입니다")
@Size(max = 100, message = "협정 대학명은 100자 이하여야 합니다")
String name
String name,
@Pattern(
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])?)+$",
message = "올바른 이메일 도메인 형식이 아닙니다 (예: inha.edu, inu.ac.kr)"
)
String emailDomain
) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

public record AdminHomeUniversityResponse(
long id,
String name
String name,
String emailDomain
) {

public static AdminHomeUniversityResponse from(HomeUniversity homeUniversity) {
return new AdminHomeUniversityResponse(
homeUniversity.getId(),
homeUniversity.getName()
homeUniversity.getName(),
homeUniversity.getEmailDomain()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package com.example.solidconnection.admin.university.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record AdminHomeUniversityUpdateRequest(
@NotBlank(message = "협정 대학명은 필수입니다")
@Size(max = 100, message = "협정 대학명은 100자 이하여야 합니다")
String name
String name,
@Pattern(
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])?)+$",
message = "올바른 이메일 도메인 형식이 아닙니다 (예: inha.edu, inu.ac.kr)"
)
String emailDomain
) {

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.solidconnection.admin.university.service;

import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_ALREADY_EXISTS;
import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS;
import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_HAS_REFERENCES;
import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_NOT_FOUND;

Expand Down Expand Up @@ -48,7 +49,8 @@ public AdminHomeUniversityResponse getHomeUniversity(Long id) {
)
public AdminHomeUniversityResponse createHomeUniversity(AdminHomeUniversityCreateRequest request) {
validateNameNotExists(request.name());
HomeUniversity homeUniversity = new HomeUniversity(null, request.name());
validateEmailDomainNotExists(request.emailDomain());
HomeUniversity homeUniversity = new HomeUniversity(null, request.name(), request.emailDomain());
return AdminHomeUniversityResponse.from(homeUniversityRepository.save(homeUniversity));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand All @@ -59,6 +61,16 @@ private void validateNameNotExists(String name) {
});
}

private void validateEmailDomainNotExists(String emailDomain) {
if (emailDomain == null || emailDomain.isBlank()) {
return;
}
homeUniversityRepository.findByEmailDomain(emailDomain)
.ifPresent(existing -> {
throw new CustomException(HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS);
});
}

@Transactional
@DefaultCacheOut(
key = {"univApplyInfoTextSearch", "university:recommend:general"},
Expand All @@ -69,7 +81,8 @@ public AdminHomeUniversityResponse updateHomeUniversity(Long id, AdminHomeUniver
HomeUniversity homeUniversity = homeUniversityRepository.findById(id)
.orElseThrow(() -> new CustomException(HOME_UNIVERSITY_NOT_FOUND));
validateNameNotDuplicated(request.name(), id);
homeUniversity.update(request.name());
validateEmailDomainNotDuplicated(request.emailDomain(), id);
homeUniversity.update(request.name(), request.emailDomain());
return AdminHomeUniversityResponse.from(homeUniversity);
}

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

private void validateEmailDomainNotDuplicated(String emailDomain, Long excludeId) {
if (emailDomain == null || emailDomain.isBlank()) {
return;
}
homeUniversityRepository.findByEmailDomain(emailDomain)
.ifPresent(existing -> {
if (!existing.getId().equals(excludeId)) {
throw new CustomException(HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS);
}
});
}

@Transactional
@DefaultCacheOut(
key = {"univApplyInfoTextSearch", "university:recommend:general"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public enum ErrorCode {
HOST_UNIVERSITY_HAS_REFERENCES(HttpStatus.CONFLICT.value(), "해당 파견 대학을 참조하는 대학 지원 정보가 존재합니다."),
HOME_UNIVERSITY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "협정 대학교를 찾을 수 없습니다."),
HOME_UNIVERSITY_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 존재하는 협정 대학입니다."),
HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 사용 중인 이메일 도메인입니다."),
HOME_UNIVERSITY_HAS_REFERENCES(HttpStatus.CONFLICT.value(), "해당 협정 대학을 참조하는 데이터가 존재합니다."),
COUNTRY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "국가를 찾을 수 없습니다."),
COUNTRY_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 국가를 찾을 수 없습니다."),
Expand Down Expand Up @@ -78,6 +79,14 @@ public enum ErrorCode {
SIGN_IN_FAILED(HttpStatus.UNAUTHORIZED.value(), "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요."),
OAUTH_USER_CANNOT_CHANGE_PASSWORD(HttpStatus.BAD_REQUEST.value(), "소셜 로그인 사용자는 비밀번호를 변경할 수 없습니다."),

// school email verification
SCHOOL_EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST.value(), "이미 학교 이메일 인증이 완료되었습니다."),
SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 학교 이메일 도메인입니다."),
SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "학교 이메일 인증 요청을 찾을 수 없습니다. 인증 코드 발송을 다시 요청해주세요."),
SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT(HttpStatus.BAD_REQUEST.value(), "인증 코드가 일치하지 않습니다."),
SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "학교 이메일 인증 정보 저장에 실패했습니다."),
SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "학교 이메일 인증 정보가 손상되었습니다. 인증 코드 발송을 다시 요청해주세요."),

// s3
S3_SERVICE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 서비스 에러 발생"),
S3_CLIENT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 클라이언트 에러 발생"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.solidconnection.common.mail;

import lombok.RequiredArgsConstructor;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class MailService {

private final JavaMailSender javaMailSender;

public void sendVerificationEmail(String to, String verificationCode) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(to);
message.setSubject("[Solid Connect] 학교 이메일 인증");
message.setText("인증 코드: " + verificationCode + "\n\n인증 코드는 5분간 유효합니다.");
javaMailSender.send(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
import com.example.solidconnection.siteuser.dto.LocationUpdateRequest;
import com.example.solidconnection.siteuser.dto.MyPageResponse;
import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest;
import com.example.solidconnection.siteuser.dto.SchoolEmailConfirmRequest;
import com.example.solidconnection.siteuser.dto.SchoolEmailRequest;
import com.example.solidconnection.siteuser.service.MyPageService;
import com.example.solidconnection.siteuser.service.SchoolEmailService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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.RequestParam;
Expand All @@ -23,6 +27,7 @@
class MyPageController {

private final MyPageService myPageService;
private final SchoolEmailService schoolEmailService;

@GetMapping
public ResponseEntity<MyPageResponse> getMyPageInfo(
Expand Down Expand Up @@ -59,4 +64,22 @@ public ResponseEntity<Void> updateLocation(
myPageService.updateLocation(siteUserId, request);
return ResponseEntity.ok().build();
}

@PostMapping("/school-email")
public ResponseEntity<Void> requestSchoolEmailVerification(
@AuthorizedUser long siteUserId,
@RequestBody @Valid SchoolEmailRequest request
) {
schoolEmailService.requestSchoolEmailVerification(siteUserId, request.schoolEmail());
return ResponseEntity.ok().build();
}

@PostMapping("/school-email/confirm")
public ResponseEntity<Void> confirmSchoolEmail(
@AuthorizedUser long siteUserId,
@RequestBody @Valid SchoolEmailConfirmRequest request
) {
schoolEmailService.confirmSchoolEmail(siteUserId, request.code());
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ public void updateUserStatus(UserStatus status) {
this.userStatus = status;
}

public void verifySchool(Long homeUniversityId) {
this.homeUniversityId = homeUniversityId;
}

public void becomeMentor() {
this.role = Role.MENTOR;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.solidconnection.siteuser.dto;

import jakarta.validation.constraints.NotBlank;

public record SchoolEmailConfirmRequest(
@NotBlank(message = "인증 코드는 필수입니다")
String code
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.siteuser.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record SchoolEmailRequest(
@NotBlank(message = "학교 이메일은 필수입니다")
@Email(message = "올바른 이메일 형식이 아닙니다")
String schoolEmail
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.solidconnection.siteuser.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class SchoolVerificationInfo {

private String schoolEmail;
private Long homeUniversityId;
private String code;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.example.solidconnection.siteuser.service;

import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_VERIFIED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED;
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;

import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.common.mail.MailService;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.dto.SchoolVerificationInfo;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.university.domain.HomeUniversity;
import com.example.solidconnection.university.repository.HomeUniversityRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class SchoolEmailService {

private static final long VERIFICATION_CODE_TTL_SECONDS = 300;
private static final String KEY_PREFIX = "school-email:";

private final SiteUserRepository siteUserRepository;
private final HomeUniversityRepository homeUniversityRepository;
private final MailService mailService;
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;

@Transactional
public void requestSchoolEmailVerification(long siteUserId, String schoolEmail) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

트랜잭션 어노테이션 달아주세요 !

SiteUser siteUser = siteUserRepository.findById(siteUserId)
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));

if (siteUser.getHomeUniversityId() != null) {
throw new CustomException(SCHOOL_EMAIL_ALREADY_VERIFIED);
}

String domain = extractEmailDomain(schoolEmail);
HomeUniversity homeUniversity = homeUniversityRepository.findByEmailDomain(domain)
.orElseThrow(() -> new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED));

String code = generateVerificationCode();
saveVerificationInfo(siteUserId, new SchoolVerificationInfo(schoolEmail, homeUniversity.getId(), code));

try {
mailService.sendVerificationEmail(schoolEmail, code);
} catch (Exception e) {
redisTemplate.delete(KEY_PREFIX + siteUserId);
throw e;
}
}

@Transactional
public void confirmSchoolEmail(long siteUserId, String code) {
SiteUser siteUser = siteUserRepository.findById(siteUserId)
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));

SchoolVerificationInfo info = getVerificationInfo(siteUserId);

if (!info.getCode().equals(code)) {
throw new CustomException(SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT);
}

siteUser.verifySchool(info.getHomeUniversityId());
redisTemplate.delete(KEY_PREFIX + siteUserId);
}

private void saveVerificationInfo(long siteUserId, SchoolVerificationInfo info) {
try {
redisTemplate.opsForValue().set(
KEY_PREFIX + siteUserId,
objectMapper.writeValueAsString(info),
VERIFICATION_CODE_TTL_SECONDS,
TimeUnit.SECONDS
);
} catch (JsonProcessingException e) {
throw new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED);
}
}

private SchoolVerificationInfo getVerificationInfo(long siteUserId) {
String jsonInfo = redisTemplate.opsForValue().get(KEY_PREFIX + siteUserId);
if (jsonInfo == null) {
throw new CustomException(SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND);
}
try {
return objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class);
} catch (JsonProcessingException e) {
redisTemplate.delete(KEY_PREFIX + siteUserId);
throw new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED);
}
}

private String extractEmailDomain(String email) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DTO에서 이미 @Email로 검증되지 않나요 ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

중복 검증이네요 바로 substring으로 잘라도 무방할것같습니다 수정하겠음돠

return email.substring(email.indexOf('@') + 1).toLowerCase();
}
Comment on lines +105 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

이메일 형식 검증 강화 필요

현재 @ 문자 존재 여부만 확인하고 있어, @inha.edu(로컬 파트 없음)나 test@(도메인 없음) 같은 잘못된 형식이 통과될 수 있습니다. 또한 103-104번 라인에서 @가 없을 때 SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED 에러를 던지는데, 이는 형식 오류이지 도메인 미지원 문제가 아니므로 에러 코드가 오해를 불러일으킬 수 있습니다.

개선 방안:

  1. @ 전후에 실제 텍스트가 있는지 확인 (atIndex > 0 && atIndex < email.length() - 1)
  2. 필요시 정규식 또는 Jakarta Validation의 @Email 어노테이션 활용
  3. 형식 오류용 별도 에러 코드 추가 고려 (예: SCHOOL_EMAIL_INVALID_FORMAT)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java`
around lines 101 - 107, The extractEmailDomain method currently only checks for
presence of '@' and can accept invalid emails like "`@domain`" or "local@"; update
extractEmailDomain to validate both sides of '@' (ensure atIndex > 0 && atIndex
< email.length() - 1) and throw a distinct format error (e.g.,
SCHOOL_EMAIL_INVALID_FORMAT) when that check fails instead of
SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED; optionally mention replacing or supplementing
this simple check with a proper regex or Jakarta `@Email` validation in the
calling flow for stricter validation.


private String generateVerificationCode() {
return String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000));
}
}
Loading
Loading