1638 lines
71 KiB
Markdown
1638 lines
71 KiB
Markdown
# 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 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<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): 에이전트 목록 현재 월 정산 요약을 반영한다"
|
||
```
|