Compare commits

..

3 Commits

8 changed files with 158 additions and 8 deletions

View File

@@ -0,0 +1,24 @@
# 에이전트 검색 기능 추가
## 작업 체크리스트
- [x] 관리자 에이전트 검색 관련 기존 controller/service/query/test 패턴을 확인한다.
- [x] 에이전트 검색 API 위치를 확정하고 요청/응답 형태를 정의한다.
- [x] 에이전트 닉네임 검색 동작을 검증하는 테스트를 먼저 추가한다.
- [x] 에이전트 닉네임으로 검색해 `memberId`를 식별할 수 있는 조회 기능을 구현한다.
- [x] 관련 진단/테스트/수동 QA를 수행하고 결과를 기록한다.
## 검증 기준
- 관리자 권한 진입점에서 검색 API가 노출되어야 한다.
- 검색어로 에이전트 닉네임을 조회했을 때 UI가 사용할 식별자(`memberId`)가 응답에 포함되어야 한다.
- 기존 관리자 에이전트 read 패키지의 응답/검색 패턴을 따른다.
## 검증 기록
- 1차 구현
- 무엇을: `admin.partner.agent.read` 패키지에 에이전트 닉네임 검색 API를 추가하고 관련 controller/service/query/test를 확장했다.
- 왜: 관리자 정산 비율 입력 시 memberId 직접 입력 대신 에이전트 검색 기반 선택을 지원하기 위해
- 어떻게:
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest"` → 성공
- `./gradlew build` → 성공
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest.shouldAllowAdminRoleForAgentNicknameSearch" --info` → 성공
- 수동 QA 확인 응답: `{"success":true,"message":null,"data":[{"id":11,"nickname":"agent-a"}],"errorProperty":null}`
- 참고: Kotlin LSP가 환경에 구성되어 있지 않아 LSP 진단 대신 Gradle compile/test/build 결과로 검증했다.

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize
@@ -28,6 +29,14 @@ class AdminAgentReadController(
service.searchAssignableCreators(searchWord = searchWord, offset = pageable.offset, limit = pageable.pageSize.toLong())
)
@GetMapping("/search-by-nickname")
fun searchAgentByNickname(
@RequestParam(value = "search_word") searchWord: String,
@RequestParam(value = "size", required = false) size: Int?
): ApiResponse<List<AdminSimpleMemberResponse>> = ApiResponse.ok(
service.searchAgentByNickname(searchWord = searchWord, size = size ?: 20)
)
@GetMapping("/{agentId}/creator/list")
fun getAssignedCreators(
@PathVariable agentId: Long,

View File

@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.admin.partner.agent.read
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.admin.member.QAdminSimpleMemberResponse
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.QMember.member
@@ -136,6 +138,25 @@ class AdminAgentReadQueryRepository(
.fetch()
}
fun searchAgentByNickname(searchWord: String, limit: Long): List<AdminSimpleMemberResponse> {
return queryFactory
.select(
QAdminSimpleMemberResponse(
member.id,
member.nickname
)
)
.from(member)
.where(
member.role.eq(MemberRole.AGENT)
.and(member.nickname.contains(searchWord))
.and(member.isActive.isTrue)
)
.orderBy(member.id.desc())
.limit(limit)
.fetch()
}
fun getAssignedCreatorTotalCount(agentId: Long, currentTime: LocalDateTime): Int {
return queryFactory
.select(agentCreatorRelation.id.count())

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
@@ -44,6 +45,13 @@ class AdminAgentReadService(
)
}
@Transactional(readOnly = true)
fun searchAgentByNickname(searchWord: String, size: Int = 20): List<AdminSimpleMemberResponse> {
if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length")
val limit = if (size <= 0) 20 else size
return queryRepository.searchAgentByNickname(searchWord = searchWord, limit = limit.toLong())
}
@Transactional(readOnly = true)
fun getAssignedCreators(agentId: Long, offset: Long, limit: Long): GetAdminAgentAssignedCreatorResponse {
validateAgent(agentId)

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
@@ -95,6 +96,32 @@ class AdminAgentReadControllerSecurityTest @Autowired constructor(
println(result.response.contentAsString)
}
@Test
@DisplayName("관리자 권한이면 에이전트 닉네임 검색에 성공한다")
fun shouldAllowAdminRoleForAgentNicknameSearch() {
Mockito.`when`(service.searchAgentByNickname(searchWord = "agent", size = 10)).thenReturn(
listOf(
AdminSimpleMemberResponse(
id = 11L,
nickname = "agent-a"
)
)
)
val result = mockMvc.perform(
get("/admin/partner/agent/search-by-nickname")
.param("search_word", "agent")
.param("size", "10")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data[0].id").value(11L))
.andReturn()
println(result.response.contentAsString)
}
@Test
@DisplayName("익명 사용자는 관리자 조회 API에 접근할 수 없다")
fun shouldRejectAnonymousUser() {

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorItem
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorResponse
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementTotal
@@ -76,6 +77,24 @@ class AdminAgentReadControllerTest {
Mockito.verify(service).searchAssignableCreators(searchWord = "creator", offset = 0L, limit = 10L)
}
@Test
@DisplayName("관리자 컨트롤러는 에이전트 닉네임 검색 파라미터를 서비스로 전달한다")
fun shouldForwardAgentNicknameSearchRequest() {
val body = listOf(
AdminSimpleMemberResponse(
id = 11L,
nickname = "agent-a"
)
)
Mockito.`when`(service.searchAgentByNickname(searchWord = "agent", size = 10)).thenReturn(body)
val response = controller.searchAgentByNickname(searchWord = "agent", size = 10)
assertEquals(true, response.success)
assertEquals(11L, response.data!!.first().id)
Mockito.verify(service).searchAgentByNickname(searchWord = "agent", size = 10)
}
@Test
@DisplayName("관리자 컨트롤러는 소속 크리에이터 목록 조회 파라미터를 서비스로 전달한다")
fun shouldForwardAssignedCreatorListRequest() {

View File

@@ -81,6 +81,20 @@ class AdminAgentReadQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(null, "agent-search"), items.map { it.currentAgentNickname })
}
@Test
@DisplayName("에이전트 닉네임 검색은 활성 AGENT만 id와 nickname으로 반환한다")
fun shouldSearchActiveAgentsByNickname() {
val matchedAgent = saveMember("agent-alpha", MemberRole.AGENT)
saveMember("agent-beta", MemberRole.AGENT, isActive = false)
saveMember("creator-agent", MemberRole.CREATOR)
val items = repository.searchAgentByNickname(searchWord = "agent", limit = 20)
assertEquals(1, items.size)
assertEquals(matchedAgent.id, items.first().id)
assertEquals("agent-alpha", items.first().nickname)
}
@Test
@DisplayName("특정 에이전트 소속 크리에이터 목록은 assignedAt을 포함해 현재 활성 구간만 반환한다")
fun shouldGetAssignedCreatorsForAdminDetail() {
@@ -101,15 +115,15 @@ class AdminAgentReadQueryRepositoryTest @Autowired constructor(
assertEquals(now.minusDays(3), items.first().assignedAt)
}
private fun saveMember(nickname: String, role: MemberRole): Member {
return memberRepository.saveAndFlush(
Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
member.isActive = isActive
return memberRepository.saveAndFlush(member)
}
private fun saveRelation(agent: Member, creator: Member, assignedAt: LocalDateTime, unassignedAt: LocalDateTime?) {

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.admin.partner.agent.read
import kr.co.vividnext.sodalive.admin.member.AdminSimpleMemberResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
@@ -41,6 +42,33 @@ class AdminAgentReadServiceTest {
assertEquals("admin.member.search_word_min_length", exception.messageKey)
}
@Test
@DisplayName("에이전트 닉네임 검색은 두 글자 미만 검색어를 거부한다")
fun shouldRejectTooShortAgentSearchWord() {
val exception = assertThrows(SodaException::class.java) {
service.searchAgentByNickname(searchWord = "a", size = 20)
}
assertEquals("admin.member.search_word_min_length", exception.messageKey)
}
@Test
@DisplayName("에이전트 닉네임 검색은 size가 0 이하이면 20으로 보정한다")
fun shouldDefaultAgentSearchSizeToTwenty() {
val expected = listOf(
AdminSimpleMemberResponse(
id = 11L,
nickname = "agent-a"
)
)
Mockito.`when`(queryRepository.searchAgentByNickname(searchWord = "agent", limit = 20L)).thenReturn(expected)
val actual = service.searchAgentByNickname(searchWord = "agent", size = 0)
assertEquals(expected, actual)
Mockito.verify(queryRepository).searchAgentByNickname(searchWord = "agent", limit = 20L)
}
@Test
@DisplayName("에이전트 목록 조회는 현재 월 summary 필드를 그대로 반환한다")
fun shouldReturnAgentListWithCurrentMonthSummaryFields() {