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

1638 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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