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
@@ -0,0 +1,36 @@
package codel.member.business.signup

import codel.config.Loggable
import codel.member.business.SignupService
import codel.member.domain.Member
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Component
import org.springframework.web.multipart.MultipartFile

/**
* 본인인증 기능 추가 후 전략
*
* 본인인증 이미지 제출 기능이 추가된 신규 앱(1.2.0 이상)용 전략입니다.
* 히든 프로필 이미지 제출 후, 별도의 본인인증 이미지 제출이 필요합니다.
* 재심사의 경우 새로운 재심사 전용 API(/v1/profile/review/resubmit)를 사용해야 합니다.
*/
@Component
class PostVerificationStrategy(
private val signupService: SignupService
) : SignupStrategy, Loggable {

override fun handleHiddenImages(
member: Member,
images: List<MultipartFile>
): ResponseEntity<Any> {
log.info {
"본인인증 후 플로우 - userId: ${member.getIdOrThrow()}, " +
"appVersion: >= 1.2.0"
}

// SignupService의 registerHiddenImages 호출 (히든 이미지만 등록)
signupService.registerHiddenImages(member, images)

return ResponseEntity.ok().build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package codel.member.business.signup

import codel.config.Loggable
import codel.member.business.SignupService
import codel.member.domain.Member
import codel.member.domain.MemberStatus
import codel.member.exception.MemberException
import codel.member.infrastructure.MemberJpaRepository
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile

/**
* 본인인증 기능 추가 전 전략
*
* 본인인증 이미지 제출 기능이 없던 구버전 앱(1.2.0 미만)용 전략입니다.
* 히든 프로필 이미지 제출 시 회원 상태에 따라 다르게 처리합니다:
* - PERSONALITY_COMPLETED: 히든 이미지 등록 후 HIDDEN_COMPLETED 상태로 변경 (정상 회원가입 완료)
*/
@Component
class PreVerificationStrategy(
private val signupService: SignupService,
private val memberJpaRepository: MemberJpaRepository
) : SignupStrategy, Loggable {

@Transactional
override fun handleHiddenImages(
member: Member,
images: List<MultipartFile>
): ResponseEntity<Any> {
log.info {
"본인인증 전 플로우 - userId: ${member.getIdOrThrow()}, " +
"status: ${member.memberStatus}, appVersion: < 1.2.0"
}

// 히든 이미지 등록 (기존 SignupService 로직 재활용)
signupService.registerHiddenImages(member, images)

member.completeHiddenProfile()
memberJpaRepository.save(member)
log.info {
"정상 가입 플로우 완료 - userId: ${member.getIdOrThrow()}, " +
"status: HIDDEN_COMPLETED"
}

return ResponseEntity.ok().build()
}
}
25 changes: 25 additions & 0 deletions src/main/kotlin/codel/member/business/signup/SignupStrategy.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package codel.member.business.signup

import codel.member.domain.Member
import org.springframework.http.ResponseEntity
import org.springframework.web.multipart.MultipartFile

/**
* 회원가입 히든 이미지 등록 전략 인터페이스
*
* 앱 버전과 회원 상태에 따라 다른 동작을 수행하기 위한 전략 패턴
*/
interface SignupStrategy {

/**
* 히든 이미지 등록 처리
*
* @param member 로그인한 회원
* @param images 업로드할 이미지 파일 목록
* @return 처리 결과 응답
*/
fun handleHiddenImages(
member: Member,
images: List<MultipartFile>
): ResponseEntity<Any>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package codel.member.business.signup

import codel.config.Loggable
import org.springframework.stereotype.Component

/**
* 회원가입 전략 선택 Resolver
*
* 앱 버전을 기반으로 적절한 SignupStrategy를 선택합니다.
* - 1.2.0 미만: 본인인증 기능 추가 전 전략 (PreVerificationStrategy)
* - 1.2.0 이상: 본인인증 기능 추가 후 전략 (PostVerificationStrategy)
*/
@Component
class SignupStrategyResolver(
private val postVerificationStrategy: PostVerificationStrategy,
private val preVerificationStrategy: PreVerificationStrategy
) : Loggable {

/**
* 앱 버전에 따라 적절한 전략을 선택합니다.
*
* @param appVersion 앱 버전 (X-App-Version 헤더)
* @return 선택된 전략
*/
fun resolveStrategy(appVersion: String?): SignupStrategy {
log.debug {
"전략 선택 시작 - appVersion: $appVersion"
}

return when {
// 신규 앱 (1.2.0 이상) → 본인인증 후 전략
isNewApp(appVersion) -> {
log.info {
"PostVerificationStrategy 선택 - appVersion: $appVersion"
}
postVerificationStrategy
}

// 구버전 앱 (1.2.0 미만) → 본인인증 전 전략
else -> {
log.info {
"PreVerificationStrategy 선택 - appVersion: ${appVersion ?: "null"}"
}
preVerificationStrategy
}
}
}

/**
* 신규 앱 버전인지 판단
*
* 1.2.0 이상이면 신규 앱으로 간주합니다.
*
* @param version 앱 버전 문자열 (예: "1.2.0")
* @return 신규 앱 여부
*/
private fun isNewApp(version: String?): Boolean {
if (version == null) {
log.debug { "앱 버전 null → 구버전으로 간주" }
return false
}

return try {
val parts = version.split(".")
val major = parts.getOrNull(0)?.toIntOrNull() ?: 0
val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0

// 1.2.0 이상이면 신규 앱
val isNew = major > 1 || (major == 1 && minor >= 2)

log.debug {
"앱 버전 파싱: $version → major=$major, minor=$minor, isNew=$isNew"
}

isNew
} catch (e: Exception) {
log.warn(e) { "앱 버전 파싱 실패: $version → 구버전으로 간주" }
false // 파싱 실패 시 안전하게 구버전으로 간주
}
}
}
12 changes: 8 additions & 4 deletions src/main/kotlin/codel/member/presentation/SignupController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package codel.member.presentation
import codel.config.argumentresolver.LoginMember
import codel.member.business.MemberService
import codel.member.business.SignupService
import codel.member.business.signup.SignupStrategyResolver
import codel.member.domain.Member
import codel.member.presentation.request.EssentialProfileRequest
import codel.member.presentation.request.HiddenProfileRequest
Expand All @@ -23,6 +24,7 @@ import org.springframework.web.multipart.MultipartFile
class SignupController(
private val memberService: MemberService,
private val signupService: SignupService,
private val signupStrategyResolver: SignupStrategyResolver,
private val asyncNotificationService: IAsyncNotificationService
) : SignupControllerSwagger {

Expand Down Expand Up @@ -81,10 +83,12 @@ class SignupController(
@PostMapping("/hidden/images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
override fun registerHiddenImages(
@LoginMember member: Member,
@RequestPart images: List<MultipartFile>
): ResponseEntity<Unit> {
signupService.registerHiddenImages(member, images)
return ResponseEntity.ok().build()
@RequestPart images: List<MultipartFile>,
@RequestHeader("X-App-Version", required = false) appVersion: String?
): ResponseEntity<Any> {
// 앱 버전에 따라 적절한 전략을 선택하여 처리
val strategy = signupStrategyResolver.resolveStrategy(appVersion)
return strategy.handleHiddenImages(member, images)
}

@PostMapping("/verification/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,19 +117,32 @@ interface SignupControllerSwagger {

@Operation(
summary = "Hidden Profile 이미지 등록",
description = "히든 프로필 이미지를 등록하고 Hidden Profile을 완료합니다. 회원가입이 완료되어 PENDING 상태로 변경됩니다."
description = """
히든 프로필 이미지를 등록합니다. 앱 버전과 회원 상태에 따라 다르게 동작합니다.

**정상 가입 (PERSONALITY_COMPLETED):**
- 히든 프로필 이미지를 등록하고 다음 단계로 진행합니다.

**재심사 (REJECT):**
- 구버전 앱(1.2.0 미만): 히든 이미지를 등록하고 PENDING 상태로 변경 (하위호환)
- 신규 앱(1.2.0 이상): 새로운 재심사 API(/v1/profile/review/resubmit)를 사용하도록 안내

**X-App-Version 헤더:**
- 앱 버전을 명시하지 않으면 구버전으로 간주되어 하위호환 로직이 적용됩니다.
"""
)
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "등록 완료 (PENDING 상태로 변경)"),
ApiResponse(responseCode = "400", description = "잘못된 이미지 파일 또는 단계 오류"),
ApiResponse(responseCode = "200", description = "등록 완료"),
ApiResponse(responseCode = "400", description = "잘못된 이미지 파일, 단계 오류, 또는 신규 앱에서 재심사 시도"),
ApiResponse(responseCode = "401", description = "인증 실패")
]
)
fun registerHiddenImages(
@Parameter(hidden = true) @LoginMember member: Member,
@Parameter(description = "얼굴 이미지 파일들 (3장)") images: List<MultipartFile>
): ResponseEntity<Unit>
@Parameter(description = "얼굴 이미지 파일들 (3장)") images: List<MultipartFile>,
@Parameter(description = "앱 버전 (예: 1.2.0)") appVersion: String?
): ResponseEntity<Any>

@Operation(
summary = "사용자 인증 이미지 제출",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package codel.member.business.signup

import codel.member.business.SignupService
import codel.member.domain.Member
import codel.member.domain.MemberStatus
import codel.member.domain.OauthType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito.*
import org.springframework.http.HttpStatus
import org.springframework.mock.web.MockMultipartFile

class PostVerificationStrategyTest {

private lateinit var signupService: SignupService
private lateinit var strategy: PostVerificationStrategy

@BeforeEach
fun setUp() {
signupService = mock(SignupService::class.java)
strategy = PostVerificationStrategy(signupService)
}

@DisplayName("히든 이미지를 등록한다")
@Test
fun handleHiddenImages_registerImages() {
// given
val member = Member(
id = 1L,
oauthId = "test-oauth-id",
oauthType = OauthType.KAKAO,
memberStatus = MemberStatus.PERSONALITY_COMPLETED,
email = "test@test.com"
)

val images = listOf(
MockMultipartFile("image1", "test1.jpg", "image/jpeg", "test1".toByteArray()),
MockMultipartFile("image2", "test2.jpg", "image/jpeg", "test2".toByteArray()),
MockMultipartFile("image3", "test3.jpg", "image/jpeg", "test3".toByteArray())
)

// when
val response = strategy.handleHiddenImages(member, images)

// then
verify(signupService, times(1)).registerHiddenImages(member, images)
assertEquals(HttpStatus.OK, response.statusCode)
}

@DisplayName("회원 상태를 변경하지 않는다")
@Test
fun handleHiddenImages_noStatusChange() {
// given
val initialStatus = MemberStatus.PERSONALITY_COMPLETED
val member = Member(
id = 1L,
oauthId = "test-oauth-id",
oauthType = OauthType.KAKAO,
memberStatus = initialStatus,
email = "test@test.com"
)

val images = listOf(
MockMultipartFile("image1", "test1.jpg", "image/jpeg", "test1".toByteArray())
)

// when
strategy.handleHiddenImages(member, images)

// then
assertEquals(initialStatus, member.memberStatus)
}

@DisplayName("다양한 회원 상태에서 모두 동일하게 동작한다")
@Test
fun handleHiddenImages_differentMemberStatuses() {
// given
val statuses = listOf(
MemberStatus.PERSONALITY_COMPLETED,
MemberStatus.REJECT,
MemberStatus.PENDING,
MemberStatus.DONE
)

val images = listOf(
MockMultipartFile("image1", "test1.jpg", "image/jpeg", "test1".toByteArray())
)

// when & then
statuses.forEach { status ->
val member = Member(
id = 1L,
oauthId = "test-oauth-id",
oauthType = OauthType.KAKAO,
memberStatus = status,
email = "test@test.com"
)

val response = strategy.handleHiddenImages(member, images)

verify(signupService, times(1)).registerHiddenImages(member, images)
assertEquals(status, member.memberStatus) // 상태 유지
assertEquals(HttpStatus.OK, response.statusCode)

reset(signupService)
}
}
}
Loading