diff --git a/docs/20260410_관리자에이전트정산상세조회구현계획.md b/docs/20260410_관리자에이전트정산상세조회구현계획.md new file mode 100644 index 00000000..2c3f2ea8 --- /dev/null +++ b/docs/20260410_관리자에이전트정산상세조회구현계획.md @@ -0,0 +1,1637 @@ +# 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): 에이전트 목록 현재 월 정산 요약을 반영한다" +``` diff --git a/docs/20260410_관리자에이전트정산상세조회설계.md b/docs/20260410_관리자에이전트정산상세조회설계.md new file mode 100644 index 00000000..644b9e0c --- /dev/null +++ b/docs/20260410_관리자에이전트정산상세조회설계.md @@ -0,0 +1,313 @@ +# 관리자 에이전트 정산 상세 조회 설계 + +## 문서 목적 +- 관리자 페이지에서 에이전트 목록을 보고, 특정 에이전트 상세 화면에서 소속 크리에이터와 에이전트별 정산 현황을 조회할 수 있도록 백엔드 read API 설계를 고정한다. +- 기존 `partner/agent/calculate` 계산 로직은 최대한 재사용하고, `ADMIN` 전용 조회 진입점만 별도로 추가한다. + +## 요구사항 정리 +- 관리자 화면에는 에이전트 리스트가 필요하다. + - 에이전트 닉네임 + - 에이전트에 속한 크리에이터 수 +- 관리자 화면에는 크리에이터 검색이 필요하다. + - 특정 에이전트에 크리에이터를 소속시키기 위한 검색 +- 관리자 화면에는 특정 에이전트에 현재 소속된 크리에이터 목록이 필요하다. + - 에이전트 소속 해제를 위한 목록 +- 관리자 화면에는 특정 에이전트 기준 정산 상세가 필요하다. + - 라이브 정산 현황 + - 콘텐츠 판매 정산 현황 + - 커뮤니티 정산 현황 + - 채널 후원 정산 현황 + - 콘텐츠 후원 정산 현황 + +## 범위와 해석 +- 이번 설계는 **B안: 에이전트 목록 → 에이전트 상세** 흐름을 기준으로 한다. +- 에이전트 목록 화면에서는 요약 정보만 제공한다. +- 실제 정산 데이터는 에이전트 상세 화면에서 조회한다. +- 기존 `assignment`, `ratio`, `settlement/finalize` 쓰기 기능은 유지하고, 이번 범위에서는 관리자용 read API만 추가한다. + +## 현재 코드 기준 확인 사항 +- 관리자 전용 에이전트 관련 컨트롤러는 이미 존재한다. + - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt` + - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt` + - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt` +- 에이전트 본인 전용 정산 조회 컨트롤러도 이미 존재한다. + - `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt` +- 따라서 현재 부족한 것은 정산 계산 로직이 아니라, `ADMIN`이 특정 `agentId`를 지정해 같은 데이터를 읽는 관리자 전용 read API 레이어다. + +## 권장 아키텍처 + +### 1. 관리자 전용 read 진입점 추가 +- 신규 패키지: `kr.co.vividnext.sodalive.admin.partner.agent.read` +- 신규 구성 + - `AdminAgentReadController` + - `AdminAgentReadService` + - `AdminAgentReadQueryRepository` +- 역할 + - 에이전트 목록 조회 + - 크리에이터 검색 조회 + - 특정 에이전트 소속 크리에이터 목록 조회 + - 특정 에이전트 기준 5종 정산 조회 + +### 2. 기존 도메인 계산 로직 재사용 +- 기존 계산/집계 로직은 계속 `kr.co.vividnext.sodalive.partner.agent.calculate` 아래에 둔다. +- 관리자용 read 서비스는 기존 `AgentCalculateService`, `AgentCalculateQueryRepository`, snapshot 조회 경로를 재사용한다. +- 현재 `AgentCalculateService`의 정산 조회 메서드는 이미 `agentId`를 직접 받으므로, 관리자 read 서비스는 이 메서드를 그대로 호출한다. +- 최종안은 **정산 계산 로직은 `partner.agent.calculate`에 유지하고, ADMIN은 별도 read 서비스에서 기존 agentId 기반 조회 메서드를 재사용하는 구조**다. + +### 3. 쓰기와 읽기 축 분리 +- 쓰기 축 + - `admin.partner.agent.assignment` + - `admin.partner.agent.ratio` + - `admin.partner.agent.settlement` +- 읽기 축 + - `admin.partner.agent.read` + - `partner.agent.calculate` +- 이렇게 나누면 “관리자 권한으로 읽는다”와 “정산 계산 규칙을 제공한다”의 책임이 섞이지 않는다. + +## 화면 흐름 기준 API 설계 + +### 1. 에이전트 목록 API +- 목적: 관리자 화면의 첫 진입 리스트 +- 권장 경로: `GET /admin/partner/agent/list` +- 권한: `ADMIN` +- 요청 파라미터 + - `pageable` +- 응답 필드 + - `agentId` + - `agentNickname` + - `assignedCreatorCount` + - `liveAgentSettlementAmount` + - `contentAgentSettlementAmount` + - `communityAgentSettlementAmount` + - `contentDonationAgentSettlementAmount` + - `channelDonationAgentSettlementAmount` + - `totalCount` +- 조회 기준 + - `Member.role == AGENT` + - 현재 활성 소속 크리에이터 수만 집계 + - 활성 소속 판정은 현재 코드의 assignment window 규칙과 동일하게 현재 시각 기준 `assignedAt <= now < unassignedAt(or null)`를 따른다. + - 정산 합계는 별도 날짜 입력 없이 **현재 월 기준**으로 계산한다. + - 기준 시간대: `Asia/Seoul` + - 시작 시각: 현재 월 1일 `00:00:00` + - 종료 시각: 다음 달 1일 `00:00:00` 직전까지 포함되는 배타 상한 방식 + - 실제 조회 구간은 위 KST 월 경계를 UTC `LocalDateTime`으로 변환해 DB의 UTC 저장 시각과 비교한다. + - 각 합계 값의 의미는 해당 기간의 상세 조회 응답 `total.agentSettlementAmount`와 동일하다. + - 해당 월에 정산 내역이 없더라도 에이전트 목록에서는 제외하지 않고, 5종 합계를 모두 `0`으로 내려준다. + +### 2. 크리에이터 검색 API +- 목적: 특정 에이전트에 크리에이터를 소속시키기 전 검색 +- 권장 경로: `GET /admin/partner/agent/creator/search` +- 권한: `ADMIN` +- 요청 파라미터 + - `search_word` + - `pageable` +- 응답 필드 + - `creatorId` + - `creatorNickname` + - `currentAgentId` nullable + - `currentAgentNickname` nullable +- 동작 원칙 + - 기존 `AdminMemberController.searchCreator()` 검색 관례를 따른다. + - 검색 결과에 현재 활성 소속 agent 정보를 붙여서, 이미 다른 agent 소속인지 운영자가 바로 판단할 수 있게 한다. + +### 3. 특정 에이전트 소속 크리에이터 목록 API +- 목적: 상세 화면의 소속 크리에이터 탭 +- 권장 경로: `GET /admin/partner/agent/{agentId}/creator/list` +- 권한: `ADMIN` +- 응답 필드 + - `creatorId` + - `creatorNickname` + - `assignedAt` +- 참고 + - 현재 `GetAgentAssignedCreatorResponse`는 `creatorId`, `creatorNickname`만 제공한다. + - 관리자 상세 화면에서는 운영 판단을 위해 `assignedAt`이 같이 내려가는 편이 자연스럽다. + +### 4. 특정 에이전트 정산 상세 API 5종 +- 목적: 에이전트 상세 화면의 정산 탭 +- 권한: `ADMIN` +- 권장 경로 + - `GET /admin/partner/agent/{agentId}/calculate/live-by-creator` + - `GET /admin/partner/agent/{agentId}/calculate/content-by-creator` + - `GET /admin/partner/agent/{agentId}/calculate/community-by-creator` + - `GET /admin/partner/agent/{agentId}/calculate/channel-donation-by-creator` + - `GET /admin/partner/agent/{agentId}/calculate/content-donation-by-creator` +- 공통 요청 파라미터 + - `startDateStr` + - `endDateStr` + - `pageable` +- 응답 원칙 + - 기존 AGENT 전용 응답 계약을 가능한 그대로 유지한다. + - `totalCount`, `total`, `items` 구조 유지 + - 각 item의 집계 필드 유지 + - `count` + - `totalCan` + - `krw` + - `fee` + - `settlementAmount` + - `agentSettlementAmount` +- 차이점 + - 기존 AGENT API는 로그인 principal에서 `agentId`를 얻는다. + - 관리자 API는 path variable `agentId`를 받는다. + +## 데이터 모델/응답 설계 + +### 1. 에이전트 목록 응답 DTO +- 신규 DTO 필요 +- DTO 명 + - `GetAdminAgentListResponse` + - `GetAdminAgentListItem` +- 필수 필드 + - response: `totalCount: Int`, `items: List` + - item: + - `agentId: Long` + - `agentNickname: String` + - `assignedCreatorCount: Int` + - `liveAgentSettlementAmount: Int` + - `contentAgentSettlementAmount: Int` + - `communityAgentSettlementAmount: Int` + - `contentDonationAgentSettlementAmount: Int` + - `channelDonationAgentSettlementAmount: Int` + +### 2. 크리에이터 검색 응답 DTO +- 신규 DTO 필요 +- 기존 `admin/member` 검색 응답을 그대로 쓰기보다, 현재 활성 agent 정보를 함께 주는 전용 DTO가 필요하다. +- DTO 명 + - `SearchAdminAgentAssignableCreatorResponse` + - `SearchAdminAgentAssignableCreatorItem` +- 필수 필드 + - response: `totalCount: Int`, `items: List` + - item: `creatorId: Long`, `creatorNickname: String`, `currentAgentId: Long?`, `currentAgentNickname: String?` + +### 3. 관리자용 소속 크리에이터 목록 DTO +- 기존 `GetAgentAssignedCreatorResponse`를 그대로 재사용하기보다는 관리자용 항목에 `assignedAt`을 포함한 전용 DTO가 적합하다. +- DTO 명 + - `GetAdminAgentAssignedCreatorResponse` + - `GetAdminAgentAssignedCreatorItem` +- 필수 필드 + - response: `totalCount: Int`, `items: List` + - item: `creatorId: Long`, `creatorNickname: String`, `assignedAt: LocalDateTime` + +### 4. 관리자용 정산 상세 DTO +- 가능하면 기존 아래 DTO를 그대로 재사용한다. + - `GetAgentSettlementByCreatorResponse` + - `GetAgentChannelDonationSettlementByCreatorResponse` +- 이유 + - 화면 주체만 ADMIN으로 바뀌고 데이터 shape는 동일하기 때문이다. + - DTO까지 갈라지면 정산 계약이 중복될 가능성이 높다. + +## 서비스 설계 + +### 1. `AdminAgentReadService` +- 책임 + - 에이전트 목록 조회 + - 크리에이터 검색 조회 + - 에이전트 소속 크리에이터 목록 조회 + - 에이전트 정산 상세 조회 진입 +- 내부 동작 + - 목록/검색/소속 목록은 전용 read query를 사용한다. + - 에이전트 목록 조회 시 현재 월 기준 시작/종료 시각을 서비스에서 계산해 전용 read query에 전달한다. + - 정산 상세는 기존 `AgentCalculateService`의 agentId 기반 public 조회 메서드를 사용한다. + +### 2. 공통 정산 read 메서드 분리 +- 현재 `AgentCalculateService`의 핵심 정산 조회 메서드는 이미 `agentId` 파라미터 중심 public 메서드다. +- 따라서 아래 방향으로 정리한다. + - controller는 AGENT/ADMIN 별도로 유지 + - ADMIN read 서비스는 기존 `AgentCalculateService` public 메서드를 직접 호출한다. +- 기대 효과 + - 정산 계산 규칙 중복 제거 + - snapshot 우선 조회 / live 계산 fallback 규칙 재사용 + +## Repository / Query 방향 + +### 1. 에이전트 목록용 조회 추가 +- 필요 기능 + - AGENT role member 목록 + - 각 agent의 현재 활성 creator count + - 각 agent의 현재 월 기준 5종 정산 합계 +- 구현 방식 + - `AdminAgentReadQueryRepository`에서 전용 projection query를 제공한다. + - 기본 에이전트 목록과 활성 creator count는 기존 Querydsl projection query로 조회한다. + - 현재 월 5종 정산 합계는 각 목록 item마다 기존 `AgentCalculateQueryRepository` total 조회 메서드를 재사용해 채운다. + - 정산 row가 없는 agent도 목록에 남겨야 하므로 기존 total 조회 응답의 `0` 기본값을 그대로 사용한다. + +### 2. 크리에이터 검색용 조회 추가 +- 필요 기능 + - creator nickname 기준 검색 + - 현재 활성 소속 agent nullable join +- 구현 방식 + - `AdminAgentReadQueryRepository`에서 전용 검색 query를 제공한다. + +### 3. 소속 크리에이터 목록 조회 추가 +- 기존 `AgentCalculateQueryRepository.getAssignedCreators()`를 재사용할 수 있다. +- 다만 `assignedAt`을 응답에 내려야 하면 projection을 확장하거나 관리자 전용 projection을 추가해야 한다. + +## 인증/예외 처리 원칙 +- 새 관리자 read 엔드포인트는 모두 `@PreAuthorize("hasRole('ADMIN')")`를 사용한다. +- `agentId`가 존재하지 않거나 role이 `AGENT`가 아니면 `SodaException(messageKey = ...)`로 실패한다. +- `creator/search`는 기존 `admin/member/search` 관례처럼 최소 검색어 길이 검증을 적용한다. +- 정산 계산식, snapshot 우선 전략, assignment/ratio history 적용 규칙은 기존 `partner.agent.calculate` 동작을 그대로 사용한다. + +## 테스트 설계 + +### 1. 컨트롤러 테스트 +- `ADMIN` 접근 성공 +- 익명 사용자 접근 실패 +- `AGENT` 또는 일반 사용자 접근 실패 + +### 2. 서비스/리포지토리 테스트 +- 에이전트 목록에서 닉네임과 현재 활성 creator count가 맞는지 검증 +- 크리에이터 검색 결과에 현재 agent 소속 정보가 올바르게 붙는지 검증 +- 특정 agent 소속 크리에이터 목록이 현재 활성 구간 기준으로만 내려오는지 검증 + +### 3. 정산 parity 테스트 +- 동일 기간, 동일 `agentId`에 대해 + - AGENT 전용 조회 응답 + - ADMIN 전용 조회 응답 + - 두 결과의 `totalCount`, `total`, `items`가 동일한지 검증 +- 대상 5종 + - live + - content + - community + - channel donation + - content donation + +### 4. 설계 대비 구현 계획 누락 체크리스트 +- [x] 관리자 컨트롤러 테스트에 `ADMIN` 접근 성공, 익명 접근 실패, `AGENT` 또는 일반 사용자 접근 실패 시나리오가 모두 포함되어 있는지 확인한다. +- [x] 동일 기간, 동일 `agentId` 기준으로 AGENT 전용 응답과 ADMIN 전용 응답의 `totalCount`, `total`, `items` parity를 5종 모두 검증하는 테스트가 구현 계획에 포함되어 있는지 확인한다. +- [x] 구현 계획의 검증 기록 단계에 `무엇을/왜/어떻게`, 실제 실행 명령과 결과, 후속 수정 시 누적 기록 및 `정정` 추가 원칙이 명시되어 있는지 확인한다. +- [x] `/admin/partner/agent/list`가 별도 날짜 입력 없이 현재 월 기준 5종 정산 합계를 계산하도록 구현 계획에 반영되어 있는지 확인한다. +- [x] 에이전트 목록 응답 DTO와 리스트 조회 테스트에 `live/content/community/contentDonation/channelDonation` 5종 `agentSettlementAmount` summary 필드가 모두 반영되어 있는지 확인한다. +- [x] 해당 월에 정산 내역이 없는 에이전트도 목록에서 제외하지 않고 5종 합계를 `0`으로 표기하는 쿼리/응답/테스트 계획이 포함되어 있는지 확인한다. +- [x] 리스트 summary 값이 같은 월 기준 상세 조회 응답의 `total.agentSettlementAmount`와 일치하는지 검증하는 회귀 테스트가 구현 계획에 포함되어 있는지 확인한다. + +## 구현 시 유의사항 +- 관리자용 정산 API를 새로 만든다고 해서 계산 query를 복제하지 않는다. +- 현재 assignment 활성 판정은 시간창 규칙을 반드시 동일하게 사용한다. +- 정산 응답 계약은 기존 agent 응답과 불필요하게 분기하지 않는다. +- 목록 화면은 요약 정보만 제공하고, 상세 정산은 상세 화면에서만 조회한다. +- 목록의 5종 합계는 상세 API의 기간 필터와 별개로, 현재 월 기준 요약값이라는 의미를 문서와 코드에서 동일하게 유지한다. +- 해당 월에 정산 내역이 없더라도 에이전트 행은 유지하고 금액은 `0`으로 표기한다. + +## 최종 설계 결론 +- 이번 기능은 “새 정산 엔진 추가”가 아니라 “기존 agent 정산 계산을 ADMIN에서 조회할 수 있게 read API를 보강”하는 작업으로 본다. +- 관리자 페이지 흐름은 아래로 고정한다. + 1. `/admin/partner/agent/list`로 에이전트 목록과 현재 월 기준 5종 정산 합계 조회 + 2. 상세 화면에서 `/admin/partner/agent/{agentId}/creator/list`로 현재 소속 크리에이터 조회 + 3. 필요 시 `/admin/partner/agent/creator/search`로 크리에이터 검색 후 기존 assignment API로 소속 지정 + 4. 상세 화면에서 `/admin/partner/agent/{agentId}/calculate/*` 5종으로 정산 현황 조회 +- 목록의 5종 합계는 상세 페이지 이동 포인트용 현재 월 summary이며, 상세 화면의 날짜 범위 조회와 역할을 구분한다. +- 현재 월에 정산 내역이 없는 에이전트도 목록에 포함되며, 합계는 0원으로 표시한다. +- 이 설계를 기준으로 다음 단계에서는 구현 계획 문서를 작성한다. + +## 검증 기록 +- 1차 설계 + - 무엇을: 관리자용 에이전트 목록/상세 read API 구조와 기존 agent 계산 로직 재사용 범위를 문서로 고정했다. + - 왜: 현재 코드에는 관리자용 assignment/ratio/finalize는 있지만, 관리자용 agent 정산 상세 조회 API는 없어 read 레이어 설계가 먼저 필요했기 때문이다. + - 어떻게: + - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt` 확인 + - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt` 확인 + - `src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt` 확인 + - `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt` 확인 + - `src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt` 확인 + - `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberController.kt` 확인 + - 결과: 관리자용 read API 부재와 기존 계산 로직 재사용 가능성을 문서에 반영했다.