Files
sodalive-backend-spring-boot/docs/20260410_관리자에이전트정산상세조회구현계획.md

71 KiB
Raw Blame History

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

  • 컨트롤러 보안 테스트에 ADMIN 허용, 익명 401, AGENT/일반 사용자 403을 검증하는 시나리오를 추가한다.
  • 정산 5종에 대해 동일 기간·동일 agentId 기준 AGENT/ADMIN 응답의 totalCount, total, items parity를 비교하는 회귀 테스트를 추가한다.
  • 검증 기록 단계에 무엇을/왜/어떻게, 실제 실행 명령/결과, 누적 기록 및 정정 원칙을 명시한다.

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

  • Step 1: Write the failing Querydsl repository tests

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)
    }
}
  • 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.

  • Step 3: Write the DTOs and Querydsl repository
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<GetAdminAgentListItem>
)

data class GetAdminAgentListItem @QueryProjection constructor(
    val agentId: Long,
    val agentNickname: String,
    val assignedCreatorCount: Int
)

data class SearchAdminAgentAssignableCreatorResponse(
    val totalCount: Int,
    val items: List<SearchAdminAgentAssignableCreatorItem>
)

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<GetAdminAgentAssignedCreatorItem>
)

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<GetAdminAgentListItem> {
        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<SearchAdminAgentAssignableCreatorItem> {
        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<GetAdminAgentAssignedCreatorItem> {
        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)))
    }
}
  • 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.

  • Step 5: Commit the query repository slice
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

  • Step 1: Write the failing service tests

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
        )
    }
}
  • 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.

  • Step 3: Write the admin read service
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")
        }
    }
}
  • 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.

  • Step 5: Commit the service slice
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

  • Step 1: Write the failing controller tests

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)
    }
}
  • 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.

  • Step 3: Write the admin read controller
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()))
}
  • 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.

  • Step 5: Commit the controller slice
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

  • Step 1: Write the failing MockMvc security tests

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)
    }
}
  • 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.

  • Step 3: Write the MockMvc security test file after controller implementation
@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)
    }
}
  • 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.

  • Step 5: Commit the controller security slice
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

  • Step 1: Write the failing parity regression test

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!!
    }
}
  • 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.

  • Step 3: Implement the parity regression coverage for all 5 settlement types
@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)
}
  • 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.

  • Step 5: Commit the parity regression slice
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

  • Step 1: Update this plan documents checklist and verification record

## 검증 기록
- 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으로 구현했다.
  • 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.

  • 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.

  • Step 4: Run the full build for compile and packaging verification

Run: ./gradlew build

Expected: PASS with BUILD SUCCESSFUL.

  • Step 5: Commit the finished feature
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

  • /admin/partner/agent/liststartDateStr, endDateStr 없이 현재 월 기준 summary를 계산하도록 반영한다.
  • GetAdminAgentListItemlive/content/community/contentDonation/channelDonation 5종 agentSettlementAmount 필드를 추가한다.
  • 해당 월 정산 내역이 없는 에이전트도 목록에 남기고, 5종 금액을 모두 0으로 반환하도록 반영한다.
  • 리스트 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

  • Step 1: Write the failing current-month list summary test

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
        )
    }
}
  • 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.

  • Step 3: Extend the list DTO and Querydsl repository
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<GetAdminAgentListItem> {
    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
            )
        }
}
  • 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
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

  • Step 1: Write the failing service and controller tests

@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)
}
  • 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.

  • Step 3: Update the service and controller contract
@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())
)
  • 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
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

  • Step 1: Update the checklist and verification record for the current-month summary follow-up

## 검증 기록
- 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 필드 유지 확인)
  • 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.

  • 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
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): 에이전트 목록 현재 월 정산 요약을 반영한다"