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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public enum ErrorCode {

// school email verification
SCHOOL_EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST.value(), "이미 학교 이메일 인증이 완료되었습니다."),
SCHOOL_EMAIL_ALREADY_USED(HttpStatus.CONFLICT.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(), "인증 코드가 일치하지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public class UpdateViewCountService {
public void updateViewCount(String key) {
Long postId = postRedisManager.getPostIdFromPostViewCountRedisKey(key);
Long viewCount = postRedisManager.getAndDeleteViewCount(key);
if (viewCount == null) {
return;
}
postRepository.increaseViewCount(postId, viewCount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
@UniqueConstraint(
name = "uk_site_user_nickname",
columnNames = {"nickname"}
),
@UniqueConstraint(
name = "uk_site_user_verified_school_email",
columnNames = {"verified_school_email"}
)
})
public class SiteUser extends BaseEntity {
Expand All @@ -54,6 +58,9 @@ public class SiteUser extends BaseEntity {
@Column(name = "home_university_id", nullable = true)
private Long homeUniversityId;

@Column(name = "verified_school_email", nullable = true, length = 100)
private String verifiedSchoolEmail;

@Setter
@Column(name = "profile_image_url", length = 500)
private String profileImageUrl;
Expand Down Expand Up @@ -159,8 +166,9 @@ public void updateUserStatus(UserStatus status) {
this.userStatus = status;
}

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

public void becomeMentor() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

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

public record SchoolEmailRequest(
@NotBlank(message = "학교 이메일은 필수입니다")
@Email(message = "올바른 이메일 형식이 아닙니다")
@Size(max = 100, message = "학교 이메일은 100자 이하여야 합니다")
String schoolEmail
) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public interface SiteUserRepository extends JpaRepository<SiteUser, Long>, SiteU

boolean existsByNickname(String nickname);

boolean existsByVerifiedSchoolEmail(String verifiedSchoolEmail);

@Query("SELECT u FROM SiteUser u WHERE u.quitedAt <= :cutoffDate")
List<SiteUser> findUsersToBeRemoved(@Param("cutoffDate") LocalDate cutoffDate);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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_ALREADY_USED;
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;
Expand All @@ -17,9 +18,11 @@
import com.example.solidconnection.university.repository.HomeUniversityRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Locale;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -46,15 +49,17 @@ public void requestSchoolEmailVerification(long siteUserId, String schoolEmail)
throw new CustomException(SCHOOL_EMAIL_ALREADY_VERIFIED);
}

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

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

try {
mailService.sendVerificationEmail(schoolEmail, code);
mailService.sendVerificationEmail(normalizedSchoolEmail, code);
} catch (Exception e) {
redisTemplate.delete(KEY_PREFIX + siteUserId);
throw e;
Expand All @@ -72,10 +77,21 @@ public void confirmSchoolEmail(long siteUserId, String code) {
throw new CustomException(SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT);
}

siteUser.verifySchool(info.getHomeUniversityId());
String verifiedSchoolEmail = normalizeSchoolEmail(info.getSchoolEmail());
validateVerifiedSchoolEmailNotDuplicated(verifiedSchoolEmail);
siteUser.verifySchool(info.getHomeUniversityId(), verifiedSchoolEmail);
flushVerifiedSchoolEmail();
redisTemplate.delete(KEY_PREFIX + siteUserId);
}

private void flushVerifiedSchoolEmail() {
try {
siteUserRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new CustomException(SCHOOL_EMAIL_ALREADY_USED);
}
}

private void saveVerificationInfo(long siteUserId, SchoolVerificationInfo info) {
try {
redisTemplate.opsForValue().set(
Expand All @@ -95,15 +111,57 @@ private SchoolVerificationInfo getVerificationInfo(long siteUserId) {
throw new CustomException(SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND);
}
try {
return objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class);
SchoolVerificationInfo info = objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class);
validateVerificationInfo(siteUserId, info);
return info;
} catch (JsonProcessingException e) {
redisTemplate.delete(KEY_PREFIX + siteUserId);
throw new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED);
throw corruptedVerificationInfoException(siteUserId);
}
}

private void validateVerificationInfo(long siteUserId, SchoolVerificationInfo info) {
if (info == null
|| isBlank(info.getSchoolEmail())
|| !hasEmailDomain(info.getSchoolEmail())
|| info.getHomeUniversityId() == null
|| isBlank(info.getCode())
|| !homeUniversityRepository.existsById(info.getHomeUniversityId())) {
throw corruptedVerificationInfoException(siteUserId);
}
}

private boolean isBlank(String value) {
return value == null || value.isBlank();
}

private CustomException corruptedVerificationInfoException(long siteUserId) {
redisTemplate.delete(KEY_PREFIX + siteUserId);
return new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED);
}

