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
|
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 kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
@@ -28,6 +29,14 @@ class AdminAgentReadController(
|
|||||||
service.searchAssignableCreators(searchWord = searchWord, offset = pageable.offset, limit = pageable.pageSize.toLong())
|
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")
|
@GetMapping("/{agentId}/creator/list")
|
||||||
fun getAssignedCreators(
|
fun getAssignedCreators(
|
||||||
@PathVariable agentId: Long,
|
@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.BooleanExpression
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
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.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.QMember
|
import kr.co.vividnext.sodalive.member.QMember
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
@@ -136,6 +138,25 @@ class AdminAgentReadQueryRepository(
|
|||||||
.fetch()
|
.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 {
|
fun getAssignedCreatorTotalCount(agentId: Long, currentTime: LocalDateTime): Int {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(agentCreatorRelation.id.count())
|
.select(agentCreatorRelation.id.count())
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.partner.agent.read
|
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.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
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)
|
@Transactional(readOnly = true)
|
||||||
fun getAssignedCreators(agentId: Long, offset: Long, limit: Long): GetAdminAgentAssignedCreatorResponse {
|
fun getAssignedCreators(agentId: Long, offset: Long, limit: Long): GetAdminAgentAssignedCreatorResponse {
|
||||||
validateAgent(agentId)
|
validateAgent(agentId)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.partner.agent.read
|
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.common.CountryContext
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
@@ -95,6 +96,32 @@ class AdminAgentReadControllerSecurityTest @Autowired constructor(
|
|||||||
println(result.response.contentAsString)
|
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
|
@Test
|
||||||
@DisplayName("익명 사용자는 관리자 조회 API에 접근할 수 없다")
|
@DisplayName("익명 사용자는 관리자 조회 API에 접근할 수 없다")
|
||||||
fun shouldRejectAnonymousUser() {
|
fun shouldRejectAnonymousUser() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.partner.agent.read
|
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.GetAgentChannelDonationSettlementByCreatorItem
|
||||||
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorResponse
|
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorResponse
|
||||||
import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementTotal
|
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)
|
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
|
@Test
|
||||||
@DisplayName("관리자 컨트롤러는 소속 크리에이터 목록 조회 파라미터를 서비스로 전달한다")
|
@DisplayName("관리자 컨트롤러는 소속 크리에이터 목록 조회 파라미터를 서비스로 전달한다")
|
||||||
fun shouldForwardAssignedCreatorListRequest() {
|
fun shouldForwardAssignedCreatorListRequest() {
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ class AdminAgentReadQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(listOf(null, "agent-search"), items.map { it.currentAgentNickname })
|
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
|
@Test
|
||||||
@DisplayName("특정 에이전트 소속 크리에이터 목록은 assignedAt을 포함해 현재 활성 구간만 반환한다")
|
@DisplayName("특정 에이전트 소속 크리에이터 목록은 assignedAt을 포함해 현재 활성 구간만 반환한다")
|
||||||
fun shouldGetAssignedCreatorsForAdminDetail() {
|
fun shouldGetAssignedCreatorsForAdminDetail() {
|
||||||
@@ -101,15 +115,15 @@ class AdminAgentReadQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(now.minusDays(3), items.first().assignedAt)
|
assertEquals(now.minusDays(3), items.first().assignedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveMember(nickname: String, role: MemberRole): Member {
|
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||||
return memberRepository.saveAndFlush(
|
val member = Member(
|
||||||
Member(
|
|
||||||
email = "$nickname@test.com",
|
email = "$nickname@test.com",
|
||||||
password = "password",
|
password = "password",
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
role = role
|
role = role
|
||||||
)
|
)
|
||||||
)
|
member.isActive = isActive
|
||||||
|
return memberRepository.saveAndFlush(member)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveRelation(agent: Member, creator: Member, assignedAt: LocalDateTime, unassignedAt: LocalDateTime?) {
|
private fun saveRelation(agent: Member, creator: Member, assignedAt: LocalDateTime, unassignedAt: LocalDateTime?) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.partner.agent.read
|
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.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
@@ -41,6 +42,33 @@ class AdminAgentReadServiceTest {
|
|||||||
assertEquals("admin.member.search_word_min_length", exception.messageKey)
|
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
|
@Test
|
||||||
@DisplayName("에이전트 목록 조회는 현재 월 summary 필드를 그대로 반환한다")
|
@DisplayName("에이전트 목록 조회는 현재 월 summary 필드를 그대로 반환한다")
|
||||||
fun shouldReturnAgentListWithCurrentMonthSummaryFields() {
|
fun shouldReturnAgentListWithCurrentMonthSummaryFields() {
|
||||||
|
|||||||
Reference in New Issue
Block a user