# Admin Agent Settlement Read API Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 관리자 페이지에서 에이전트 목록을 조회하고, 특정 에이전트 상세 화면에서 소속 크리에이터와 5종 정산 현황을 조회할 수 있는 ADMIN 전용 read API를 추가한다. **Architecture:** 관리자 진입점은 `admin.partner.agent.read` 패키지에 새로 만들고, 에이전트 목록/크리에이터 검색/소속 크리에이터 목록은 전용 Querydsl read repository에서 조회한다. 정산 상세 5종은 기존 `partner.agent.calculate.AgentCalculateService`가 이미 제공하는 `agentId` 기반 public 메서드를 그대로 재사용해 ADMIN/AGENT 계산 규칙이 같도록 유지한다. **Tech Stack:** Kotlin, Spring Boot 2.7, Spring Security `@PreAuthorize`, Querydsl JPA, JUnit 5, Mockito, Gradle --- ## File Structure **Create:** - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadController.kt` - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadService.kt` - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepository.kt` - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentListResponse.kt` - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/SearchAdminAgentAssignableCreatorResponse.kt` - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentAssignedCreatorResponse.kt` - `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepositoryTest.kt` - `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadServiceTest.kt` - `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerTest.kt` - `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerSecurityTest.kt` - `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadParityTest.kt` **Modify:** - `docs/20260410_관리자에이전트정산상세조회구현계획.md` --- ## Design Gap Checklist - [x] 컨트롤러 보안 테스트에 `ADMIN` 허용, 익명 `401`, `AGENT`/일반 사용자 `403`을 검증하는 시나리오를 추가한다. - [x] 정산 5종에 대해 동일 기간·동일 `agentId` 기준 AGENT/ADMIN 응답의 `totalCount`, `total`, `items` parity를 비교하는 회귀 테스트를 추가한다. - [x] 검증 기록 단계에 `무엇을/왜/어떻게`, 실제 실행 명령/결과, 누적 기록 및 `정정` 원칙을 명시한다. --- ### Task 1: Admin read query models and Querydsl repository **Files:** - Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentListResponse.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/SearchAdminAgentAssignableCreatorResponse.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentAssignedCreatorResponse.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepositoryTest.kt` - [x] **Step 1: Write the failing Querydsl repository tests** ```kotlin package kr.co.vividnext.sodalive.admin.partner.agent.read import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository 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.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import import java.time.LocalDateTime @DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) @Import(QueryDslConfig::class) class AdminAgentReadQueryRepositoryTest @Autowired constructor( private val queryFactory: JPAQueryFactory, private val memberRepository: MemberRepository, private val relationRepository: AgentCreatorRelationRepository ) { private lateinit var repository: AdminAgentReadQueryRepository @BeforeEach fun setup() { repository = AdminAgentReadQueryRepository(queryFactory) } @Test @DisplayName("에이전트 목록 조회는 활성 소속 크리에이터 수를 함께 반환한다") fun shouldGetAgentListWithAssignedCreatorCount() { val agentA = saveMember("agent-a", MemberRole.AGENT) val agentB = saveMember("agent-b", MemberRole.AGENT) val creatorA = saveMember("creator-a", MemberRole.CREATOR) val creatorB = saveMember("creator-b", MemberRole.CREATOR) val now = LocalDateTime.of(2026, 4, 10, 12, 0) saveRelation(agentA, creatorA, assignedAt = now.minusDays(1), unassignedAt = null) saveRelation(agentA, creatorB, assignedAt = now.minusDays(2), unassignedAt = now.plusDays(1)) val totalCount = repository.getAgentListTotalCount() val items = repository.getAgentList(offset = 0, limit = 20, currentTime = now) assertEquals(2, totalCount) assertEquals(listOf(agentB.id, agentA.id), items.map { it.agentId }) assertEquals(listOf(0, 2), items.map { it.assignedCreatorCount }) } @Test @DisplayName("크리에이터 검색은 현재 활성 에이전트 소속 정보를 함께 반환한다") fun shouldSearchAssignableCreatorsWithCurrentAgentInfo() { val agent = saveMember("agent-search", MemberRole.AGENT) val creatorAssigned = saveMember("creator-alpha", MemberRole.CREATOR) val creatorFree = saveMember("creator-beta", MemberRole.CREATOR) val now = LocalDateTime.of(2026, 4, 10, 12, 0) saveRelation(agent, creatorAssigned, assignedAt = now.minusDays(1), unassignedAt = null) val totalCount = repository.searchAssignableCreatorsTotalCount(searchWord = "creator", currentTime = now) val items = repository.searchAssignableCreators(searchWord = "creator", offset = 0, limit = 20, currentTime = now) assertEquals(2, totalCount) assertEquals(listOf(creatorFree.id, creatorAssigned.id), items.map { it.creatorId }) assertEquals(listOf(null, agent.id), items.map { it.currentAgentId }) assertEquals(listOf(null, "agent-search"), items.map { it.currentAgentNickname }) } @Test @DisplayName("특정 에이전트 소속 크리에이터 목록은 assignedAt을 포함해 현재 활성 구간만 반환한다") fun shouldGetAssignedCreatorsForAdminDetail() { val agent = saveMember("agent-detail", MemberRole.AGENT) val activeCreator = saveMember("creator-active", MemberRole.CREATOR) val futureCreator = saveMember("creator-future", MemberRole.CREATOR) val now = LocalDateTime.of(2026, 4, 10, 12, 0) saveRelation(agent, activeCreator, assignedAt = now.minusDays(3), unassignedAt = null) saveRelation(agent, futureCreator, assignedAt = now.plusDays(1), unassignedAt = null) val totalCount = repository.getAssignedCreatorTotalCount(agentId = agent.id!!, currentTime = now) val items = repository.getAssignedCreators(agentId = agent.id!!, offset = 0, limit = 20, currentTime = now) assertEquals(1, totalCount) assertEquals(1, items.size) assertEquals(activeCreator.id, items.first().creatorId) 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 saveRelation(agent: Member, creator: Member, assignedAt: LocalDateTime, unassignedAt: LocalDateTime?) { val relation = AgentCreatorRelation() relation.agent = agent relation.creator = creator relation.assignedAt = assignedAt relation.unassignedAt = unassignedAt relationRepository.saveAndFlush(relation) } } ``` - [x] **Step 2: Run the query repository test to verify it fails** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadQueryRepositoryTest` Expected: FAIL with unresolved references for `AdminAgentReadQueryRepository`, `GetAdminAgentListItem`, `SearchAdminAgentAssignableCreatorItem`, and `GetAdminAgentAssignedCreatorItem`. - [x] **Step 3: Write the DTOs and Querydsl repository** ```kotlin package kr.co.vividnext.sodalive.admin.partner.agent.read import com.querydsl.core.annotations.QueryProjection import com.querydsl.core.types.dsl.BooleanExpression import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.partner.agent.assignment.QAgentCreatorRelation.agentCreatorRelation import org.springframework.stereotype.Repository import java.time.LocalDateTime data class GetAdminAgentListResponse( val totalCount: Int, val items: List ) data class GetAdminAgentListItem @QueryProjection constructor( val agentId: Long, val agentNickname: String, val assignedCreatorCount: Int ) data class SearchAdminAgentAssignableCreatorResponse( val totalCount: Int, val items: List ) data class SearchAdminAgentAssignableCreatorItem @QueryProjection constructor( val creatorId: Long, val creatorNickname: String, val currentAgentId: Long?, val currentAgentNickname: String? ) data class GetAdminAgentAssignedCreatorResponse( val totalCount: Int, val items: List ) data class GetAdminAgentAssignedCreatorItem @QueryProjection constructor( val creatorId: Long, val creatorNickname: String, val assignedAt: LocalDateTime ) @Repository class AdminAgentReadQueryRepository( private val queryFactory: JPAQueryFactory ) { fun getAgentListTotalCount(): Int { return queryFactory .select(member.id.count()) .from(member) .where(member.role.eq(MemberRole.AGENT)) .fetchOne() ?.toInt() ?: 0 } fun getAgentList(offset: Long, limit: Long, currentTime: LocalDateTime): List { return queryFactory .select( QGetAdminAgentListItem( member.id, member.nickname, agentCreatorRelation.id.count().intValue() ) ) .from(member) .leftJoin(agentCreatorRelation) .on( agentCreatorRelation.agent.id.eq(member.id) .and(isActiveAssignment(currentTime)) ) .where(member.role.eq(MemberRole.AGENT)) .groupBy(member.id, member.nickname) .orderBy(member.id.desc()) .offset(offset) .limit(limit) .fetch() } fun searchAssignableCreatorsTotalCount(searchWord: String, currentTime: LocalDateTime): Int { return queryFactory .select(member.id.countDistinct()) .from(member) .leftJoin(agentCreatorRelation) .on( agentCreatorRelation.creator.id.eq(member.id) .and(isActiveAssignment(currentTime)) ) .where( member.role.eq(MemberRole.CREATOR) .and(member.nickname.contains(searchWord)) ) .fetchOne() ?.toInt() ?: 0 } fun searchAssignableCreators( searchWord: String, offset: Long, limit: Long, currentTime: LocalDateTime ): List { val currentAgent = kr.co.vividnext.sodalive.member.QMember("currentAgent") return queryFactory .select( QSearchAdminAgentAssignableCreatorItem( member.id, member.nickname, currentAgent.id, currentAgent.nickname ) ) .from(member) .leftJoin(agentCreatorRelation) .on( agentCreatorRelation.creator.id.eq(member.id) .and(isActiveAssignment(currentTime)) ) .leftJoin(agentCreatorRelation.agent, currentAgent) .where( member.role.eq(MemberRole.CREATOR) .and(member.nickname.contains(searchWord)) ) .orderBy(member.id.desc()) .offset(offset) .limit(limit) .fetch() } fun getAssignedCreatorTotalCount(agentId: Long, currentTime: LocalDateTime): Int { return queryFactory .select(agentCreatorRelation.id.count()) .from(agentCreatorRelation) .where(agentCreatorRelation.agent.id.eq(agentId).and(isActiveAssignment(currentTime))) .fetchOne() ?.toInt() ?: 0 } fun getAssignedCreators( agentId: Long, offset: Long, limit: Long, currentTime: LocalDateTime ): List { return queryFactory .select( QGetAdminAgentAssignedCreatorItem( agentCreatorRelation.creator.id, agentCreatorRelation.creator.nickname, agentCreatorRelation.assignedAt ) ) .from(agentCreatorRelation) .where(agentCreatorRelation.agent.id.eq(agentId).and(isActiveAssignment(currentTime))) .orderBy(agentCreatorRelation.creator.id.desc()) .offset(offset) .limit(limit) .fetch() } private fun isActiveAssignment(currentTime: LocalDateTime): BooleanExpression { return agentCreatorRelation.assignedAt.loe(currentTime) .and(agentCreatorRelation.unassignedAt.isNull.or(agentCreatorRelation.unassignedAt.gt(currentTime))) } } ``` - [x] **Step 4: Run the query repository test to verify it passes** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadQueryRepositoryTest` Expected: PASS with `BUILD SUCCESSFUL`. - [x] **Step 5: Commit the query repository slice** ```bash git add src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentListResponse.kt src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/SearchAdminAgentAssignableCreatorResponse.kt src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentAssignedCreatorResponse.kt src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepository.kt src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepositoryTest.kt git commit -m "feat(admin-agent): 관리자 에이전트 조회 query를 추가한다" ``` --- ### Task 2: Admin read service and settlement delegation **Files:** - Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadServiceTest.kt` - [x] **Step 1: Write the failing service tests** ```kotlin package kr.co.vividnext.sodalive.admin.partner.agent.read import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorItem import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorResponse import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorTotal import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito import java.time.LocalDateTime import java.util.Optional class AdminAgentReadServiceTest { private lateinit var queryRepository: AdminAgentReadQueryRepository private lateinit var memberRepository: MemberRepository private lateinit var calculateService: AgentCalculateService private lateinit var service: AdminAgentReadService @BeforeEach fun setup() { queryRepository = Mockito.mock(AdminAgentReadQueryRepository::class.java) memberRepository = Mockito.mock(MemberRepository::class.java) calculateService = Mockito.mock(AgentCalculateService::class.java) service = AdminAgentReadService(queryRepository, memberRepository, calculateService) } @Test @DisplayName("크리에이터 검색은 두 글자 미만 검색어를 거부한다") fun shouldRejectTooShortSearchWord() { val exception = assertThrows(SodaException::class.java) { service.searchAssignableCreators(searchWord = "a", offset = 0, limit = 20) } assertEquals("admin.member.search_word_min_length", exception.messageKey) } @Test @DisplayName("소속 크리에이터 목록은 AGENT 대상만 조회한다") fun shouldThrowWhenTargetMemberIsNotAgent() { val creator = Member( email = "creator@test.com", password = "password", nickname = "creator", role = MemberRole.CREATOR ) creator.id = 7L Mockito.`when`(memberRepository.findById(7L)).thenReturn(Optional.of(creator)) val exception = assertThrows(SodaException::class.java) { service.getAssignedCreators(agentId = 7L, offset = 0, limit = 20) } assertEquals("partner.agent.ratio.invalid_agent", exception.messageKey) } @Test @DisplayName("라이브 정산 상세 조회는 기존 AgentCalculateService로 위임한다") fun shouldDelegateLiveSettlementToAgentCalculateService() { val agent = Member( email = "agent@test.com", password = "password", nickname = "agent", role = MemberRole.AGENT ) agent.id = 11L val response = GetAgentSettlementByCreatorResponse( totalCount = 1, total = GetAgentSettlementByCreatorTotal( count = 1, totalCan = 50, krw = 5000, fee = 330, settlementAmount = 3736, tax = 123, depositAmount = 3613, agentSettlementAmount = 131 ), items = listOf( GetAgentSettlementByCreatorItem( creatorId = 21L, creatorNickname = "creator-a", count = 1, totalCan = 50, krw = 5000, fee = 330, settlementAmount = 3736, tax = 123, depositAmount = 3613, agentSettlementAmount = 131 ) ) ) Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent)) Mockito.`when`( calculateService.getCalculateLiveByCreator( startDateStr = "2026-04-01", endDateStr = "2026-04-30", agentId = 11L, offset = 0L, limit = 20L ) ).thenReturn(response) val actual = service.getCalculateLiveByCreator( agentId = 11L, startDateStr = "2026-04-01", endDateStr = "2026-04-30", offset = 0L, limit = 20L ) assertEquals(131, actual.items.first().agentSettlementAmount) Mockito.verify(calculateService).getCalculateLiveByCreator( startDateStr = "2026-04-01", endDateStr = "2026-04-30", agentId = 11L, offset = 0L, limit = 20L ) } } ``` - [x] **Step 2: Run the service test to verify it fails** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest` Expected: FAIL with unresolved reference for `AdminAgentReadService`. - [x] **Step 3: Write the admin read service** ```kotlin package kr.co.vividnext.sodalive.admin.partner.agent.read import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorResponse import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorResponse import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime @Service class AdminAgentReadService( private val queryRepository: AdminAgentReadQueryRepository, private val memberRepository: MemberRepository, private val calculateService: AgentCalculateService ) { @Transactional(readOnly = true) fun getAgentList(offset: Long, limit: Long): GetAdminAgentListResponse { val now = LocalDateTime.now() return GetAdminAgentListResponse( totalCount = queryRepository.getAgentListTotalCount(), items = queryRepository.getAgentList(offset = offset, limit = limit, currentTime = now) ) } @Transactional(readOnly = true) fun searchAssignableCreators(searchWord: String, offset: Long, limit: Long): SearchAdminAgentAssignableCreatorResponse { if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length") val now = LocalDateTime.now() return SearchAdminAgentAssignableCreatorResponse( totalCount = queryRepository.searchAssignableCreatorsTotalCount(searchWord = searchWord, currentTime = now), items = queryRepository.searchAssignableCreators(searchWord = searchWord, offset = offset, limit = limit, currentTime = now) ) } @Transactional(readOnly = true) fun getAssignedCreators(agentId: Long, offset: Long, limit: Long): GetAdminAgentAssignedCreatorResponse { validateAgent(agentId) val now = LocalDateTime.now() return GetAdminAgentAssignedCreatorResponse( totalCount = queryRepository.getAssignedCreatorTotalCount(agentId = agentId, currentTime = now), items = queryRepository.getAssignedCreators(agentId = agentId, offset = offset, limit = limit, currentTime = now) ) } @Transactional(readOnly = true) fun getCalculateLiveByCreator( agentId: Long, startDateStr: String, endDateStr: String, offset: Long, limit: Long ): GetAgentSettlementByCreatorResponse { validateAgent(agentId) return calculateService.getCalculateLiveByCreator(startDateStr, endDateStr, agentId, offset, limit) } @Transactional(readOnly = true) fun getCalculateContentByCreator( agentId: Long, startDateStr: String, endDateStr: String, offset: Long, limit: Long ): GetAgentSettlementByCreatorResponse { validateAgent(agentId) return calculateService.getCalculateContentByCreator(startDateStr, endDateStr, agentId, offset, limit) } @Transactional(readOnly = true) fun getCalculateCommunityByCreator( agentId: Long, startDateStr: String, endDateStr: String, offset: Long, limit: Long ): GetAgentSettlementByCreatorResponse { validateAgent(agentId) return calculateService.getCalculateCommunityByCreator(startDateStr, endDateStr, agentId, offset, limit) } @Transactional(readOnly = true) fun getCalculateContentDonationByCreator( agentId: Long, startDateStr: String, endDateStr: String, offset: Long, limit: Long ): GetAgentSettlementByCreatorResponse { validateAgent(agentId) return calculateService.getCalculateContentDonationByCreator(startDateStr, endDateStr, agentId, offset, limit) } @Transactional(readOnly = true) fun getChannelDonationByCreator( agentId: Long, startDateStr: String, endDateStr: String, offset: Long, limit: Long ): GetAgentChannelDonationSettlementByCreatorResponse { validateAgent(agentId) return calculateService.getChannelDonationByCreator(startDateStr, endDateStr, agentId, offset, limit) } private fun validateAgent(agentId: Long) { val member = memberRepository.findById(agentId).orElseThrow { SodaException(messageKey = "partner.agent.ratio.agent_not_found") } if (member.role != MemberRole.AGENT) { throw SodaException(messageKey = "partner.agent.ratio.invalid_agent") } } } ``` - [x] **Step 4: Run the service test to verify it passes** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest` Expected: PASS with `BUILD SUCCESSFUL`. - [x] **Step 5: Commit the service slice** ```bash git add src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadService.kt src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadServiceTest.kt git commit -m "feat(admin-agent): 관리자 에이전트 상세 조회 서비스를 추가한다" ``` --- ### Task 3: Admin read controller and endpoint forwarding **Files:** - Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadController.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerTest.kt` - [x] **Step 1: Write the failing controller tests** ```kotlin package kr.co.vividnext.sodalive.admin.partner.agent.read 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 import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorItem import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorResponse import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorTotal 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.data.domain.PageRequest import java.time.LocalDateTime class AdminAgentReadControllerTest { private lateinit var service: AdminAgentReadService private lateinit var controller: AdminAgentReadController @BeforeEach fun setup() { service = Mockito.mock(AdminAgentReadService::class.java) controller = AdminAgentReadController(service) } @Test @DisplayName("관리자 컨트롤러는 에이전트 목록 조회 파라미터를 서비스로 전달한다") fun shouldForwardAgentListRequest() { val body = GetAdminAgentListResponse( totalCount = 1, items = listOf(GetAdminAgentListItem(agentId = 11L, agentNickname = "agent-a", assignedCreatorCount = 2)) ) Mockito.`when`(service.getAgentList(offset = 20L, limit = 20L)).thenReturn(body) val response = controller.getAgentList(PageRequest.of(1, 20)) assertEquals(true, response.success) assertEquals(2, response.data!!.items.first().assignedCreatorCount) Mockito.verify(service).getAgentList(offset = 20L, limit = 20L) } @Test @DisplayName("관리자 컨트롤러는 크리에이터 검색 파라미터를 서비스로 전달한다") fun shouldForwardCreatorSearchRequest() { val body = SearchAdminAgentAssignableCreatorResponse( totalCount = 1, items = listOf( SearchAdminAgentAssignableCreatorItem( creatorId = 21L, creatorNickname = "creator-a", currentAgentId = 11L, currentAgentNickname = "agent-a" ) ) ) Mockito.`when`(service.searchAssignableCreators(searchWord = "creator", offset = 0L, limit = 10L)).thenReturn(body) val response = controller.searchAssignableCreators(searchWord = "creator", pageable = PageRequest.of(0, 10)) assertEquals(true, response.success) assertEquals(11L, response.data!!.items.first().currentAgentId) Mockito.verify(service).searchAssignableCreators(searchWord = "creator", offset = 0L, limit = 10L) } @Test @DisplayName("관리자 컨트롤러는 소속 크리에이터 목록 조회 파라미터를 서비스로 전달한다") fun shouldForwardAssignedCreatorListRequest() { val body = GetAdminAgentAssignedCreatorResponse( totalCount = 1, items = listOf( GetAdminAgentAssignedCreatorItem( creatorId = 21L, creatorNickname = "creator-a", assignedAt = LocalDateTime.of(2026, 4, 1, 10, 0) ) ) ) Mockito.`when`(service.getAssignedCreators(agentId = 11L, offset = 10L, limit = 5L)).thenReturn(body) val response = controller.getAssignedCreators(agentId = 11L, pageable = PageRequest.of(2, 5)) assertEquals(true, response.success) assertEquals(21L, response.data!!.items.first().creatorId) Mockito.verify(service).getAssignedCreators(agentId = 11L, offset = 10L, limit = 5L) } @Test @DisplayName("관리자 컨트롤러는 라이브 정산 조회 파라미터를 서비스로 전달한다") fun shouldForwardLiveSettlementRequest() { val body = GetAgentSettlementByCreatorResponse( totalCount = 1, total = GetAgentSettlementByCreatorTotal(1, 50, 5000, 330, 3736, 123, 3613, 131), items = listOf(GetAgentSettlementByCreatorItem(21L, "creator-a", 1, 50, 5000, 330, 3736, 123, 3613, 131)) ) Mockito.`when`( service.getCalculateLiveByCreator( agentId = 11L, startDateStr = "2026-04-01", endDateStr = "2026-04-30", offset = 0L, limit = 20L ) ).thenReturn(body) val response = controller.getCalculateLiveByCreator( agentId = 11L, startDateStr = "2026-04-01", endDateStr = "2026-04-30", pageable = PageRequest.of(0, 20) ) assertEquals(true, response.success) assertEquals(131, response.data!!.items.first().agentSettlementAmount) Mockito.verify(service).getCalculateLiveByCreator(11L, "2026-04-01", "2026-04-30", 0L, 20L) } @Test @DisplayName("관리자 컨트롤러는 채널후원 정산 조회 파라미터를 서비스로 전달한다") fun shouldForwardChannelDonationSettlementRequest() { val body = GetAgentChannelDonationSettlementByCreatorResponse( totalCount = 1, total = GetAgentChannelDonationSettlementTotal(1, 50, 5000, 330, 3970, 131, 3839, 397), items = listOf(GetAgentChannelDonationSettlementByCreatorItem(21L, "creator-a", 1, 50, 5000, 330, 3970, 131, 3839, 397)) ) Mockito.`when`( service.getChannelDonationByCreator( agentId = 11L, startDateStr = "2026-04-01", endDateStr = "2026-04-30", offset = 0L, limit = 20L ) ).thenReturn(body) val response = controller.getChannelDonationByCreator( agentId = 11L, startDateStr = "2026-04-01", endDateStr = "2026-04-30", pageable = PageRequest.of(0, 20) ) assertEquals(true, response.success) assertEquals(397, response.data!!.items.first().agentSettlementAmount) Mockito.verify(service).getChannelDonationByCreator(11L, "2026-04-01", "2026-04-30", 0L, 20L) } } ``` - [x] **Step 2: Run the controller test to verify it fails** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest` Expected: FAIL with unresolved reference for `AdminAgentReadController`. - [x] **Step 3: Write the admin read controller** ```kotlin package kr.co.vividnext.sodalive.admin.partner.agent.read import kr.co.vividnext.sodalive.common.ApiResponse import org.springframework.data.domain.Pageable import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController @PreAuthorize("hasRole('ADMIN')") @RequestMapping("/admin/partner/agent") class AdminAgentReadController( private val service: AdminAgentReadService ) { @GetMapping("/list") fun getAgentList(pageable: Pageable) = ApiResponse.ok( service.getAgentList(offset = pageable.offset, limit = pageable.pageSize.toLong()) ) @GetMapping("/creator/search") fun searchAssignableCreators( @RequestParam(value = "search_word") searchWord: String, pageable: Pageable ) = ApiResponse.ok( service.searchAssignableCreators(searchWord = searchWord, offset = pageable.offset, limit = pageable.pageSize.toLong()) ) @GetMapping("/{agentId}/creator/list") fun getAssignedCreators( @PathVariable agentId: Long, pageable: Pageable ) = ApiResponse.ok( service.getAssignedCreators(agentId = agentId, offset = pageable.offset, limit = pageable.pageSize.toLong()) ) @GetMapping("/{agentId}/calculate/live-by-creator") fun getCalculateLiveByCreator( @PathVariable agentId: Long, @RequestParam startDateStr: String, @RequestParam endDateStr: String, pageable: Pageable ) = ApiResponse.ok(service.getCalculateLiveByCreator(agentId, startDateStr, endDateStr, pageable.offset, pageable.pageSize.toLong())) @GetMapping("/{agentId}/calculate/content-by-creator") fun getCalculateContentByCreator( @PathVariable agentId: Long, @RequestParam startDateStr: String, @RequestParam endDateStr: String, pageable: Pageable ) = ApiResponse.ok(service.getCalculateContentByCreator(agentId, startDateStr, endDateStr, pageable.offset, pageable.pageSize.toLong())) @GetMapping("/{agentId}/calculate/community-by-creator") fun getCalculateCommunityByCreator( @PathVariable agentId: Long, @RequestParam startDateStr: String, @RequestParam endDateStr: String, pageable: Pageable ) = ApiResponse.ok(service.getCalculateCommunityByCreator(agentId, startDateStr, endDateStr, pageable.offset, pageable.pageSize.toLong())) @GetMapping("/{agentId}/calculate/channel-donation-by-creator") fun getChannelDonationByCreator( @PathVariable agentId: Long, @RequestParam startDateStr: String, @RequestParam endDateStr: String, pageable: Pageable ) = ApiResponse.ok(service.getChannelDonationByCreator(agentId, startDateStr, endDateStr, pageable.offset, pageable.pageSize.toLong())) @GetMapping("/{agentId}/calculate/content-donation-by-creator") fun getCalculateContentDonationByCreator( @PathVariable agentId: Long, @RequestParam startDateStr: String, @RequestParam endDateStr: String, pageable: Pageable ) = ApiResponse.ok(service.getCalculateContentDonationByCreator(agentId, startDateStr, endDateStr, pageable.offset, pageable.pageSize.toLong())) } ``` - [x] **Step 4: Run the controller test to verify it passes** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest` Expected: PASS with `BUILD SUCCESSFUL`. - [x] **Step 5: Commit the controller slice** ```bash git add src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadController.kt src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerTest.kt git commit -m "feat(admin-agent): 관리자 에이전트 상세 조회 엔드포인트를 추가한다" ``` --- ### Task 4: Admin read controller security coverage **Files:** - Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerSecurityTest.kt` - [x] **Step 1: Write the failing MockMvc security tests** ```kotlin package kr.co.vividnext.sodalive.admin.partner.agent.read import kr.co.vividnext.sodalive.common.ApiResponse import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @SpringBootTest @AutoConfigureMockMvc class AdminAgentReadControllerSecurityTest @Autowired constructor( private val mockMvc: MockMvc ) { @MockBean private lateinit var service: AdminAgentReadService @Test @DisplayName("관리자 권한이면 에이전트 목록 조회에 성공한다") fun shouldAllowAdminRole() { Mockito.`when`(service.getAgentList(offset = 0L, limit = 20L)).thenReturn( GetAdminAgentListResponse(totalCount = 0, items = emptyList()) ) mockMvc.perform( get("/admin/partner/agent/list") .param("page", "0") .param("size", "20") .with(user("admin").roles("ADMIN")) ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) } @Test @DisplayName("익명 사용자는 관리자 조회 API에 접근할 수 없다") fun shouldRejectAnonymousUser() { mockMvc.perform( get("/admin/partner/agent/list") .param("page", "0") .param("size", "20") ) .andExpect(status().isUnauthorized) } @Test @DisplayName("에이전트 권한 사용자는 관리자 조회 API에 접근할 수 없다") fun shouldRejectAgentRole() { mockMvc.perform( get("/admin/partner/agent/list") .param("page", "0") .param("size", "20") .with(user("agent").roles("AGENT")) ) .andExpect(status().isForbidden) } @Test @DisplayName("일반 사용자 권한 사용자는 관리자 조회 API에 접근할 수 없다") fun shouldRejectUserRole() { mockMvc.perform( get("/admin/partner/agent/list") .param("page", "0") .param("size", "20") .with(user("user").roles("USER")) ) .andExpect(status().isForbidden) } } ``` - [x] **Step 2: Run the controller security test to verify it fails** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest` Expected: FAIL with unresolved reference for `AdminAgentReadController`, `AdminAgentReadService`, or missing admin read route wiring. - [x] **Step 3: Write the MockMvc security test file after controller implementation** ```kotlin @SpringBootTest @AutoConfigureMockMvc class AdminAgentReadControllerSecurityTest @Autowired constructor( private val mockMvc: MockMvc ) { @MockBean private lateinit var service: AdminAgentReadService @Test fun shouldAllowAdminRole() { Mockito.`when`(service.getAgentList(offset = 0L, limit = 20L)).thenReturn( GetAdminAgentListResponse(totalCount = 0, items = emptyList()) ) mockMvc.perform( get("/admin/partner/agent/list") .param("page", "0") .param("size", "20") .with(user("admin").roles("ADMIN")) ) .andExpect(status().isOk) } @Test fun shouldRejectAnonymousUser() { mockMvc.perform(get("/admin/partner/agent/list").param("page", "0").param("size", "20")) .andExpect(status().isUnauthorized) } @Test fun shouldRejectAgentRole() { mockMvc.perform( get("/admin/partner/agent/list") .param("page", "0") .param("size", "20") .with(user("agent").roles("AGENT")) ) .andExpect(status().isForbidden) } @Test fun shouldRejectUserRole() { mockMvc.perform( get("/admin/partner/agent/list") .param("page", "0") .param("size", "20") .with(user("user").roles("USER")) ) .andExpect(status().isForbidden) } } ``` - [x] **Step 4: Run the controller security test to verify it passes** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest` Expected: PASS with `BUILD SUCCESSFUL`. - [x] **Step 5: Commit the controller security slice** ```bash git add src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerSecurityTest.kt git commit -m "test(admin-agent): 관리자 조회 컨트롤러 보안 테스트를 추가한다" ``` --- ### Task 5: Admin and agent settlement parity regression **Files:** - Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadParityTest.kt` - [x] **Step 1: Write the failing parity regression test** ```kotlin package kr.co.vividnext.sodalive.admin.partner.agent.read import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService import kr.co.vividnext.sodalive.partner.agent.ratio.AgentSettlementRatioRepository import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository 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.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import import javax.persistence.EntityManager @DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) @Import(QueryDslConfig::class) class AdminAgentReadParityTest @Autowired constructor( private val queryFactory: JPAQueryFactory, private val memberRepository: MemberRepository, private val relationRepository: AgentCreatorRelationRepository, private val snapshotRepository: AgentSettlementSnapshotRepository, private val entityManager: EntityManager ) { private var seededAgentId: Long = 0L private lateinit var agentService: AgentCalculateService private lateinit var adminService: AdminAgentReadService @BeforeEach fun setup() { val queryRepository = AgentCalculateQueryRepository(queryFactory, entityManager) val adminQueryRepository = AdminAgentReadQueryRepository(queryFactory) agentService = AgentCalculateService(queryRepository, snapshotRepository) adminService = AdminAgentReadService(adminQueryRepository, memberRepository, agentService) seededAgentId = seedParityFixtures() } @Test @DisplayName("관리자 라이브 정산 조회는 에이전트 조회와 동일한 응답을 반환한다") fun shouldMatchLiveSettlementBetweenAdminAndAgent() { val agentResponse = agentService.getCalculateLiveByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L) val adminResponse = adminService.getCalculateLiveByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L) assertEquals(agentResponse, adminResponse) } private fun seedParityFixtures(): Long { val agent = saveMember("agent-parity", MemberRole.AGENT) val creator = saveMember("creator-parity", MemberRole.CREATOR) saveRelation(agent, creator) seedLiveSettlementFixtures(agent, creator) seedContentSettlementFixtures(agent, creator) seedCommunitySettlementFixtures(agent, creator) seedChannelDonationSettlementFixtures(agent, creator) seedContentDonationSettlementFixtures(agent, creator) return agent.id!! } } ``` - [x] **Step 2: Run the parity regression test to verify it fails** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadParityTest` Expected: FAIL with unresolved reference for `AdminAgentReadService`, parity fixture helpers, or missing admin read classes. - [x] **Step 3: Implement the parity regression coverage for all 5 settlement types** ```kotlin @Test @DisplayName("관리자 콘텐츠 정산 조회는 에이전트 조회와 동일한 응답을 반환한다") fun shouldMatchContentSettlementBetweenAdminAndAgent() { val agentResponse = agentService.getCalculateContentByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L) val adminResponse = adminService.getCalculateContentByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L) assertEquals(agentResponse.totalCount, adminResponse.totalCount) assertEquals(agentResponse.total, adminResponse.total) assertEquals(agentResponse.items, adminResponse.items) } @Test @DisplayName("관리자 커뮤니티 정산 조회는 에이전트 조회와 동일한 응답을 반환한다") fun shouldMatchCommunitySettlementBetweenAdminAndAgent() { val agentResponse = agentService.getCalculateCommunityByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L) val adminResponse = adminService.getCalculateCommunityByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L) assertEquals(agentResponse.totalCount, adminResponse.totalCount) assertEquals(agentResponse.total, adminResponse.total) assertEquals(agentResponse.items, adminResponse.items) } @Test @DisplayName("관리자 채널후원 정산 조회는 에이전트 조회와 동일한 응답을 반환한다") fun shouldMatchChannelDonationSettlementBetweenAdminAndAgent() { val agentResponse = agentService.getChannelDonationByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L) val adminResponse = adminService.getChannelDonationByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L) assertEquals(agentResponse.totalCount, adminResponse.totalCount) assertEquals(agentResponse.total, adminResponse.total) assertEquals(agentResponse.items, adminResponse.items) } @Test @DisplayName("관리자 콘텐츠후원 정산 조회는 에이전트 조회와 동일한 응답을 반환한다") fun shouldMatchContentDonationSettlementBetweenAdminAndAgent() { val agentResponse = agentService.getCalculateContentDonationByCreator("2026-02-20", "2026-02-20", seededAgentId, 0L, 20L) val adminResponse = adminService.getCalculateContentDonationByCreator(seededAgentId, "2026-02-20", "2026-02-20", 0L, 20L) assertEquals(agentResponse.totalCount, adminResponse.totalCount) assertEquals(agentResponse.total, adminResponse.total) assertEquals(agentResponse.items, adminResponse.items) } ``` - [x] **Step 4: Run the parity regression test to verify it passes** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadParityTest` Expected: PASS with `BUILD SUCCESSFUL`. - [x] **Step 5: Commit the parity regression slice** ```bash git add src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadParityTest.kt git commit -m "test(admin-agent): 관리자와 에이전트 정산 응답 parity를 검증한다" ``` --- ### Task 6: Full verification and plan log update **Files:** - Modify: `docs/20260410_관리자에이전트정산상세조회구현계획.md` - [x] **Step 1: Update this plan document’s checklist and verification record** ```markdown ## 검증 기록 - 1차 구현 - 무엇을: 관리자용 에이전트 목록/검색/소속 목록/5종 정산 read API를 구현했다. - 왜: ADMIN이 특정 agentId 기준 상세 운영 정보를 조회할 수 있어야 했기 때문이다. - 어떻게: - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadQueryRepositoryTest` 결과: 성공 - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest` 결과: 성공 - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest` 결과: 성공 - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest` 결과: 성공 - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadParityTest` 결과: 성공 - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest.shouldAllowAdminRole --info` 결과: 성공 (`/admin/partner/agent/list?page=0&size=20` 응답 본문 `{"success":true,"message":null,"data":{"totalCount":0,"items":[]},"errorProperty":null}` 확인) - 명령: `./gradlew build` 결과: 성공 ## 정정/추가 메모 - 후속 수정이 발생하면 기존 검증 기록을 삭제하지 않고 `2차 수정 검증 기록`처럼 누적한다. - 기존 기록 정정이 필요하면 원문을 지우지 말고 `정정` 항목에 사유와 변경 내용을 추가한다. - 관리자 보안 테스트는 사용자 요청에 따라 `@SpringBootTest` 대신 `@WebMvcTest`와 테스트 전용 SecurityFilterChain으로 구현했다. ``` - [x] **Step 2: Run the focused test suite** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadParityTest` Expected: PASS with `BUILD SUCCESSFUL`. - [x] **Step 3: Run the existing agent calculate regression tests** Run: `./gradlew test --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateControllerTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateServiceTest --tests kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepositoryTest` Expected: PASS with `BUILD SUCCESSFUL`. - [x] **Step 4: Run the full build for compile and packaging verification** Run: `./gradlew build` Expected: PASS with `BUILD SUCCESSFUL`. - [x] **Step 5: Commit the finished feature** ```bash git add src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read docs/20260410_관리자에이전트정산상세조회구현계획.md docs/20260410_관리자에이전트정산상세조회설계.md git commit -m "feat(admin-agent): 관리자 에이전트 정산 상세 조회를 추가한다" ``` --- ## Follow-up Scope: Agent List Current-Month Settlement Summary ### Design Gap Checklist - [x] `/admin/partner/agent/list`가 `startDateStr`, `endDateStr` 없이 현재 월 기준 summary를 계산하도록 반영한다. - [x] `GetAdminAgentListItem`에 `live/content/community/contentDonation/channelDonation` 5종 `agentSettlementAmount` 필드를 추가한다. - [x] 해당 월 정산 내역이 없는 에이전트도 목록에 남기고, 5종 금액을 모두 `0`으로 반환하도록 반영한다. - [x] 리스트 summary 값이 같은 월 기준 상세 조회 응답의 `total.agentSettlementAmount`와 일치하는지 검증하는 회귀 테스트를 추가한다. --- ### Task 7: Agent list summary DTO and current-month repository enrichment **Files:** - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentListResponse.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepository.kt` - Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadCurrentMonthListSummaryTest.kt` - [x] **Step 1: Write the failing current-month list summary test** ```kotlin package kr.co.vividnext.sodalive.admin.partner.agent.read import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatioRepository import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.content.AudioContentRepository import kr.co.vividnext.sodalive.content.order.OrderRepository import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository import kr.co.vividnext.sodalive.live.room.LiveRoomRepository import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateQueryRepository import kr.co.vividnext.sodalive.partner.agent.calculate.AgentCalculateService import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository import kr.co.vividnext.sodalive.can.use.UseCanRepository import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository 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.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import import java.time.LocalDateTime import javax.persistence.EntityManager @DataJpaTest(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) @Import(QueryDslConfig::class) class AdminAgentReadCurrentMonthListSummaryTest @Autowired constructor( private val queryFactory: JPAQueryFactory, private val memberRepository: MemberRepository, private val relationRepository: AgentCreatorRelationRepository, private val creatorSettlementRatioRepository: CreatorSettlementRatioRepository, private val audioContentRepository: AudioContentRepository, private val orderRepository: OrderRepository, private val liveRoomRepository: LiveRoomRepository, private val creatorCommunityRepository: CreatorCommunityRepository, private val useCanRepository: UseCanRepository, private val useCanCalculateRepository: UseCanCalculateRepository, private val snapshotRepository: AgentSettlementSnapshotRepository, private val entityManager: EntityManager ) { private lateinit var repository: AdminAgentReadQueryRepository private lateinit var calculateService: AgentCalculateService @BeforeEach fun setup() { val calculateQueryRepository = AgentCalculateQueryRepository(queryFactory, entityManager) repository = AdminAgentReadQueryRepository(queryFactory, calculateQueryRepository) calculateService = AgentCalculateService(calculateQueryRepository, snapshotRepository) registerMysqlDateFunctions() } @Test @DisplayName("에이전트 목록 조회는 현재 월 5종 정산 합계를 포함하고 내역이 없으면 0을 반환한다") fun shouldReturnCurrentMonthSettlementSummariesAndZeroForEmptyAgents() { val now = ZonedDateTime.of(LocalDateTime.of(2026, 4, 10, 12, 0), ZoneId.of("Asia/Seoul")) val agentWithRows = saveMember("agent-with-rows", MemberRole.AGENT) val emptyAgent = saveMember("agent-empty", MemberRole.AGENT) seedCurrentMonthSettlementFixtures(agentWithRows, now.toLocalDateTime()) val items = repository.getAgentList(offset = 0, limit = 20, currentTime = now) val emptyItem = items.first { it.agentId == emptyAgent.id } val agentItem = items.first { it.agentId == agentWithRows.id } assertEquals(0, emptyItem.liveAgentSettlementAmount) assertEquals(0, emptyItem.contentAgentSettlementAmount) assertEquals(0, emptyItem.communityAgentSettlementAmount) assertEquals(0, emptyItem.contentDonationAgentSettlementAmount) assertEquals(0, emptyItem.channelDonationAgentSettlementAmount) assertEquals( calculateService.getCalculateLiveByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L).total.agentSettlementAmount, agentItem.liveAgentSettlementAmount ) assertEquals( calculateService.getCalculateContentByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L).total.agentSettlementAmount, agentItem.contentAgentSettlementAmount ) assertEquals( calculateService.getCalculateCommunityByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L).total.agentSettlementAmount, agentItem.communityAgentSettlementAmount ) assertEquals( calculateService.getCalculateContentDonationByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L).total.agentSettlementAmount, agentItem.contentDonationAgentSettlementAmount ) assertEquals( calculateService.getChannelDonationByCreator("2026-04-01", "2026-04-30", agentWithRows.id!!, 0L, 20L).total.agentSettlementAmount, agentItem.channelDonationAgentSettlementAmount ) } } ``` - [x] **Step 2: Run the current-month list summary test to verify it fails** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadCurrentMonthListSummaryTest` Expected: FAIL with unresolved references for the new list summary fields or missing current-month summary query support. - [x] **Step 3: Extend the list DTO and Querydsl repository** ```kotlin data class GetAdminAgentListItem @QueryProjection constructor( val agentId: Long, val agentNickname: String, val assignedCreatorCount: Int, val liveAgentSettlementAmount: Int, val contentAgentSettlementAmount: Int, val communityAgentSettlementAmount: Int, val contentDonationAgentSettlementAmount: Int, val channelDonationAgentSettlementAmount: Int ) fun getAgentList(offset: Long, limit: Long, currentTime: ZonedDateTime): List { val kstCurrentTime = currentTime.withZoneSameInstant(ZoneId.of("Asia/Seoul")) val currentMonthStartKst = kstCurrentTime.toLocalDate().withDayOfMonth(1).atStartOfDay(ZoneId.of("Asia/Seoul")) val nextMonthStartKst = currentMonthStartKst.plusMonths(1) val currentMonthStart = currentMonthStartKst.withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime() val currentMonthEnd = nextMonthStartKst.withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime().minusNanos(1) return queryFactory .select( QGetAdminAgentListItem( member.id, member.nickname, agentCreatorRelation.id.countDistinct().intValue(), Expressions.numberTemplate(Int::class.java, "0"), Expressions.numberTemplate(Int::class.java, "0"), Expressions.numberTemplate(Int::class.java, "0"), Expressions.numberTemplate(Int::class.java, "0"), Expressions.numberTemplate(Int::class.java, "0") ) ) .from(member) .leftJoin(agentCreatorRelation) .on(agentCreatorRelation.agent.id.eq(member.id).and(isActiveAssignment(kstCurrentTime.toLocalDateTime()))) .where(member.role.eq(MemberRole.AGENT)) .groupBy(member.id, member.nickname) .orderBy(member.id.desc()) .offset(offset) .limit(limit) .fetch() .map { item -> item.copy( liveAgentSettlementAmount = calculateQueryRepository .getCalculateLiveByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId) .agentSettlementAmount, contentAgentSettlementAmount = calculateQueryRepository .getCalculateContentByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId) .agentSettlementAmount, communityAgentSettlementAmount = calculateQueryRepository .getCalculateCommunityByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId) .agentSettlementAmount, contentDonationAgentSettlementAmount = calculateQueryRepository .getCalculateContentDonationByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId) .agentSettlementAmount, channelDonationAgentSettlementAmount = calculateQueryRepository .getChannelDonationByCreatorTotal(currentMonthStart, currentMonthEnd, item.agentId) .agentSettlementAmount ) } } ``` - [x] **Step 4: Run the current-month list summary test to verify it passes** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadCurrentMonthListSummaryTest` Expected: PASS with `BUILD SUCCESSFUL`. - [ ] **Step 5: Commit the list summary query slice** ```bash git add src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/GetAdminAgentListResponse.kt src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadQueryRepository.kt src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadCurrentMonthListSummaryTest.kt git commit -m "feat(admin-agent): 에이전트 목록에 현재 월 정산 요약을 추가한다" ``` --- ### Task 8: Agent list service and controller contract update **Files:** - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadService.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadController.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadServiceTest.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerTest.kt` - [x] **Step 1: Write the failing service and controller tests** ```kotlin @Test @DisplayName("에이전트 목록 조회는 현재 월 summary 필드를 그대로 반환한다") fun shouldReturnAgentListWithCurrentMonthSummaryFields() { Mockito.`when`(queryRepository.getAgentListTotalCount()).thenReturn(1) Mockito.doReturn( listOf( GetAdminAgentListItem( agentId = 11L, agentNickname = "agent-a", assignedCreatorCount = 2, liveAgentSettlementAmount = 131, contentAgentSettlementAmount = 80, communityAgentSettlementAmount = 55, contentDonationAgentSettlementAmount = 12, channelDonationAgentSettlementAmount = 397 ) ) ).`when`(queryRepository).getAgentList( Mockito.eq(0L), Mockito.eq(20L), Mockito.any(ZonedDateTime::class.java) ?: ZonedDateTime.now() ) val actual = service.getAgentList(offset = 0L, limit = 20L) assertEquals(397, actual.items.first().channelDonationAgentSettlementAmount) } @Test @DisplayName("관리자 컨트롤러는 에이전트 목록 summary 필드를 응답에 포함한다") fun shouldForwardAgentListSummaryResponse() { val body = GetAdminAgentListResponse( totalCount = 1, items = listOf( GetAdminAgentListItem( agentId = 11L, agentNickname = "agent-a", assignedCreatorCount = 2, liveAgentSettlementAmount = 131, contentAgentSettlementAmount = 80, communityAgentSettlementAmount = 55, contentDonationAgentSettlementAmount = 12, channelDonationAgentSettlementAmount = 397 ) ) ) Mockito.`when`(service.getAgentList(offset = 20L, limit = 20L)).thenReturn(body) val response = controller.getAgentList(PageRequest.of(1, 20)) assertEquals(131, response.data!!.items.first().liveAgentSettlementAmount) assertEquals(397, response.data!!.items.first().channelDonationAgentSettlementAmount) } ``` - [x] **Step 2: Run the service and controller tests to verify they fail** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest` Expected: FAIL because the list summary fields are not yet reflected in the service/controller test contracts. - [x] **Step 3: Update the service and controller contract** ```kotlin @Transactional(readOnly = true) fun getAgentList(offset: Long, limit: Long): GetAdminAgentListResponse { val now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")) return GetAdminAgentListResponse( totalCount = queryRepository.getAgentListTotalCount(), items = queryRepository.getAgentList(offset = offset, limit = limit, currentTime = now) ) } @GetMapping("/list") fun getAgentList(pageable: Pageable) = ApiResponse.ok( service.getAgentList(offset = pageable.offset, limit = pageable.pageSize.toLong()) ) ``` - [x] **Step 4: Run the service and controller tests to verify they pass** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest` Expected: PASS with `BUILD SUCCESSFUL`. - [ ] **Step 5: Commit the list summary contract slice** ```bash git add src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadService.kt src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadController.kt src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadServiceTest.kt src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read/AdminAgentReadControllerTest.kt git commit -m "test(admin-agent): 에이전트 목록 정산 요약 계약을 갱신한다" ``` --- ### Task 9: Follow-up verification and plan log update **Files:** - Modify: `docs/20260410_관리자에이전트정산상세조회구현계획.md` - [x] **Step 1: Update the checklist and verification record for the current-month summary follow-up** ```markdown ## 검증 기록 - 2차 수정 - 무엇을: 에이전트 목록 응답에 현재 월 기준 5종 정산 합계를 추가하고, 정산 내역이 없는 에이전트는 0원으로 유지하도록 보강했다. - 왜: 목록 화면이 각 상세 정산 페이지로 이동하는 summary entry point 역할을 해야 했기 때문이다. - 어떻게: - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadCurrentMonthListSummaryTest` 결과: 성공 - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest` 결과: 성공 - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest.shouldAllowAdminRole --info` 결과: 성공 (`/admin/partner/agent/list?page=0&size=20` 응답 본문 `{"success":true,"message":null,"data":{"totalCount":1,"items":[{"agentId":11,"agentNickname":"agent-a","assignedCreatorCount":2,"liveAgentSettlementAmount":131,"contentAgentSettlementAmount":80,"communityAgentSettlementAmount":55,"contentDonationAgentSettlementAmount":12,"channelDonationAgentSettlementAmount":397}]},"errorProperty":null}` 확인) - 명령: `./gradlew build` 결과: 성공 - 3차 수정 - 무엇을: 에이전트 목록 현재 월 경계를 `Asia/Seoul` 타임존 기준으로 고정했다. - 왜: 설계 문서의 KST 월 경계 요구와 DB UTC 저장 시각 조회 조건을 일치시켜야 했기 때문이다. - 어떻게: - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadCurrentMonthListSummaryTest` 결과: 성공 - 명령: `./gradlew build` 결과: 성공 - 명령: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest.shouldAllowAdminRole --info` 결과: 성공 (`/admin/partner/agent/list?page=0&size=20` 응답 본문에 현재 월 5종 summary 필드 유지 확인) ``` - [x] **Step 2: Run the focused current-month summary test suite** Run: `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadCurrentMonthListSummaryTest --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadServiceTest --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerTest` Expected: PASS with `BUILD SUCCESSFUL`. - [x] **Step 3: Run the full build for compile and packaging verification** Run: `./gradlew build` Expected: PASS with `BUILD SUCCESSFUL`. - [ ] **Step 4: Commit the current-month summary follow-up** ```bash git add src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/read docs/20260410_관리자에이전트정산상세조회구현계획.md docs/20260410_관리자에이전트정산상세조회설계.md git commit -m "feat(admin-agent): 에이전트 목록 현재 월 정산 요약을 반영한다" ```