private void validateVerifiedSchoolEmailNotDuplicated(String verifiedSchoolEmail) {
if (siteUserRepository.existsByVerifiedSchoolEmail(verifiedSchoolEmail)) {
throw new CustomException(SCHOOL_EMAIL_ALREADY_USED);
}
}

private String normalizeSchoolEmail(String schoolEmail) {
return schoolEmail.trim().toLowerCase(Locale.ROOT);
}

private String extractEmailDomain(String email) {
return email.substring(email.indexOf('@') + 1).toLowerCase();
if (!hasEmailDomain(email)) {
throw new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED);
}
return email.substring(email.indexOf('@') + 1);
}

private boolean hasEmailDomain(String email) {
if (email == null) {
return false;
}
int atIndex = email.indexOf('@');
return atIndex > 0 && atIndex < email.length() - 1;
}

private String generateVerificationCode() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE site_user
ADD COLUMN verified_school_email VARCHAR(100) NULL,
ADD CONSTRAINT uk_site_user_verified_school_email UNIQUE (verified_school_email);
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.example.solidconnection.community.post.service;

import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;

import com.example.solidconnection.community.post.repository.PostRepository;
import org.junit.jupiter.api.DisplayName;
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;

@DisplayName("조회수 업데이트 서비스 테스트")
@ExtendWith(MockitoExtension.class)
class UpdateViewCountServiceTest {

@InjectMocks
private UpdateViewCountService updateViewCountService;

@Mock
private PostRepository postRepository;

@Mock
private PostRedisManager postRedisManager;

@Test
void Redis_조회수_키가_이미_소비되었으면_DB_업데이트를_하지_않는다() {
// given
String key = "post:view:1";
given(postRedisManager.getPostIdFromPostViewCountRedisKey(key)).willReturn(1L);
given(postRedisManager.getAndDeleteViewCount(key)).willReturn(null);

// when
updateViewCountService.updateViewCount(key);

// then
then(postRepository).shouldHaveNoInteractions();
}

@Test
void Redis_조회수가_있으면_DB_조회수를_증가시킨다() {
// given
String key = "post:view:1";
given(postRedisManager.getPostIdFromPostViewCountRedisKey(key)).willReturn(1L);
given(postRedisManager.getAndDeleteViewCount(key)).willReturn(2L);

// when
updateViewCountService.updateViewCount(key);

// then
then(postRepository).should().increaseViewCount(1L, 2L);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.example.solidconnection.siteuser.dto;

import static org.assertj.core.api.Assertions.assertThat;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

@DisplayName("학교 이메일 인증 요청 유효성 검사 테스트")
class SchoolEmailRequestTest {

private static final String MESSAGE = "message";
private static final String SCHOOL_EMAIL_MAX_LENGTH_MESSAGE = "학교 이메일은 100자 이하여야 합니다";

private Validator validator;

@BeforeEach
void setUp() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}

@Nested
class 학교_이메일_길이_검증 {

@Test
void 학교_이메일이_100자이면_검증을_통과한다() {
// given
SchoolEmailRequest request = new SchoolEmailRequest(emailWithLength(100));

// when
Set<ConstraintViolation<SchoolEmailRequest>> violations = validator.validate(request);

// then
assertThat(violations).isEmpty();
}

@Test
void 학교_이메일이_100자를_초과하면_검증에_실패한다() {
// given
SchoolEmailRequest request = new SchoolEmailRequest(emailWithLength(101));

// when
Set<ConstraintViolation<SchoolEmailRequest>> violations = validator.validate(request);

// then
assertThat(violations)
.extracting(MESSAGE)
.contains(SCHOOL_EMAIL_MAX_LENGTH_MESSAGE);
}
}

private String emailWithLength(int length) {
String prefix = "a@";
String domainPrefix = "b".repeat(63) + ".";
String suffix = ".edu";
String domain = domainPrefix + "c".repeat(length - prefix.length() - domainPrefix.length() - suffix.length());
return prefix + domain + suffix;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,22 @@ class 닉네임은_중복될_수_없다 {
.isInstanceOf(DataIntegrityViolationException.class);
}
}

@Nested
class 인증된_학교_이메일은_중복될_수_없다 {

@Test
void 인증된_학교_이메일이_동일한_사용자를_저장하면_예외가_발생한다() {
// given
SiteUser user1 = createSiteUser("email1", "nickname1", AuthType.KAKAO);
SiteUser user2 = createSiteUser("email2", "nickname2", AuthType.KAKAO);
user1.verifySchool(1L, "test@inha.edu");
user2.verifySchool(1L, "test@inha.edu");
siteUserRepository.save(user1);

// when, then
assertThatCode(() -> siteUserRepository.saveAndFlush(user2))
.isInstanceOf(DataIntegrityViolationException.class);
}
}
}
Loading
Loading