Compare commits
3 Commits
59a4b06d86
...
88ffaf6d04
| Author | SHA1 | Date | |
|---|---|---|---|
| 88ffaf6d04 | |||
| 765c087af3 | |||
| 535f5d16cc |
24
docs/20260411_에이전트검색기능추가.md
Normal file
24
docs/20260411_에이전트검색기능추가.md
Normal 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 결과로 검증했다.
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
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?) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user