Compare commits
4 Commits
c422bb3d6e
...
12f3a76c57
| Author | SHA1 | Date | |
|---|---|---|---|
| 12f3a76c57 | |||
| 70530f87fc | |||
| 94eb11ad5a | |||
| 6b274b9529 |
34
docs/20260305_관리자사용자차단기능추가.md
Normal file
34
docs/20260305_관리자사용자차단기능추가.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
- [x] 관리자 차단 신규 API/DTO/서비스 파일 생성
|
||||||
|
- [x] 차단 처리 시 탈퇴 이유 저장 및 회원 비활성화 처리
|
||||||
|
- [x] 차단 처리 시 Redis 로그인 토큰 전체 삭제
|
||||||
|
- [x] 본인인증 회원 BlockAuth 기록 처리
|
||||||
|
- [x] 동일 본인인증 정보 계정 일괄 탈퇴 처리
|
||||||
|
- [x] 활성 계정 조회 조건을 `name + birth + di + uniqueCi`로 강화
|
||||||
|
- [x] 관리자 차단 서비스 테스트 추가
|
||||||
|
- [x] 정적 진단 및 테스트/빌드 검증
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
### 1차 구현
|
||||||
|
- 무엇을: `kr.co.vividnext.sodalive.admin.member` 패키지에 신규 관리자 차단 API(`AdminMemberBlockController`), 요청 DTO(`AdminMemberBlockRequest`), 서비스(`AdminMemberBlockService`)를 추가했다. 서비스에서 탈퇴 이유 저장/회원 비활성화, Redis 로그인 토큰 전체 삭제, 본인인증 정보 `BlockAuth` 기록을 순서대로 처리하고, 서비스 단위 테스트(`AdminMemberBlockServiceTest`)를 추가했다.
|
||||||
|
- 왜: 관리자 페이지에서 사용자 차단 시 계정 비활성화 이력, 세션 무효화, 본인인증 기반 재가입 차단 정보를 한 번의 동작으로 일관되게 처리하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
||||||
|
- 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberBlockServiceTest` 실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
|
||||||
|
### 2차 수정
|
||||||
|
- 무엇을: 관리자 차단 시 차단 대상 회원의 본인인증 정보(`di`)와 동일한 활성 계정을 모두 조회해 일괄 탈퇴 처리하도록 `AdminMemberBlockService`를 수정했다. 각 대상 계정마다 탈퇴 사유(`SignOut`) 저장, 회원 비활성화, Redis 로그인 토큰 전체 삭제를 수행하고, 기존 `BlockAuth` 저장 로직은 유지했다. 테스트도 동일 본인인증 다계정 탈퇴 시나리오를 포함하도록 확장했다.
|
||||||
|
- 왜: 본인인증 정보를 공유하는 다중 계정을 관리자 차단 시 함께 정리해야 우회 가입 계정이 활성 상태로 남지 않기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
||||||
|
- 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.admin.member.AdminMemberBlockServiceTest` 실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
|
||||||
|
### 3차 수정
|
||||||
|
- 무엇을: 활성 계정 조회 조건을 `di` 단일 조건에서 `name + birth + di + uniqueCi` AND 조건으로 강화했다. 이를 위해 `AuthRepository`의 활성 계정 조회 메서드를 `getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(...)`로 변경하고, 호출부인 `AdminMemberBlockService`, `AuthService.authenticate`를 모두 신규 메서드로 교체했다. `AdminMemberBlockServiceTest`도 신규 시그니처 기준으로 스텁/검증을 수정했다.
|
||||||
|
- 왜: `di`만으로 동일인을 판단하면 과매칭 리스크가 있어, 본인인증 핵심 식별 속성을 함께 사용해 활성 계정 판별 정확도를 높이기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- 정적 진단: `lsp_diagnostics`로 수정한 Kotlin 파일 진단을 시도했으나, 실행 환경에 Kotlin LSP가 설정되어 있지 않아 수행 불가(도구 에러 확인).
|
||||||
|
- 테스트: `./gradlew test` 실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- 빌드: `./gradlew build -x test` 실행, `BUILD SUCCESSFUL` 확인.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.member
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
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.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/member/block")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminMemberBlockController(private val service: AdminMemberBlockService) {
|
||||||
|
@PostMapping
|
||||||
|
fun blockMember(@RequestBody request: AdminMemberBlockRequest) = ApiResponse.ok(
|
||||||
|
service.blockMember(request),
|
||||||
|
"차단되었습니다."
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.member
|
||||||
|
|
||||||
|
data class AdminMemberBlockRequest(
|
||||||
|
val memberId: Long,
|
||||||
|
val reason: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.member
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberService
|
||||||
|
import kr.co.vividnext.sodalive.member.SignOut
|
||||||
|
import kr.co.vividnext.sodalive.member.SignOutRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.BlockAuth
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.BlockAuthRepository
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminMemberBlockService(
|
||||||
|
private val adminMemberRepository: AdminMemberRepository,
|
||||||
|
private val signOutRepository: SignOutRepository,
|
||||||
|
private val memberService: MemberService,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val blockAuthRepository: BlockAuthRepository
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
fun blockMember(request: AdminMemberBlockRequest) {
|
||||||
|
if (request.reason.isBlank()) {
|
||||||
|
throw SodaException(messageKey = "member.validation.signout_reason_required")
|
||||||
|
}
|
||||||
|
|
||||||
|
val member = adminMemberRepository.findByIdAndActive(memberId = request.memberId)
|
||||||
|
?: throw SodaException(messageKey = "admin.member.not_found")
|
||||||
|
|
||||||
|
val auth = member.auth
|
||||||
|
val memberIdsToBlock = if (auth != null) {
|
||||||
|
authRepository.getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
|
||||||
|
name = auth.name,
|
||||||
|
birth = auth.birth,
|
||||||
|
di = auth.di,
|
||||||
|
uniqueCi = auth.uniqueCi
|
||||||
|
)
|
||||||
|
.ifEmpty { listOf(member.id!!) }
|
||||||
|
} else {
|
||||||
|
listOf(member.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIdsToBlock
|
||||||
|
.distinct()
|
||||||
|
.forEach { memberId ->
|
||||||
|
val targetMember = adminMemberRepository.findByIdAndActive(memberId = memberId)
|
||||||
|
?: return@forEach
|
||||||
|
|
||||||
|
targetMember.isActive = false
|
||||||
|
targetMember.nickname = "deleted_${targetMember.nickname}"
|
||||||
|
|
||||||
|
val signOut = SignOut(reason = request.reason)
|
||||||
|
signOut.member = targetMember
|
||||||
|
signOutRepository.save(signOut)
|
||||||
|
|
||||||
|
memberService.logoutAll(memberId = targetMember.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth == null) return
|
||||||
|
val alreadyBlockedAuthId = blockAuthRepository.findByUniqueCiAndDi(auth.uniqueCi, auth.di)
|
||||||
|
if (alreadyBlockedAuthId == null || alreadyBlockedAuthId <= 0) {
|
||||||
|
blockAuthRepository.save(
|
||||||
|
BlockAuth(
|
||||||
|
name = auth.name,
|
||||||
|
birth = auth.birth,
|
||||||
|
uniqueCi = auth.uniqueCi,
|
||||||
|
di = auth.di,
|
||||||
|
gender = auth.gender
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ interface AuthQueryRepository {
|
|||||||
fun getMemberIdsByDi(di: String): List<Long>
|
fun getMemberIdsByDi(di: String): List<Long>
|
||||||
fun getMemberIdsByNameAndBirthAndDiAndGender(name: String, birth: String, di: String, gender: Int): List<Long>
|
fun getMemberIdsByNameAndBirthAndDiAndGender(name: String, birth: String, di: String, gender: Int): List<Long>
|
||||||
fun getAuthIdByMemberId(memberId: Long): Long?
|
fun getAuthIdByMemberId(memberId: Long): Long?
|
||||||
fun getActiveMemberIdsByDi(di: String): List<Long>
|
fun getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(name: String, birth: String, di: String, uniqueCi: String): List<Long>
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AuthQueryRepository {
|
class AuthQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AuthQueryRepository {
|
||||||
@@ -60,13 +60,21 @@ class AuthQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AuthQ
|
|||||||
.fetchFirst()
|
.fetchFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getActiveMemberIdsByDi(di: String): List<Long> {
|
override fun getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
|
||||||
|
name: String,
|
||||||
|
birth: String,
|
||||||
|
di: String,
|
||||||
|
uniqueCi: String
|
||||||
|
): List<Long> {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(member.id)
|
.select(member.id)
|
||||||
.from(member)
|
.from(member)
|
||||||
.leftJoin(member.auth, auth)
|
.leftJoin(member.auth, auth)
|
||||||
.where(
|
.where(
|
||||||
auth.di.eq(di)
|
auth.name.eq(name)
|
||||||
|
.and(auth.birth.eq(birth))
|
||||||
|
.and(auth.di.eq(di))
|
||||||
|
.and(auth.uniqueCi.eq(uniqueCi))
|
||||||
.and(member.isActive.isTrue)
|
.and(member.isActive.isTrue)
|
||||||
)
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
|
|||||||
@@ -81,7 +81,12 @@ class AuthService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun authenticate(certificate: AuthVerifyCertificate, memberId: Long): AuthResponse {
|
fun authenticate(certificate: AuthVerifyCertificate, memberId: Long): AuthResponse {
|
||||||
val memberIds = repository.getActiveMemberIdsByDi(di = certificate.di)
|
val memberIds = repository.getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
|
||||||
|
name = certificate.name,
|
||||||
|
birth = certificate.birth,
|
||||||
|
di = certificate.di,
|
||||||
|
uniqueCi = certificate.unique
|
||||||
|
)
|
||||||
if (memberIds.size >= 3) {
|
if (memberIds.size >= 3) {
|
||||||
val message = messageSource.getMessage("member.auth.max_accounts", langContext.lang) ?: ""
|
val message = messageSource.getMessage("member.auth.max_accounts", langContext.lang) ?: ""
|
||||||
throw SodaException(
|
throw SodaException(
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.member
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberService
|
||||||
|
import kr.co.vividnext.sodalive.member.SignOut
|
||||||
|
import kr.co.vividnext.sodalive.member.SignOutRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.BlockAuth
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.BlockAuthRepository
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.ArgumentCaptor
|
||||||
|
import org.mockito.Mockito
|
||||||
|
|
||||||
|
class AdminMemberBlockServiceTest {
|
||||||
|
private lateinit var adminMemberRepository: AdminMemberRepository
|
||||||
|
private lateinit var signOutRepository: SignOutRepository
|
||||||
|
private lateinit var memberService: MemberService
|
||||||
|
private lateinit var authRepository: AuthRepository
|
||||||
|
private lateinit var blockAuthRepository: BlockAuthRepository
|
||||||
|
private lateinit var service: AdminMemberBlockService
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
adminMemberRepository = Mockito.mock(AdminMemberRepository::class.java)
|
||||||
|
signOutRepository = Mockito.mock(SignOutRepository::class.java)
|
||||||
|
memberService = Mockito.mock(MemberService::class.java)
|
||||||
|
authRepository = Mockito.mock(AuthRepository::class.java)
|
||||||
|
blockAuthRepository = Mockito.mock(BlockAuthRepository::class.java)
|
||||||
|
|
||||||
|
service = AdminMemberBlockService(
|
||||||
|
adminMemberRepository = adminMemberRepository,
|
||||||
|
signOutRepository = signOutRepository,
|
||||||
|
memberService = memberService,
|
||||||
|
authRepository = authRepository,
|
||||||
|
blockAuthRepository = blockAuthRepository
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldBlockAllMembersWithSameAuthAndStoreBlockAuth() {
|
||||||
|
val member = Member(password = "password", nickname = "tester")
|
||||||
|
member.id = 101L
|
||||||
|
val linkedMember = Member(password = "password", nickname = "linked")
|
||||||
|
linkedMember.id = 202L
|
||||||
|
|
||||||
|
val auth = Auth(
|
||||||
|
name = "홍길동",
|
||||||
|
birth = "19900101",
|
||||||
|
uniqueCi = "unique-ci",
|
||||||
|
di = "di-value",
|
||||||
|
gender = 1
|
||||||
|
)
|
||||||
|
auth.member = member
|
||||||
|
|
||||||
|
val request = AdminMemberBlockRequest(memberId = 101L, reason = "운영정책 위반")
|
||||||
|
|
||||||
|
Mockito.`when`(adminMemberRepository.findByIdAndActive(memberId = request.memberId)).thenReturn(member)
|
||||||
|
Mockito.`when`(
|
||||||
|
authRepository.getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
|
||||||
|
name = auth.name,
|
||||||
|
birth = auth.birth,
|
||||||
|
di = auth.di,
|
||||||
|
uniqueCi = auth.uniqueCi
|
||||||
|
)
|
||||||
|
).thenReturn(listOf(101L, 202L))
|
||||||
|
Mockito.`when`(adminMemberRepository.findByIdAndActive(memberId = 202L)).thenReturn(linkedMember)
|
||||||
|
Mockito.`when`(blockAuthRepository.findByUniqueCiAndDi(auth.uniqueCi, auth.di)).thenReturn(null)
|
||||||
|
|
||||||
|
service.blockMember(request)
|
||||||
|
|
||||||
|
assertFalse(member.isActive)
|
||||||
|
assertEquals("deleted_tester", member.nickname)
|
||||||
|
assertFalse(linkedMember.isActive)
|
||||||
|
assertEquals("deleted_linked", linkedMember.nickname)
|
||||||
|
|
||||||
|
val signOutCaptor = ArgumentCaptor.forClass(SignOut::class.java)
|
||||||
|
Mockito.verify(signOutRepository, Mockito.times(2)).save(signOutCaptor.capture())
|
||||||
|
assertEquals(2, signOutCaptor.allValues.size)
|
||||||
|
assertTrue(signOutCaptor.allValues.all { it.reason == "운영정책 위반" })
|
||||||
|
assertEquals(setOf(101L, 202L), signOutCaptor.allValues.mapNotNull { it.member?.id }.toSet())
|
||||||
|
|
||||||
|
Mockito.verify(memberService).logoutAll(memberId = 101L)
|
||||||
|
Mockito.verify(memberService).logoutAll(memberId = 202L)
|
||||||
|
Mockito.verify(authRepository).getActiveMemberIdsByNameAndBirthAndDiAndUniqueCi(
|
||||||
|
name = auth.name,
|
||||||
|
birth = auth.birth,
|
||||||
|
di = auth.di,
|
||||||
|
uniqueCi = auth.uniqueCi
|
||||||
|
)
|
||||||
|
Mockito.verify(blockAuthRepository).findByUniqueCiAndDi(auth.uniqueCi, auth.di)
|
||||||
|
|
||||||
|
val blockAuthCaptor = ArgumentCaptor.forClass(BlockAuth::class.java)
|
||||||
|
Mockito.verify(blockAuthRepository).save(blockAuthCaptor.capture())
|
||||||
|
assertEquals(auth.name, blockAuthCaptor.value.name)
|
||||||
|
assertEquals(auth.birth, blockAuthCaptor.value.birth)
|
||||||
|
assertEquals(auth.uniqueCi, blockAuthCaptor.value.uniqueCi)
|
||||||
|
assertEquals(auth.di, blockAuthCaptor.value.di)
|
||||||
|
assertEquals(auth.gender, blockAuthCaptor.value.gender)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldBlockMemberWithoutBlockAuthWhenMemberHasNoVerification() {
|
||||||
|
val member = Member(password = "password", nickname = "tester")
|
||||||
|
member.id = 202L
|
||||||
|
|
||||||
|
val request = AdminMemberBlockRequest(memberId = 202L, reason = "반복 신고")
|
||||||
|
|
||||||
|
Mockito.`when`(adminMemberRepository.findByIdAndActive(memberId = request.memberId)).thenReturn(member)
|
||||||
|
|
||||||
|
service.blockMember(request)
|
||||||
|
|
||||||
|
assertFalse(member.isActive)
|
||||||
|
assertEquals("deleted_tester", member.nickname)
|
||||||
|
Mockito.verify(signOutRepository).save(Mockito.any(SignOut::class.java))
|
||||||
|
Mockito.verify(memberService).logoutAll(memberId = 202L)
|
||||||
|
Mockito.verifyNoInteractions(authRepository)
|
||||||
|
Mockito.verifyNoInteractions(blockAuthRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldThrowWhenReasonIsBlank() {
|
||||||
|
val request = AdminMemberBlockRequest(memberId = 1L, reason = " ")
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.blockMember(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("member.validation.signout_reason_required", exception.messageKey)
|
||||||
|
Mockito.verifyNoInteractions(adminMemberRepository)
|
||||||
|
Mockito.verifyNoInteractions(signOutRepository)
|
||||||
|
Mockito.verifyNoInteractions(memberService)
|
||||||
|
Mockito.verifyNoInteractions(authRepository)
|
||||||
|
Mockito.verifyNoInteractions(blockAuthRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldThrowWhenMemberNotFound() {
|
||||||
|
val request = AdminMemberBlockRequest(memberId = 303L, reason = "운영자 차단")
|
||||||
|
Mockito.`when`(adminMemberRepository.findByIdAndActive(memberId = request.memberId)).thenReturn(null)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.blockMember(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("admin.member.not_found", exception.messageKey)
|
||||||
|
Mockito.verify(adminMemberRepository).findByIdAndActive(memberId = 303L)
|
||||||
|
Mockito.verifyNoInteractions(signOutRepository)
|
||||||
|
Mockito.verifyNoInteractions(memberService)
|
||||||
|
Mockito.verifyNoInteractions(authRepository)
|
||||||
|
Mockito.verifyNoInteractions(blockAuthRepository)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user