Compare commits
5 Commits
360678091c
...
3eda0abcfc
| Author | SHA1 | Date | |
|---|---|---|---|
| 3eda0abcfc | |||
| 9e6326f08a | |||
| 1853c28f14 | |||
| 3ca8b70a9f | |||
| e02a83b83e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
HELP.md
|
HELP.md
|
||||||
.gradle
|
.gradle
|
||||||
.envrc
|
.envrc
|
||||||
|
.omx/
|
||||||
build/
|
build/
|
||||||
!**/src/main/**/build/
|
!**/src/main/**/build/
|
||||||
!**/src/test/**/build/
|
!**/src/test/**/build/
|
||||||
|
|||||||
10
docs/20260406_omxgitignore.md
Normal file
10
docs/20260406_omxgitignore.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# `.omx/` Git 제외 처리
|
||||||
|
|
||||||
|
- [x] `.gitignore`에 `.omx/`를 추가한다.
|
||||||
|
- [x] `git status`와 `git check-ignore`로 제외가 정상 동작하는지 확인한다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
- 무엇을: `.gitignore`에 `.omx/`를 추가하고 `.omx/` 하위 파일들이 무시되는지 확인했다.
|
||||||
|
- 왜: `.omx/`는 런타임 상태, 로그, 메트릭 파일이라 버전 관리 대상이 아니기 때문이다.
|
||||||
|
- 어떻게: `git check-ignore -v .omx/tmux-hook.json .omx/state/hud-state.json .omx/logs/turns-2026-04-06.jsonl`로 무시 규칙을 확인했고, `git status --short`로 `.omx/`가 더 이상 추적 대상이 아니고 문서만 남는지 확인했다.
|
||||||
19
docs/20260407_audio_content_settlement_ratio_ddl.sql
Normal file
19
docs/20260407_audio_content_settlement_ratio_ddl.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
SET @schema_name := DATABASE();
|
||||||
|
|
||||||
|
SET @settlement_ratio_column_exists := (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = @schema_name
|
||||||
|
AND table_name = 'content'
|
||||||
|
AND column_name = 'settlement_ratio'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @add_settlement_ratio_column_sql := IF(
|
||||||
|
@settlement_ratio_column_exists = 0,
|
||||||
|
'ALTER TABLE content ADD COLUMN settlement_ratio INT NULL COMMENT ''콘텐츠별 정산 요율(%)'' AFTER price',
|
||||||
|
'SELECT ''content.settlement_ratio already exists'' AS message'
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE add_settlement_ratio_column_stmt FROM @add_settlement_ratio_column_sql;
|
||||||
|
EXECUTE add_settlement_ratio_column_stmt;
|
||||||
|
DEALLOCATE PREPARE add_settlement_ratio_column_stmt;
|
||||||
85
docs/20260407_콘텐츠별정산요율추가.md
Normal file
85
docs/20260407_콘텐츠별정산요율추가.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
- [x] 변수명 확정: 엔티티 내부 추가 변수는 `AudioContent.settlementRatio: Int?`로 사용한다.
|
||||||
|
- 이유: `AudioContent`는 이미 콘텐츠 도메인 엔티티이므로 `contentSettlementRatio`는 중복 표현에 가깝다.
|
||||||
|
- 근거: 이 저장소의 엔티티 필드는 `AudioContent.price`, `LiveRoom.price`, `CreatorCommunity.price`처럼 엔티티 스코프 안에서는 도메인 접두어를 반복하지 않는다.
|
||||||
|
- 예외 기준: `CreatorSettlementRatio.contentSettlementRatio`처럼 하나의 엔티티 안에서 `live/content/community` 여러 정산 대상을 함께 구분해야 할 때만 `content` 접두어가 필요하다.
|
||||||
|
- DTO/API 정책: 해당 값은 관리자에서만 사용하므로 관리자 요청/응답 DTO와 API 필드명도 예외 없이 `settlementRatio`로 통일한다.
|
||||||
|
- nullable 정책: 기존 데이터와 크리에이터 정산 요율 미등록 케이스를 안전하게 수용하기 위해 초기 도입 시 `Int?`로 두고, 계산 시 `콘텐츠별 요율 -> 크리에이터 기본 요율 -> 70% 기본값` 순서로 fallback 하도록 설계한다.
|
||||||
|
|
||||||
|
- [x] `AudioContent` 엔티티에 콘텐츠별 정산 요율 필드를 추가한다.
|
||||||
|
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt`
|
||||||
|
- 작업 내용: `price` 인접 위치에 `settlementRatio: Int?` 필드를 추가하고, 기존 생성자 호출부가 모두 컴파일되도록 생성 경로를 함께 정리한다.
|
||||||
|
|
||||||
|
- [x] 관리자 콘텐츠 목록 조회 응답에 콘텐츠별 정산 요율을 노출한다.
|
||||||
|
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt`
|
||||||
|
- 작업 내용:
|
||||||
|
- `QGetAdminContentListItem(...)` QueryProjection에 `audioContent.settlementRatio`를 추가한다.
|
||||||
|
- `GetAdminContentListItem`에 `settlementRatio: Int?`를 추가하고, 관리자 목록 응답 필드명도 동일하게 `settlementRatio`로 맞춘다.
|
||||||
|
- `AdminContentController.getAudioContentList` 응답에 정산 요율이 함께 내려가도록 조회 체인을 맞춘다.
|
||||||
|
|
||||||
|
- [x] 관리자 콘텐츠 수정 API에서 콘텐츠별 정산 요율을 수정할 수 있게 한다.
|
||||||
|
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt`
|
||||||
|
- 작업 내용:
|
||||||
|
- `UpdateAdminContentRequest`에 `settlementRatio: Int?`를 추가하고, 관리자 수정 요청 필드명도 동일하게 `settlementRatio`로 맞춘다.
|
||||||
|
- `AdminContentService.updateAudioContent`에서 요청값이 들어오면 `audioContent.settlementRatio`를 갱신한다.
|
||||||
|
- 숫자 범위 정책은 `0~100`으로 검증한다.
|
||||||
|
- 개별 콘텐츠 정산 요율 삭제는 `isSettlementRatioDeleted: true` 플래그로만 처리하고, `settlementRatio`와 동시 전달 시 invalid request 로 처리한다.
|
||||||
|
|
||||||
|
- [x] 실제 콘텐츠 정산 계산이 크리에이터 기본 요율이 아니라 콘텐츠별 요율을 우선 사용하도록 변경한다.
|
||||||
|
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateContentQueryData.kt`
|
||||||
|
- 작업 내용:
|
||||||
|
- 콘텐츠 판매/누적 판매 집계 쿼리에서 `creatorSettlementRatio.contentSettlementRatio` 직접 사용 부분을 점검한다.
|
||||||
|
- 정산 대상 비율은 `audioContent.settlementRatio`를 우선 사용하고, 값이 없을 때만 `creatorSettlementRatio.contentSettlementRatio`를 fallback 하도록 쿼리 또는 계산 DTO를 조정한다.
|
||||||
|
- 현재 `GetCalculateContentQueryData`의 70% 기본값 정책은 마지막 fallback 으로 유지한다.
|
||||||
|
|
||||||
|
- [x] 크리에이터 관리자 정산 조회도 동일 기준을 사용하도록 맞춘다.
|
||||||
|
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt`
|
||||||
|
- 작업 내용: 관리자 정산 조회와 동일하게 콘텐츠별 정산 요율 우선 정책을 반영해 관리자/크리에이터 화면 간 계산 기준이 달라지지 않게 한다.
|
||||||
|
|
||||||
|
- [x] 기존 데이터 처리 정책을 정리한다.
|
||||||
|
- 대상 범위: 운영 DB 스키마/기존 콘텐츠 데이터
|
||||||
|
- 작업 내용:
|
||||||
|
- 신규 컬럼 추가 시 nullable 로 도입하고, DDL은 `docs/20260407_audio_content_settlement_ratio_ddl.sql` 기준으로 관리한다.
|
||||||
|
- 콘텐츠 등록 시 크리에이터 기본 정산 요율을 복사하지 않고, `NULL` 상태에서도 계산이 가능하도록 fallback 순서를 유지한다.
|
||||||
|
- 운영 정책상 기존 콘텐츠에도 즉시 고정값이 필요하면 별도 SQL 또는 배치 백필 계획을 추가로 작성한다.
|
||||||
|
|
||||||
|
- [x] 영향 DTO/Q 클래스/컴파일 산출물을 재생성하고 검증한다.
|
||||||
|
- 작업 내용:
|
||||||
|
- QueryDSL projection 변경 후 Q 클래스 재생성이 필요한지 확인하고 빌드로 반영한다.
|
||||||
|
- 엔티티/관리자 DTO/API/QueryProjection 필드명이 모두 `settlementRatio`로 일치하는지 확인해 매핑 누락 가능성을 제거한다.
|
||||||
|
|
||||||
|
- [x] 검증을 단계별로 수행한다.
|
||||||
|
- 작업 내용:
|
||||||
|
- `AdminContentController.getAudioContentList`에서 정산 요율 조회 포함 여부를 확인한다.
|
||||||
|
- `AdminContentController.modifyAudioContent`에서 정산 요율 수정 반영 여부를 확인한다.
|
||||||
|
- `AdminContentController.modifyAudioContent`에서 `isSettlementRatioDeleted = true` 요청 시 개별 콘텐츠 정산 요율이 삭제되는지 확인한다.
|
||||||
|
- 콘텐츠 등록 시 `settlementRatio`가 nullable 상태로 유지되어도 계산 fallback 이 정상 동작하는지 확인한다.
|
||||||
|
- 콘텐츠 정산 조회(`AdminCalculateQueryRepository`, `CreatorAdminCalculateQueryRepository`)가 콘텐츠별 요율을 우선 적용하는지 확인한다.
|
||||||
|
- 실행 검증은 최소 `./gradlew build`, 필요 시 `./gradlew test`까지 수행한다.
|
||||||
|
|
||||||
|
## 1차 구현 검증 기록
|
||||||
|
|
||||||
|
- 무엇을: `AudioContent.settlementRatio` nullable 컬럼 추가, 관리자 목록/수정 API 반영, 콘텐츠/누적 정산 쿼리의 콘텐츠별 요율 우선 fallback 반영, 생성 시 기본 요율 복사 제거.
|
||||||
|
- 왜: 기존 콘텐츠와 미설정 콘텐츠를 `NULL`로 유지하면서도 관리자에서 개별 요율을 조회/수정하고 정산 시 올바른 fallback 순서를 적용하기 위해.
|
||||||
|
- 어떻게:
|
||||||
|
- `./gradlew build` → 성공
|
||||||
|
- `./gradlew test` → `build` 과정에 포함되어 성공
|
||||||
|
- 관리자/정산 API의 실서버 수동 호출 검증 → 이 로컬 작업 세션에서는 애플리케이션 실행 및 인증 가능한 테스트 데이터가 없어 미실행
|
||||||
|
|
||||||
|
## 2차 수정 검증 기록
|
||||||
|
|
||||||
|
- 무엇을: `@SpringBootTest` 없이 콘텐츠 정산 계산 DTO의 명시 비율 적용과 `null -> 70% fallback`을 검증하는 순수 단위 테스트를 추가했다.
|
||||||
|
- 왜: 정산 쿼리 변경만으로는 계산 결과가 코드상에서 충분히 고정되지 않아, 문서에 적힌 계산 규칙을 재현 가능한 테스트로 보장하기 위해.
|
||||||
|
- 어떻게:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` → 성공
|
||||||
|
- 검증 대상: `GetCalculateContentQueryData`, `GetCumulativeSalesByContentQueryData`
|
||||||
|
- 검증 시나리오: `settlementRatio = 80` 적용, `settlementRatio = null` 시 70% fallback 적용
|
||||||
|
|
||||||
|
## 3차 수정 검증 기록
|
||||||
|
|
||||||
|
- 무엇을: 관리자 콘텐츠 수정 API의 개별 콘텐츠 정산 요율 삭제 방식을 명시적 null 대신 `isSettlementRatioDeleted` 플래그로 전환한다.
|
||||||
|
- 왜: 부분 업데이트 요청에서 필드 생략과 null 삭제 의미가 섞이지 않도록 API 계약을 명확히 하기 위해.
|
||||||
|
- 어떻게:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.AdminContentServiceTest` → 성공
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` → 성공
|
||||||
|
- `./gradlew build` → 성공
|
||||||
|
- 검증 시나리오: 유효한 요율 설정, 삭제 플래그 삭제, 값/삭제 플래그 동시 전달 충돌, `null` 키/삭제 플래그 동시 전달 충돌, 범위 초과 거부, 정산 fallback 회귀 확인
|
||||||
@@ -86,6 +86,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val pointGroup = CaseBuilder()
|
val pointGroup = CaseBuilder()
|
||||||
.`when`(order.point.loe(0)).then(0)
|
.`when`(order.point.loe(0)).then(0)
|
||||||
.otherwise(1)
|
.otherwise(1)
|
||||||
|
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(audioContent.id)
|
.select(audioContent.id)
|
||||||
@@ -108,7 +109,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
orderFormattedDate,
|
orderFormattedDate,
|
||||||
order.can,
|
order.can,
|
||||||
pointGroup,
|
pointGroup,
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
contentSettlementRatio
|
||||||
)
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
@@ -124,6 +125,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val pointGroup = CaseBuilder()
|
val pointGroup = CaseBuilder()
|
||||||
.`when`(order.point.loe(0)).then(0)
|
.`when`(order.point.loe(0)).then(0)
|
||||||
.otherwise(1)
|
.otherwise(1)
|
||||||
|
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
@@ -137,7 +139,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
order.id.count(),
|
order.id.count(),
|
||||||
order.can.sum(),
|
order.can.sum(),
|
||||||
order.point.sum(),
|
order.point.sum(),
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
contentSettlementRatio
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(order)
|
.from(order)
|
||||||
@@ -159,7 +161,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
orderFormattedDate,
|
orderFormattedDate,
|
||||||
order.can,
|
order.can,
|
||||||
pointGroup,
|
pointGroup,
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
contentSettlementRatio
|
||||||
)
|
)
|
||||||
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
@@ -182,13 +184,23 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getCumulativeSalesByContentTotalCount(): Int {
|
fun getCumulativeSalesByContentTotalCount(): Int {
|
||||||
|
val pointGroup = CaseBuilder()
|
||||||
|
.`when`(order.point.loe(0)).then(0)
|
||||||
|
.otherwise(1)
|
||||||
|
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(audioContent.id)
|
.select(audioContent.id)
|
||||||
.from(order)
|
.from(order)
|
||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(order.isActive.isTrue)
|
.where(order.isActive.isTrue)
|
||||||
.groupBy(member.id, audioContent.id, order.can)
|
.groupBy(member.id, audioContent.id, order.type, order.can, pointGroup, contentSettlementRatio)
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
@@ -197,6 +209,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val pointGroup = CaseBuilder()
|
val pointGroup = CaseBuilder()
|
||||||
.`when`(order.point.loe(0)).then(0)
|
.`when`(order.point.loe(0)).then(0)
|
||||||
.otherwise(1)
|
.otherwise(1)
|
||||||
|
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
@@ -209,7 +222,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
order.id.count(),
|
order.id.count(),
|
||||||
order.can.sum(),
|
order.can.sum(),
|
||||||
order.point.sum(),
|
order.point.sum(),
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
contentSettlementRatio
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(order)
|
.from(order)
|
||||||
@@ -227,7 +240,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
order.type,
|
order.type,
|
||||||
order.can,
|
order.can,
|
||||||
pointGroup,
|
pointGroup,
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
contentSettlementRatio
|
||||||
)
|
)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class AdminAudioContentQueryRepositoryImpl(
|
|||||||
audioContentTheme.theme,
|
audioContentTheme.theme,
|
||||||
audioContentTheme.id,
|
audioContentTheme.id,
|
||||||
audioContent.price,
|
audioContent.price,
|
||||||
|
audioContent.settlementRatio,
|
||||||
audioContent.limited,
|
audioContent.limited,
|
||||||
audioContent.remaining,
|
audioContent.remaining,
|
||||||
audioContent.isAdult,
|
audioContent.isAdult,
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ class AdminContentService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateAudioContent(coverImage: MultipartFile?, requestString: String) {
|
fun updateAudioContent(coverImage: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateAdminContentRequest::class.java)
|
val requestNode = objectMapper.readTree(requestString)
|
||||||
|
val request = objectMapper.treeToValue(requestNode, UpdateAdminContentRequest::class.java)
|
||||||
val audioContent = repository.findByIdOrNull(id = request.id)
|
val audioContent = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException(messageKey = "admin.content.not_found")
|
?: throw SodaException(messageKey = "admin.content.not_found")
|
||||||
|
|
||||||
@@ -145,6 +146,18 @@ class AdminContentService(
|
|||||||
val theme = themeRepository.findByIdAndActive(id = request.themeId)
|
val theme = themeRepository.findByIdAndActive(id = request.themeId)
|
||||||
audioContent.theme = theme
|
audioContent.theme = theme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.isSettlementRatioDeleted == true) {
|
||||||
|
if (requestNode.has("settlementRatio")) {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
audioContent.settlementRatio = null
|
||||||
|
} else if (request.settlementRatio != null) {
|
||||||
|
if (request.settlementRatio !in 0..100) {
|
||||||
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
audioContent.settlementRatio = request.settlementRatio
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getContentMainTabList(): List<GetContentMainTabItem> {
|
fun getContentMainTabList(): List<GetContentMainTabItem> {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ data class GetAdminContentListItem @QueryProjection constructor(
|
|||||||
val theme: String,
|
val theme: String,
|
||||||
val themeId: Long,
|
val themeId: Long,
|
||||||
val price: Int,
|
val price: Int,
|
||||||
|
val settlementRatio: Int?,
|
||||||
val totalContentCount: Int?,
|
val totalContentCount: Int?,
|
||||||
val remainingContentCount: Int?,
|
val remainingContentCount: Int?,
|
||||||
val isAdult: Boolean,
|
val isAdult: Boolean,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ data class UpdateAdminContentRequest(
|
|||||||
val detail: String?,
|
val detail: String?,
|
||||||
val curationId: Long?,
|
val curationId: Long?,
|
||||||
val themeId: Long?,
|
val themeId: Long?,
|
||||||
|
val settlementRatio: Int?,
|
||||||
|
val isSettlementRatioDeleted: Boolean?,
|
||||||
val isAdult: Boolean?,
|
val isAdult: Boolean?,
|
||||||
val isActive: Boolean?,
|
val isActive: Boolean?,
|
||||||
val isCommentAvailable: Boolean?
|
val isCommentAvailable: Boolean?
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ data class AudioContent(
|
|||||||
var languageCode: String?,
|
var languageCode: String?,
|
||||||
var playCount: Long = 0,
|
var playCount: Long = 0,
|
||||||
var price: Int = 0,
|
var price: Int = 0,
|
||||||
|
var settlementRatio: Int? = null,
|
||||||
var releaseDate: LocalDateTime? = null,
|
var releaseDate: LocalDateTime? = null,
|
||||||
val limited: Int? = null,
|
val limited: Int? = null,
|
||||||
var remaining: Int? = null,
|
var remaining: Int? = null,
|
||||||
|
|||||||
@@ -74,18 +74,28 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
memberId: Long
|
memberId: Long
|
||||||
): Int {
|
): Int {
|
||||||
val orderFormattedDate = getFormattedDate(order.createdAt)
|
val orderFormattedDate = getFormattedDate(order.createdAt)
|
||||||
|
val pointGroup = CaseBuilder()
|
||||||
|
.`when`(order.point.loe(0)).then(0)
|
||||||
|
.otherwise(1)
|
||||||
|
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(audioContent.id)
|
.select(audioContent.id)
|
||||||
.from(order)
|
.from(order)
|
||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
.and(order.isActive.isTrue)
|
.and(order.isActive.isTrue)
|
||||||
.and(order.creator.id.eq(memberId))
|
.and(order.creator.id.eq(memberId))
|
||||||
)
|
)
|
||||||
.groupBy(audioContent.id, order.type, orderFormattedDate, order.can)
|
.groupBy(audioContent.id, order.type, orderFormattedDate, order.can, pointGroup, contentSettlementRatio)
|
||||||
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
@@ -102,6 +112,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
val pointGroup = CaseBuilder()
|
val pointGroup = CaseBuilder()
|
||||||
.`when`(order.point.loe(0)).then(0)
|
.`when`(order.point.loe(0)).then(0)
|
||||||
.otherwise(1)
|
.otherwise(1)
|
||||||
|
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
@@ -115,7 +126,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
order.id.count(),
|
order.id.count(),
|
||||||
order.can.sum(),
|
order.can.sum(),
|
||||||
order.point.sum(),
|
order.point.sum(),
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
contentSettlementRatio
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(order)
|
.from(order)
|
||||||
@@ -138,7 +149,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
orderFormattedDate,
|
orderFormattedDate,
|
||||||
order.can,
|
order.can,
|
||||||
pointGroup,
|
pointGroup,
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
contentSettlementRatio
|
||||||
)
|
)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -161,16 +172,26 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getCumulativeSalesByContentTotalCount(memberId: Long): Int {
|
fun getCumulativeSalesByContentTotalCount(memberId: Long): Int {
|
||||||
|
val pointGroup = CaseBuilder()
|
||||||
|
.`when`(order.point.loe(0)).then(0)
|
||||||
|
.otherwise(1)
|
||||||
|
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(audioContent.id)
|
.select(audioContent.id)
|
||||||
.from(order)
|
.from(order)
|
||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
|
.leftJoin(creatorSettlementRatio)
|
||||||
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
audioContent.member.id.eq(memberId)
|
audioContent.member.id.eq(memberId)
|
||||||
.and(order.isActive.isTrue)
|
.and(order.isActive.isTrue)
|
||||||
)
|
)
|
||||||
.groupBy(member.id, audioContent.id, order.can)
|
.groupBy(member.id, audioContent.id, order.type, order.can, pointGroup, contentSettlementRatio)
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
@@ -183,6 +204,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
val pointGroup = CaseBuilder()
|
val pointGroup = CaseBuilder()
|
||||||
.`when`(order.point.loe(0)).then(0)
|
.`when`(order.point.loe(0)).then(0)
|
||||||
.otherwise(1)
|
.otherwise(1)
|
||||||
|
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
@@ -195,7 +217,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
order.id.count(),
|
order.id.count(),
|
||||||
order.can.sum(),
|
order.can.sum(),
|
||||||
order.point.sum(),
|
order.point.sum(),
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
contentSettlementRatio
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(order)
|
.from(order)
|
||||||
@@ -216,7 +238,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
order.type,
|
order.type,
|
||||||
order.can,
|
order.can,
|
||||||
pointGroup,
|
pointGroup,
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
contentSettlementRatio
|
||||||
)
|
)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ContentSettlementCalculationTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("콘텐츠 정산 응답은 콘텐츠별 정산 요율을 우선 적용한다")
|
||||||
|
fun shouldApplyExplicitSettlementRatioForCalculateContentResponse() {
|
||||||
|
val result = GetCalculateContentQueryData(
|
||||||
|
nickname = "creator",
|
||||||
|
title = "content",
|
||||||
|
registrationDate = "2026-04-07",
|
||||||
|
saleDate = "2026-04-07",
|
||||||
|
orderType = OrderType.KEEP,
|
||||||
|
orderPrice = 100,
|
||||||
|
numberOfPeople = 2,
|
||||||
|
totalCan = 100,
|
||||||
|
totalPoint = 0,
|
||||||
|
settlementRatio = 80
|
||||||
|
).toGetCalculateContentResponse()
|
||||||
|
|
||||||
|
assertEquals("소장", result.orderType)
|
||||||
|
assertEquals(10_000, result.totalKrw)
|
||||||
|
assertEquals(660, result.paymentFee)
|
||||||
|
assertEquals(7_472, result.settlementAmount)
|
||||||
|
assertEquals(247, result.tax)
|
||||||
|
assertEquals(7_225, result.depositAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("콘텐츠 정산 응답은 정산 요율이 없으면 70퍼센트 기본값으로 계산한다")
|
||||||
|
fun shouldFallbackToDefaultSettlementRatioForCalculateContentResponse() {
|
||||||
|
val result = GetCalculateContentQueryData(
|
||||||
|
nickname = "creator",
|
||||||
|
title = "content",
|
||||||
|
registrationDate = "2026-04-07",
|
||||||
|
saleDate = "2026-04-07",
|
||||||
|
orderType = OrderType.RENTAL,
|
||||||
|
orderPrice = 70,
|
||||||
|
numberOfPeople = 1,
|
||||||
|
totalCan = 100,
|
||||||
|
totalPoint = 0,
|
||||||
|
settlementRatio = null
|
||||||
|
).toGetCalculateContentResponse()
|
||||||
|
|
||||||
|
assertEquals("대여", result.orderType)
|
||||||
|
assertEquals(10_000, result.totalKrw)
|
||||||
|
assertEquals(660, result.paymentFee)
|
||||||
|
assertEquals(6_538, result.settlementAmount)
|
||||||
|
assertEquals(216, result.tax)
|
||||||
|
assertEquals(6_322, result.depositAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("누적 콘텐츠 정산 응답은 콘텐츠별 정산 요율을 우선 적용한다")
|
||||||
|
fun shouldApplyExplicitSettlementRatioForCumulativeSalesResponse() {
|
||||||
|
val result = GetCumulativeSalesByContentQueryData(
|
||||||
|
nickname = "creator",
|
||||||
|
title = "content",
|
||||||
|
registrationDate = "2026-04-07",
|
||||||
|
orderType = OrderType.KEEP,
|
||||||
|
orderPrice = 100,
|
||||||
|
numberOfPeople = 2,
|
||||||
|
totalCan = 100,
|
||||||
|
totalPoint = 0,
|
||||||
|
settlementRatio = 80
|
||||||
|
).toCumulativeSalesByContentItem()
|
||||||
|
|
||||||
|
assertEquals("소장", result.orderType)
|
||||||
|
assertEquals(10_000, result.totalKrw)
|
||||||
|
assertEquals(660, result.paymentFee)
|
||||||
|
assertEquals(7_472, result.settlementAmount)
|
||||||
|
assertEquals(247, result.tax)
|
||||||
|
assertEquals(7_225, result.depositAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("누적 콘텐츠 정산 응답은 정산 요율이 없으면 70퍼센트 기본값으로 계산한다")
|
||||||
|
fun shouldFallbackToDefaultSettlementRatioForCumulativeSalesResponse() {
|
||||||
|
val result = GetCumulativeSalesByContentQueryData(
|
||||||
|
nickname = "creator",
|
||||||
|
title = "content",
|
||||||
|
registrationDate = "2026-04-07",
|
||||||
|
orderType = OrderType.RENTAL,
|
||||||
|
orderPrice = 70,
|
||||||
|
numberOfPeople = 1,
|
||||||
|
totalCan = 100,
|
||||||
|
totalPoint = 0,
|
||||||
|
settlementRatio = null
|
||||||
|
).toCumulativeSalesByContentItem()
|
||||||
|
|
||||||
|
assertEquals("대여", result.orderType)
|
||||||
|
assertEquals(10_000, result.totalKrw)
|
||||||
|
assertEquals(660, result.paymentFee)
|
||||||
|
assertEquals(6_538, result.settlementAmount)
|
||||||
|
assertEquals(216, result.tax)
|
||||||
|
assertEquals(6_322, result.depositAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.curation.AdminContentCurationRepository
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.theme.AdminContentThemeRepository
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
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.util.Optional
|
||||||
|
|
||||||
|
class AdminContentServiceTest {
|
||||||
|
private lateinit var repository: AdminContentRepository
|
||||||
|
private lateinit var themeRepository: AdminContentThemeRepository
|
||||||
|
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||||
|
private lateinit var curationRepository: AdminContentCurationRepository
|
||||||
|
private lateinit var contentMainTabRepository: AdminContentMainTabRepository
|
||||||
|
private lateinit var s3Uploader: S3Uploader
|
||||||
|
private lateinit var service: AdminContentService
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
repository = Mockito.mock(AdminContentRepository::class.java)
|
||||||
|
themeRepository = Mockito.mock(AdminContentThemeRepository::class.java)
|
||||||
|
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||||
|
curationRepository = Mockito.mock(AdminContentCurationRepository::class.java)
|
||||||
|
contentMainTabRepository = Mockito.mock(AdminContentMainTabRepository::class.java)
|
||||||
|
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||||
|
|
||||||
|
service = AdminContentService(
|
||||||
|
repository = repository,
|
||||||
|
themeRepository = themeRepository,
|
||||||
|
audioContentCloudFront = audioContentCloudFront,
|
||||||
|
curationRepository = curationRepository,
|
||||||
|
contentMainTabRepository = contentMainTabRepository,
|
||||||
|
objectMapper = jacksonObjectMapper(),
|
||||||
|
s3Uploader = s3Uploader,
|
||||||
|
bucket = "test-bucket"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 콘텐츠 수정은 유효한 개별 정산 요율을 저장한다")
|
||||||
|
fun shouldUpdateSettlementRatioWhenValidValueIsProvided() {
|
||||||
|
val audioContent = createAudioContent(settlementRatio = 70)
|
||||||
|
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
|
||||||
|
|
||||||
|
service.updateAudioContent(
|
||||||
|
coverImage = null,
|
||||||
|
requestString = """
|
||||||
|
{"id":1,"isDefaultCoverImage":false,"settlementRatio":80}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(80, audioContent.settlementRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그로 개별 정산 요율을 삭제한다")
|
||||||
|
fun shouldDeleteSettlementRatioWhenDeleteFlagIsTrue() {
|
||||||
|
val audioContent = createAudioContent(settlementRatio = 70)
|
||||||
|
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
|
||||||
|
|
||||||
|
service.updateAudioContent(
|
||||||
|
coverImage = null,
|
||||||
|
requestString = """
|
||||||
|
{"id":1,"isDefaultCoverImage":false,"isSettlementRatioDeleted":true}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(audioContent.settlementRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그와 정산 요율을 함께 보내면 예외를 던진다")
|
||||||
|
fun shouldThrowWhenDeleteFlagAndSettlementRatioAreProvidedTogether() {
|
||||||
|
val audioContent = createAudioContent(settlementRatio = 70)
|
||||||
|
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.updateAudioContent(
|
||||||
|
coverImage = null,
|
||||||
|
requestString = """
|
||||||
|
{"id":1,"isDefaultCoverImage":false,"settlementRatio":80,"isSettlementRatioDeleted":true}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
assertEquals(70, audioContent.settlementRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그와 null 정산 요율 키를 함께 보내도 예외를 던진다")
|
||||||
|
fun shouldThrowWhenDeleteFlagAndNullSettlementRatioKeyAreProvidedTogether() {
|
||||||
|
val audioContent = createAudioContent(settlementRatio = 70)
|
||||||
|
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.updateAudioContent(
|
||||||
|
coverImage = null,
|
||||||
|
requestString = """
|
||||||
|
{"id":1,"isDefaultCoverImage":false,"settlementRatio":null,"isSettlementRatioDeleted":true}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
assertEquals(70, audioContent.settlementRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 콘텐츠 수정은 범위를 벗어난 정산 요율이면 예외를 던진다")
|
||||||
|
fun shouldThrowWhenSettlementRatioIsOutOfRange() {
|
||||||
|
val audioContent = createAudioContent(settlementRatio = 70)
|
||||||
|
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.updateAudioContent(
|
||||||
|
coverImage = null,
|
||||||
|
requestString = """
|
||||||
|
{"id":1,"isDefaultCoverImage":false,"settlementRatio":101}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
assertEquals(70, audioContent.settlementRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그와 정산 요율이 없으면 기존 값을 유지한다")
|
||||||
|
fun shouldKeepSettlementRatioWhenNoSettlementRatioFieldsAreProvided() {
|
||||||
|
val audioContent = createAudioContent(settlementRatio = 70)
|
||||||
|
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
|
||||||
|
|
||||||
|
service.updateAudioContent(
|
||||||
|
coverImage = null,
|
||||||
|
requestString = """
|
||||||
|
{"id":1,"isDefaultCoverImage":false}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(70, audioContent.settlementRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAudioContent(settlementRatio: Int?): AudioContent {
|
||||||
|
return AudioContent(
|
||||||
|
title = "title",
|
||||||
|
detail = "detail",
|
||||||
|
languageCode = "ko",
|
||||||
|
price = 100,
|
||||||
|
settlementRatio = settlementRatio
|
||||||
|
).apply {
|
||||||
|
id = 1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user