Skip to content

Commit

Permalink
[YS-68] feat: 연구자 회원가입 API 구현 (#19)
Browse files Browse the repository at this point in the history
* feat: implements participant signup

- 참여자 회원가입 API 구현
- 구글 OAuth 에외처리 로직 일부 수정

* test: add test codes for SignupMapper and ParticipantSignupUseCase

- 테스트 커버리지 충족을 위해 테스트 코드 추가
- `SignupMapperTest` 코드 추가
- `ParticipantSignupUseCase` 코드 추가

* fix: resolve duplicate requested authentication

- 실험자 회원가입 시, Authentication 중복 생성 코드 제거

* refact: move DB Transaction to seperate responsibility to satisfy clean
architecture principal

- DB 트랜잭션 책임 관련: UseCase  → Service 계층으로 위임

* refact: Applay automatic Component Scan for UseCase classes

- UseCase 클래스가 비즈니스 로직의 핵심 계층: 자동으로 컴포넌트 스캔
  대상에 포함되도록 어노테이션 제거
- 관리와 확장성을 고려하여 클린 아키텍처를 적용한 구현 방식

* style: rename API endpoint signup to meet the convention rule

- 회원가입 API endpoint 관련하여 기존 `/join` → `/signup` 으로 개선
- 코드 컨벤션을 위한 API endpoint 개선

* refact: update annotations to validate DateTime Format

- 생년월일 데이터 포맷 관련: 기존: `@NotBlank` → `@NotNull` `@Past`
  `@DateTimeFormat`

* refact: rename mapper function to match its usecase

- dto → entity 변환하는 용례에 따라, 기존: `toAddressInfoDto` →
  `toAddressInfo` 로 리네이밍

* fix: reflect updated codes to test code

- dto 함수 리네이밍 반영한 `SingupMapperTest` 버그 해결

* feat: implement researcher member signup

- 연구자 회원가입 기본 로직 초안 구현
- `emailVerified` 필드 값이 false이면, `EmailNotValidateException`
  반환하도록 구현

* test: update test codes for adjust additional logic

- 세부 조정사항: 기존 MemberEntity에 있던 `birthDate`  →
  ParticipantEntity 에 추가
- 이에 따른 기존 test code 수정하여 반영

* test: add test codes for AuthenticationUtils

- `AuthenticationUtils` 에 대한 test code 작성

* test: add test codes to UseCases and Service related to signup logic

- `SignupService`에 대한 test code 작성
- `CreateResearcherUsecase`,`ParticipantSignupUsecase` test code
  작성&수정

* feat: add verification entity

- `VerificationEntity`, `VerificationStatus` 도메인 엔티티 추가
- `VerificationRepository` 레포지토리 추가

* feat: implement Email Send Code and Verification using SMTP

- SMTP 활용하여 메일 인증 코드 발송 로직 구현
- 유효한 이메일 도메인인지 확인(실제 존재하는지 여부)
- 학교 이메일 여부 확인 로직 구현
- 학교 이메일 코드 전송 로직 구현

* fix: add exception codes for Email Verification logic

- 인증코드 만료 시에도 재요청 가능하도록 로직 수정
- 이미 승인된 대학 이메일에 대해서는 '이미 승인 완료'라고 예외 처리

* docs: add application-yml file to .gitignore

* fix: resolve merge conflicts with dev branch

* chore: move packages structure

- `usecase/SignUpUseCase/email` 패키지 밑에 메일 인증 관련 UseCase 재배ġ

* fix: adjust test codes for non-failed test configuration

- 테스트가 Fail하는 현상을 막기 위해, 몇 가지 조치를 취했습니다.

* test: add test codes for Email send verification logic

- `application/usecase/email/` 패키지 하위 UseCase에 대한 테스트 코드
  작성

* feat: add email verification condition to researcherSignup

- 연구자 회원가입 전 인증된 학교 메일에 대해서만 가입 가능하게끔 변경
- `ResearcherSignupRequest` 에서 `emailVerified=false` 인 경우, 예외
  반환

* fix: add test codes for SignupServiceTest to meet the code coverage

* refact: reflect updation from the code review

- `/v1/email`→ `/v1/emails` 로 엔드포인트 조정
- 이메일 도메인 검증 중 getter 삭제 후 인덱싱 방법으로 재조정
- MemberResponse 필드값 `isSuccess` typo 수정
- 기존 검증 로직 EmailUtils 패키지로 구조 조정
  • Loading branch information
chock-cho authored Jan 7, 2025
1 parent 48c983c commit 1f89fb3
Show file tree
Hide file tree
Showing 64 changed files with 1,106 additions and 250 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ application-local.yml

### Kotlin ###
.kotlin
/src/main/resources/application.yml
10 changes: 8 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,27 @@ configurations {

repositories {
mavenCentral()
maven{url = uri("https://jitpack.io") }
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-jpa") {
exclude(group = "org.hibernate", module = "hibernate-core")
}
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("com.github.f4b6a3:ulid-creator:5.2.3")
implementation("org.mariadb.jdbc:mariadb-java-client:2.7.3")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation ("io.awspring.cloud:spring-cloud-starter-aws:2.4.4")
implementation("com.github.in-seo:univcert:master-SNAPSHOT") {
exclude(group = "org.hamcrest", module= "harmcest-core")
}
compileOnly("org.projectlombok:lombok")
runtimeOnly("com.mysql:mysql-connector-j")
runtimeOnly("com.h2database:h2")
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/dobby/backend/DobbyBackendApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.cloud.openfeign.EnableFeignClients
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.FilterType
import org.springframework.data.jpa.repository.config.EnableJpaAuditing

@ComponentScan(
includeFilters = [ComponentScan.Filter(
Expand All @@ -17,6 +18,7 @@ import org.springframework.context.annotation.FilterType
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableFeignClients
@EnableJpaAuditing
class DobbyBackendApplication

fun main(args: Array<String>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package com.dobby.backend.application.mapper

import com.dobby.backend.infrastructure.database.entity.MemberEntity
import com.dobby.backend.infrastructure.database.entity.ParticipantEntity
import com.dobby.backend.infrastructure.database.entity.ResearcherEntity
import com.dobby.backend.infrastructure.database.entity.enum.MemberStatus
import com.dobby.backend.infrastructure.database.entity.enum.RoleType
import com.dobby.backend.presentation.api.dto.request.signup.ParticipantSignupRequest
import com.dobby.backend.presentation.api.dto.request.signup.ResearcherSignupRequest
import com.dobby.backend.infrastructure.database.entity.AddressInfo as AddressInfo
import com.dobby.backend.presentation.api.dto.request.signup.AddressInfo as DtoAddressInfo

Expand All @@ -15,16 +17,27 @@ object SignupMapper {
dto.area
)
}
fun toMember(req: ParticipantSignupRequest): MemberEntity {
fun toParticipantMember(req: ParticipantSignupRequest): MemberEntity {
return MemberEntity(
id = 0, // Auto-generated
oauthEmail = req.oauthEmail,
provider = req.provider,
status = MemberStatus.ACTIVE,
role = RoleType.PARTICIPANT,
contactEmail = req.contactEmail,
name = req.name
)
}

fun toResearcherMember(req: ResearcherSignupRequest): MemberEntity {
return MemberEntity(
id = 0, // Auto-generated
oauthEmail = req.oauthEmail,
provider = req.provider,
status = MemberStatus.ACTIVE,
role = RoleType.RESEARCHER,
contactEmail = req.contactEmail,
name = req.name,
birthDate = req.birthDate
)
}
fun toParticipant(
Expand All @@ -36,7 +49,23 @@ object SignupMapper {
basicAddressInfo = toAddressInfo(req.basicAddressInfo),
additionalAddressInfo = req.additionalAddressInfo?.let { toAddressInfo(it) },
preferType = req.preferType,
gender = req.gender
gender = req.gender,
birthDate = req.birthDate
)
}

fun toResearcher(
member: MemberEntity,
req: ResearcherSignupRequest
): ResearcherEntity{
return ResearcherEntity(
member = member,
univEmail = req.univEmail,
emailVerified = req.emailVerified,
univName = req.univName,
major = req.major,
labInfo = req.labInfo
)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.dobby.backend.application.mapper

import com.dobby.backend.infrastructure.database.entity.VerificationEntity
import com.dobby.backend.infrastructure.database.entity.enum.VerificationStatus
import com.dobby.backend.presentation.api.dto.request.signup.EmailSendRequest
import com.dobby.backend.presentation.api.dto.response.signup.EmailSendResponse
import com.dobby.backend.presentation.api.dto.response.signup.EmailVerificationResponse

object VerificationMapper {
fun toEntity(req: EmailSendRequest, code : String): VerificationEntity {
return VerificationEntity(
id= 0,
univMail = req.univEmail,
verificationCode = code,
status = VerificationStatus.HOLD,
expiresAt = null
)
}

fun toSendResDto() : EmailSendResponse{
return EmailSendResponse(
isSuccess = true,
message = "해당 학교 이메일로 성공적으로 코드를 전송했습니다. 10분 이내로 인증을 완료해주세요."
)
}

fun toVerifyResDto() : EmailVerificationResponse {
return EmailVerificationResponse(
isSuccess = true,
message = "학교 메일 인증이 완료되었습니다."
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.dobby.backend.application.service

import com.dobby.backend.application.usecase.SignupUseCase.email.EmailCodeSendUseCase
import com.dobby.backend.application.usecase.SignupUseCase.email.EmailVerificationUseCase
import com.dobby.backend.presentation.api.dto.request.signup.EmailSendRequest
import com.dobby.backend.presentation.api.dto.request.signup.EmailVerificationRequest
import com.dobby.backend.presentation.api.dto.response.signup.EmailSendResponse
import com.dobby.backend.presentation.api.dto.response.signup.EmailVerificationResponse
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service

@Service
class EmailService(
private val emailCodeSendUseCase: EmailCodeSendUseCase,
private val emailVerificationUseCase: EmailVerificationUseCase
) {
@Transactional
fun sendEmail(req: EmailSendRequest) : EmailSendResponse{
return emailCodeSendUseCase.execute(req)
}

@Transactional
fun verifyCode(req: EmailVerificationRequest) : EmailVerificationResponse {
return emailVerificationUseCase.execute(req)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package com.dobby.backend.application.service

import com.dobby.backend.application.usecase.FetchGoogleUserInfoUseCase
import com.dobby.backend.application.usecase.FetchNaverUserInfoUseCase
import com.dobby.backend.presentation.api.dto.request.auth.GoogleOauthLoginRequest
import com.dobby.backend.presentation.api.dto.request.auth.google.GoogleOauthLoginRequest
import com.dobby.backend.presentation.api.dto.request.auth.NaverOauthLoginRequest
import com.dobby.backend.presentation.api.dto.response.auth.OauthLoginResponse
import org.springframework.stereotype.Service
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
package com.dobby.backend.application.service

import com.dobby.backend.application.usecase.ParticipantSignupUseCase
import com.dobby.backend.application.usecase.SignupUseCase.CreateResearcherUseCase
import com.dobby.backend.application.usecase.SignupUseCase.ParticipantSignupUseCase
import com.dobby.backend.application.usecase.SignupUseCase.VerifyResearcherEmailUseCase
import com.dobby.backend.domain.exception.EmailNotValidateException
import com.dobby.backend.presentation.api.dto.request.signup.ParticipantSignupRequest
import com.dobby.backend.presentation.api.dto.request.signup.ResearcherSignupRequest
import com.dobby.backend.presentation.api.dto.response.signup.SignupResponse
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service

@Service
class SignupService(
private val participantSignupUseCase: ParticipantSignupUseCase
private val participantSignupUseCase: ParticipantSignupUseCase,
private val createResearcherUseCase: CreateResearcherUseCase,
private val verifyResearcherEmailUseCase: VerifyResearcherEmailUseCase
) {
@Transactional
fun participantSignup(input: ParticipantSignupRequest): SignupResponse {
return participantSignupUseCase.execute(input)
return participantSignupUseCase.execute(input)
}
}

@Transactional
fun researcherSignup(input: ResearcherSignupRequest) : SignupResponse{
if(!input.emailVerified) {
throw EmailNotValidateException()
}
verifyResearcherEmailUseCase.execute(input.univEmail)

return createResearcherUseCase.execute(input)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import com.dobby.backend.infrastructure.database.repository.MemberRepository
import com.dobby.backend.infrastructure.feign.google.GoogleAuthFeignClient
import com.dobby.backend.infrastructure.feign.google.GoogleUserInfoFeginClient
import com.dobby.backend.infrastructure.token.JwtTokenProvider
import com.dobby.backend.presentation.api.dto.request.auth.GoogleOauthLoginRequest
import com.dobby.backend.presentation.api.dto.request.auth.GoogleTokenRequest
import com.dobby.backend.presentation.api.dto.response.auth.GoogleTokenResponse
import com.dobby.backend.presentation.api.dto.request.auth.google.GoogleTokenRequest
import com.dobby.backend.presentation.api.dto.request.auth.google.GoogleOauthLoginRequest
import com.dobby.backend.presentation.api.dto.response.auth.google.GoogleTokenResponse
import com.dobby.backend.presentation.api.dto.response.auth.OauthLoginResponse
import com.dobby.backend.util.AuthenticationUtils

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.dobby.backend.application.usecase.SignupUseCase

import com.dobby.backend.application.mapper.SignupMapper
import com.dobby.backend.application.usecase.UseCase
import com.dobby.backend.infrastructure.database.repository.ResearcherRepository
import com.dobby.backend.infrastructure.token.JwtTokenProvider
import com.dobby.backend.presentation.api.dto.request.signup.ResearcherSignupRequest
import com.dobby.backend.presentation.api.dto.response.MemberResponse
import com.dobby.backend.presentation.api.dto.response.signup.SignupResponse
import com.dobby.backend.util.AuthenticationUtils

class CreateResearcherUseCase(
private val researcherRepository: ResearcherRepository,
private val jwtTokenProvider: JwtTokenProvider
) : UseCase<ResearcherSignupRequest, SignupResponse> {
override fun execute(input: ResearcherSignupRequest): SignupResponse {
val memberEntity = SignupMapper.toResearcherMember(input)
val newResearcher = SignupMapper.toResearcher(memberEntity, input)
researcherRepository.save(newResearcher)

val authentication = AuthenticationUtils.createAuthentication(memberEntity)
val accessToken = jwtTokenProvider.generateAccessToken(authentication)
val refreshToken = jwtTokenProvider.generateRefreshToken(authentication)

return SignupResponse(
accessToken = accessToken,
refreshToken = refreshToken,
memberInfo = MemberResponse.fromDomain(newResearcher.member.toDomain())
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.dobby.backend.application.usecase
package com.dobby.backend.application.usecase.SignupUseCase

import com.dobby.backend.application.mapper.SignupMapper
import com.dobby.backend.application.usecase.UseCase
import com.dobby.backend.infrastructure.database.repository.ParticipantRepository
import com.dobby.backend.infrastructure.token.JwtTokenProvider
import com.dobby.backend.presentation.api.dto.request.signup.ParticipantSignupRequest
Expand All @@ -11,10 +12,10 @@ import com.dobby.backend.util.AuthenticationUtils
class ParticipantSignupUseCase (
private val participantRepository: ParticipantRepository,
private val jwtTokenProvider: JwtTokenProvider
):UseCase<ParticipantSignupRequest, SignupResponse>
): UseCase<ParticipantSignupRequest, SignupResponse>
{
override fun execute(input: ParticipantSignupRequest): SignupResponse {
val memberEntity = SignupMapper.toMember(input)
val memberEntity = SignupMapper.toParticipantMember(input)
val participantEntity = SignupMapper.toParticipant(memberEntity, input)

val newParticipant = participantRepository.save(participantEntity)
Expand All @@ -28,4 +29,4 @@ class ParticipantSignupUseCase (
refreshToken = refreshToken
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.dobby.backend.application.usecase.SignupUseCase

import com.dobby.backend.application.usecase.UseCase
import com.dobby.backend.domain.exception.EmailNotValidateException
import com.dobby.backend.domain.exception.VerifyInfoNotFoundException
import com.dobby.backend.infrastructure.database.entity.VerificationEntity
import com.dobby.backend.infrastructure.database.entity.enum.VerificationStatus
import com.dobby.backend.infrastructure.database.repository.VerificationRepository

class VerifyResearcherEmailUseCase(
private val verificationRepository: VerificationRepository
) : UseCase<String, VerificationEntity> {
override fun execute(input: String): VerificationEntity {
val verificationEntity = verificationRepository.findByUnivMail(input)
?: throw VerifyInfoNotFoundException()

if (verificationEntity.status != VerificationStatus.VERIFIED) {
throw EmailNotValidateException()
}
return verificationEntity
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.dobby.backend.application.usecase.SignupUseCase.email

import com.dobby.backend.application.mapper.VerificationMapper
import com.dobby.backend.application.usecase.UseCase
import com.dobby.backend.domain.exception.*
import com.dobby.backend.domain.gateway.EmailGateway
import com.dobby.backend.infrastructure.database.entity.enum.VerificationStatus
import com.dobby.backend.infrastructure.database.repository.VerificationRepository
import com.dobby.backend.presentation.api.dto.request.signup.EmailSendRequest
import com.dobby.backend.presentation.api.dto.response.signup.EmailSendResponse
import com.dobby.backend.util.EmailUtils
import java.time.LocalDateTime

class EmailCodeSendUseCase(
private val verificationRepository: VerificationRepository,
private val emailGateway: EmailGateway
) : UseCase<EmailSendRequest, EmailSendResponse> {
override fun execute(input: EmailSendRequest): EmailSendResponse {
validateEmail(input.univEmail)

val code = EmailUtils.generateCode()
reflectVerification(input, code)

sendVerificationEmail(input, code)
return VerificationMapper.toSendResDto()
}

private fun validateEmail(email : String){
if(!EmailUtils.isDomainExists(email)) throw EmailDomainNotFoundException()
if(!EmailUtils.isUnivMail(email)) throw EmailNotUnivException()
}

private fun reflectVerification(input: EmailSendRequest, code: String) {
val existingInfo = verificationRepository.findByUnivMail(input.univEmail)

if (existingInfo != null) {
when (existingInfo.status) {
VerificationStatus.HOLD -> {
existingInfo.verificationCode = code
existingInfo.expiresAt = LocalDateTime.now().plusMinutes(10)
verificationRepository.save(existingInfo)
}

VerificationStatus.VERIFIED -> {
throw EmailAlreadyVerifiedException()
}
}
} else {
val newVerificationInfo = VerificationMapper.toEntity(input, code)
verificationRepository.save(newVerificationInfo)
}
}

private fun sendVerificationEmail(input: EmailSendRequest, code: String) {
val content = EMAIL_CONTENT_TEMPLATE.format(code)
emailGateway.sendEmail(input.univEmail, EMAIL_SUBJECT, content)
}

companion object {
private const val EMAIL_SUBJECT = "그라밋 - 이메일 인증 코드 입니다."
private const val EMAIL_CONTENT_TEMPLATE = """
안녕하세요, 그라밋입니다.
아래의 코드는 이메일 인증을 위한 코드입니다:
%s
10분 이내에 인증을 완료해주세요.
"""
}
}
Loading

0 comments on commit 1f89fb3

Please sign in to comment.