Compare commits

...

11 Commits

Author SHA1 Message Date
59ca353b25 feat(calculate-ratio): 정산 비율 삭제 URL 수정 2025-09-22 14:17:18 +09:00
6bc65ec412 feat(calculate-ratio): 정산 비율 조회 - 결과에 memberId 추가 2025-09-22 14:01:28 +09:00
97e95b51ab feat(calculate-ratio): 정산 비율 수정/삭제(소프트 삭제)와 업서트, 쿼리 보강
- deletedAt 기반 소프트 삭제 도입 및 restore/updateValues 추가
- 생성 시 기존(삭제 포함) 레코드 복구 후 값 갱신(업서트)
- /admin/calculate/ratio/update, /delete 엔드포인트 추가
- 정산 쿼리 조인에 deletedAt.isNull 적용하여 삭제 데이터 배제
- 목록/카운트 조회에서도 삭제 데이터 제외
2025-09-22 13:36:36 +09:00
a6dfa81ba6 사용하지 않는 '지정 원작에 속한 활성 캐릭터 목록 조회 API' 제거 2025-09-18 22:49:35 +09:00
dad517a953 feat(admin-character-list): 캐릭터 검색결과
- 캐릭터 목록과 동일한 내용으로 변경
2025-09-18 19:58:12 +09:00
eb2d093b02 feat(admin-character-list): 캐릭터 검색에 페이징 추가 2025-09-18 19:29:34 +09:00
67186bba55 feat(original): 원작
- 원천 원작, 원천 원작 링크, 글/그림 작가, 제작사, 태그 추가
2025-09-18 18:04:59 +09:00
edeecad2ce feat(original-app): 원작 리스트
- 페이징 추가
2025-09-15 16:00:09 +09:00
387f5388d9 feat(original-app): 원작 상세, 캐릭터 리스트
- 원작 상세에 캐릭터 20개 조회
- 지정 원작에 속한 활성 캐릭터 목록 조회 API 추가
2025-09-15 15:32:20 +09:00
adcaa0a5fd fix(original): 캐릭터 수정
- 원작 ID가 0이 들어오면 캐릭터의 원작을 null로 처리한다.
2025-09-15 06:43:40 +09:00
47b2c1cb93 fix(original): 캐릭터 수정
- 원작 ID가 0이 들어오면 캐릭터의 원작을 null로 처리한다.
2025-09-15 06:17:55 +09:00
24 changed files with 425 additions and 101 deletions

View File

@@ -39,7 +39,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom) .innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member) .innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate)) .and(useCan.createdAt.goe(startDate))
@@ -75,7 +78,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent) .innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .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))
@@ -142,7 +148,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent) .innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(order.isActive.isTrue) .where(order.isActive.isTrue)
.groupBy( .groupBy(
member.id, member.id,
@@ -230,7 +239,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member) .innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
@@ -251,7 +263,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom) .innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member) .innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate)) .and(useCan.createdAt.goe(startDate))
@@ -281,7 +296,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.room, liveRoom) .innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member) .innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate)) .and(useCan.createdAt.goe(startDate))
@@ -301,7 +319,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent) .innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .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))
@@ -331,7 +352,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(order.audioContent, audioContent) .innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .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))
@@ -351,7 +375,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member) .innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
@@ -382,7 +409,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
.innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member) .innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.FetchType import javax.persistence.FetchType
import javax.persistence.JoinColumn import javax.persistence.JoinColumn
@@ -9,12 +10,29 @@ import javax.persistence.OneToOne
@Entity @Entity
data class CreatorSettlementRatio( data class CreatorSettlementRatio(
val subsidy: Int, var subsidy: Int,
val liveSettlementRatio: Int, var liveSettlementRatio: Int,
val contentSettlementRatio: Int, var contentSettlementRatio: Int,
val communitySettlementRatio: Int var communitySettlementRatio: Int
) : BaseEntity() { ) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY) @OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false) @JoinColumn(name = "member_id", nullable = false)
var member: Member? = null var member: Member? = null
var deletedAt: LocalDateTime? = null
fun softDelete() {
this.deletedAt = LocalDateTime.now()
}
fun restore() {
this.deletedAt = null
}
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
this.subsidy = subsidy
this.liveSettlementRatio = live
this.contentSettlementRatio = content
this.communitySettlementRatio = community
}
} }

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
@@ -27,4 +28,14 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
limit = pageable.pageSize.toLong() limit = pageable.pageSize.toLong()
) )
) )
@PostMapping("/update")
fun updateCreatorSettlementRatio(
@RequestBody request: CreateCreatorSettlementRatioRequest
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
@PostMapping("/delete/{memberId}")
fun deleteCreatorSettlementRatio(
@PathVariable memberId: Long
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
} }

View File

@@ -7,7 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository
interface CreatorSettlementRatioRepository : interface CreatorSettlementRatioRepository :
JpaRepository<CreatorSettlementRatio, Long>, JpaRepository<CreatorSettlementRatio, Long>,
CreatorSettlementRatioQueryRepository CreatorSettlementRatioQueryRepository {
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
}
interface CreatorSettlementRatioQueryRepository { interface CreatorSettlementRatioQueryRepository {
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem> fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
@@ -21,6 +23,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory return queryFactory
.select( .select(
QGetCreatorSettlementRatioItem( QGetCreatorSettlementRatioItem(
member.id,
member.nickname, member.nickname,
creatorSettlementRatio.subsidy, creatorSettlementRatio.subsidy,
creatorSettlementRatio.liveSettlementRatio, creatorSettlementRatio.liveSettlementRatio,
@@ -30,6 +33,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
) )
.from(creatorSettlementRatio) .from(creatorSettlementRatio)
.innerJoin(creatorSettlementRatio.member, member) .innerJoin(creatorSettlementRatio.member, member)
.where(creatorSettlementRatio.deletedAt.isNull)
.orderBy(creatorSettlementRatio.id.asc()) .orderBy(creatorSettlementRatio.id.asc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@@ -40,6 +44,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
return queryFactory return queryFactory
.select(creatorSettlementRatio.id) .select(creatorSettlementRatio.id)
.from(creatorSettlementRatio) .from(creatorSettlementRatio)
.where(creatorSettlementRatio.deletedAt.isNull)
.fetch() .fetch()
.size .size
} }

View File

@@ -14,8 +14,6 @@ class CreatorSettlementRatioService(
) { ) {
@Transactional @Transactional
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
val creatorSettlementRatio = request.toEntity()
val creator = memberRepository.findByIdOrNull(request.memberId) val creator = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 크리에이터 입니다.") ?: throw SodaException("잘못된 크리에이터 입니다.")
@@ -23,10 +21,52 @@ class CreatorSettlementRatioService(
throw SodaException("잘못된 크리에이터 입니다.") throw SodaException("잘못된 크리에이터 입니다.")
} }
val existing = repository.findByMemberId(request.memberId)
if (existing != null) {
// revive if soft-deleted, then update values
existing.restore()
existing.updateValues(
request.subsidy,
request.liveSettlementRatio,
request.contentSettlementRatio,
request.communitySettlementRatio
)
repository.save(existing)
return
}
val creatorSettlementRatio = request.toEntity()
creatorSettlementRatio.member = creator creatorSettlementRatio.member = creator
repository.save(creatorSettlementRatio) repository.save(creatorSettlementRatio)
} }
@Transactional
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
val creator = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 크리에이터 입니다.")
if (creator.role != MemberRole.CREATOR) {
throw SodaException("잘못된 크리에이터 입니다.")
}
val existing = repository.findByMemberId(request.memberId)
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
existing.restore()
existing.updateValues(
request.subsidy,
request.liveSettlementRatio,
request.contentSettlementRatio,
request.communitySettlementRatio
)
repository.save(existing)
}
@Transactional
fun deleteCreatorSettlementRatio(memberId: Long) {
val existing = repository.findByMemberId(memberId)
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
existing.softDelete()
repository.save(existing)
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse { fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
val totalCount = repository.getCreatorSettlementRatioTotalCount() val totalCount = repository.getCreatorSettlementRatioTotalCount()

View File

@@ -8,6 +8,7 @@ data class GetCreatorSettlementRatioResponse(
) )
data class GetCreatorSettlementRatioItem @QueryProjection constructor( data class GetCreatorSettlementRatioItem @QueryProjection constructor(
val memberId: Long,
val nickname: String, val nickname: String,
val subsidy: Int, val subsidy: Int,
val liveSettlementRatio: Int, val liveSettlementRatio: Int,

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.chat.character
import com.amazonaws.services.s3.model.ObjectMetadata import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
@@ -73,14 +74,21 @@ class AdminChatCharacterController(
/** /**
* 캐릭터 검색(관리자) * 캐릭터 검색(관리자)
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상 * - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
* - 페이징 제거: 전체 목록 반환 * - 페이징 지원: page, size 파라미터 사용
*/ */
@GetMapping("/search") @GetMapping("/search")
fun searchCharacters( fun searchCharacters(
@RequestParam("searchTerm") searchTerm: String @RequestParam("searchTerm") searchTerm: String,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run { ) = run {
val list = adminService.searchCharactersAll(searchTerm, imageHost) val pageable = adminService.createDefaultPageRequest(page, size)
ApiResponse.ok(list) val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
val response = ChatCharacterSearchListPageResponse(
totalCount = resultPage.totalElements,
content = resultPage.content
)
ApiResponse.ok(response)
} }
/** /**

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.admin.chat.character.dto
/**
* 캐릭터 검색 결과 페이지 응답 DTO
*/
data class ChatCharacterSearchListPageResponse(
val totalCount: Long,
val content: List<ChatCharacterListResponse>
)

View File

@@ -3,16 +3,16 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.ChatCharacter
/** /**
* 캐릭터 검색 결과 응답 DTO * 원작 연결된 캐릭터 결과 응답 DTO
*/ */
data class ChatCharacterSearchResponse( data class OriginalWorkChatCharacterResponse(
val id: Long, val id: Long,
val name: String, val name: String,
val imagePath: String? val imagePath: String?
) { ) {
companion object { companion object {
fun from(character: ChatCharacter, imageHost: String): ChatCharacterSearchResponse { fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
return ChatCharacterSearchResponse( return OriginalWorkChatCharacterResponse(
id = character.id!!, id = character.id!!,
name = character.name, name = character.name,
imagePath = character.imagePath?.let { "$imageHost/$it" } imagePath = character.imagePath?.let { "$imageHost/$it" }
@@ -22,9 +22,9 @@ data class ChatCharacterSearchResponse(
} }
/** /**
* 캐릭터 검색 결과 페이지 응답 DTO * 원작 연결된 캐릭터 결과 페이지 응답 DTO
*/ */
data class ChatCharacterSearchListPageResponse( data class OriginalWorkChatCharacterListPageResponse(
val totalCount: Long, val totalCount: Long,
val content: List<ChatCharacterSearchResponse> val content: List<OriginalWorkChatCharacterResponse>
) )

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.admin.chat.character.service
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchResponse
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
@@ -72,24 +71,8 @@ class AdminChatCharacterService(
searchTerm: String, searchTerm: String,
pageable: Pageable, pageable: Pageable,
imageHost: String = "" imageHost: String = ""
): Page<ChatCharacterSearchResponse> { ): Page<ChatCharacterListResponse> {
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable) val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
return characters.map { ChatCharacterSearchResponse.from(it, imageHost) } return characters.map { ChatCharacterListResponse.from(it, imageHost) }
}
/**
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 무페이징
*
* @param searchTerm 검색어
* @param imageHost 이미지 호스트 URL
* @return 검색된 캐릭터 목록 (전체)
*/
@Transactional(readOnly = true)
fun searchCharactersAll(
searchTerm: String,
imageHost: String = ""
): List<ChatCharacterSearchResponse> {
val characters = chatCharacterRepository.searchCharactersNoPaging(searchTerm)
return characters.map { ChatCharacterSearchResponse.from(it, imageHost) }
} }
} }

View File

@@ -2,8 +2,8 @@ package kr.co.vividnext.sodalive.admin.chat.original
import com.amazonaws.services.s3.model.ObjectMetadata import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
@@ -172,9 +172,9 @@ class AdminOriginalWorkController(
@RequestParam(defaultValue = "20") size: Int @RequestParam(defaultValue = "20") size: Int
) = run { ) = run {
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size) val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
val content = pageRes.content.map { ChatCharacterSearchResponse.from(it, imageHost) } val content = pageRes.content.map { OriginalWorkChatCharacterResponse.from(it, imageHost) }
ApiResponse.ok( ApiResponse.ok(
ChatCharacterSearchListPageResponse( OriginalWorkChatCharacterListPageResponse(
totalCount = pageRes.totalElements, totalCount = pageRes.totalElements,
content = content content = content
) )

View File

@@ -12,7 +12,12 @@ data class OriginalWorkRegisterRequest(
@JsonProperty("category") val category: String, @JsonProperty("category") val category: String,
@JsonProperty("isAdult") val isAdult: Boolean = false, @JsonProperty("isAdult") val isAdult: Boolean = false,
@JsonProperty("description") val description: String = "", @JsonProperty("description") val description: String = "",
@JsonProperty("originalLink") val originalLink: String? = null @JsonProperty("originalWork") val originalWork: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("writer") val writer: String? = null,
@JsonProperty("studio") val studio: String? = null,
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
@JsonProperty("tags") val tags: List<String>? = null
) )
/** /**
@@ -25,7 +30,12 @@ data class OriginalWorkUpdateRequest(
@JsonProperty("category") val category: String? = null, @JsonProperty("category") val category: String? = null,
@JsonProperty("isAdult") val isAdult: Boolean? = null, @JsonProperty("isAdult") val isAdult: Boolean? = null,
@JsonProperty("description") val description: String? = null, @JsonProperty("description") val description: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null @JsonProperty("originalWork") val originalWork: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("writer") val writer: String? = null,
@JsonProperty("studio") val studio: String? = null,
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
@JsonProperty("tags") val tags: List<String>? = null
) )
/** /**
@@ -38,7 +48,12 @@ data class OriginalWorkResponse(
val category: String, val category: String,
val isAdult: Boolean, val isAdult: Boolean,
val description: String, val description: String,
val originalWork: String?,
val originalLink: String?, val originalLink: String?,
val writer: String?,
val studio: String?,
val originalLinks: List<String>,
val tags: List<String>,
val imageUrl: String? val imageUrl: String?
) { ) {
companion object { companion object {
@@ -55,7 +70,12 @@ data class OriginalWorkResponse(
category = entity.category, category = entity.category,
isAdult = entity.isAdult, isAdult = entity.isAdult,
description = entity.description, description = entity.description,
originalWork = entity.originalWork,
originalLink = entity.originalLink, originalLink = entity.originalLink,
writer = entity.writer,
studio = entity.studio,
originalLinks = entity.originalLinks.map { it.url },
tags = entity.tagMappings.map { it.tag.tag },
imageUrl = fullImagePath imageUrl = fullImagePath
) )
} }

View File

@@ -6,6 +6,9 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWork import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
@@ -20,7 +23,8 @@ import org.springframework.transaction.annotation.Transactional
@Service @Service
class AdminOriginalWorkService( class AdminOriginalWorkService(
private val originalWorkRepository: OriginalWorkRepository, private val originalWorkRepository: OriginalWorkRepository,
private val chatCharacterRepository: ChatCharacterRepository private val chatCharacterRepository: ChatCharacterRepository,
private val originalWorkTagRepository: OriginalWorkTagRepository
) { ) {
/** 원작 등록 (중복 제목 방지 포함) */ /** 원작 등록 (중복 제목 방지 포함) */
@@ -35,8 +39,23 @@ class AdminOriginalWorkService(
category = request.category, category = request.category,
isAdult = request.isAdult, isAdult = request.isAdult,
description = request.description, description = request.description,
originalLink = request.originalLink originalWork = request.originalWork,
originalLink = request.originalLink,
writer = request.writer,
studio = request.studio
) )
// 링크 리스트 생성
request.originalLinks?.filter { it.isNotBlank() }?.forEach { link ->
entity.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = entity))
}
// 태그 매핑 생성 (기존 태그 재사용)
request.tags?.let { tags ->
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
normalized.forEach { t ->
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
}
}
return originalWorkRepository.save(entity) return originalWorkRepository.save(entity)
} }
@@ -51,7 +70,40 @@ class AdminOriginalWorkService(
request.category?.let { ow.category = it } request.category?.let { ow.category = it }
request.isAdult?.let { ow.isAdult = it } request.isAdult?.let { ow.isAdult = it }
request.description?.let { ow.description = it } request.description?.let { ow.description = it }
request.originalWork?.let { ow.originalWork = it }
request.originalLink?.let { ow.originalLink = it } request.originalLink?.let { ow.originalLink = it }
request.writer?.let { ow.writer = it }
request.studio?.let { ow.studio = it }
// 링크 리스트가 전달되면 기존 것을 교체
request.originalLinks?.let { links ->
ow.originalLinks.clear()
links.filter { it.isNotBlank() }.forEach { link ->
ow.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = ow))
}
}
// 태그 변경사항만 반영 (요청이 null이면 변경 없음)
request.tags?.let { tags ->
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
val current = ow.tagMappings.map { it.tag.tag }.toSet()
val toAdd = normalized.minus(current)
val toRemove = current.minus(normalized)
if (toRemove.isNotEmpty()) {
val itr = ow.tagMappings.iterator()
while (itr.hasNext()) {
val m = itr.next()
if (toRemove.contains(m.tag.tag)) {
itr.remove() // orphanRemoval=true로 매핑 삭제
}
}
}
if (toAdd.isNotEmpty()) {
toAdd.forEach { t ->
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
ow.tagMappings.add(OriginalWorkTagMapping(originalWork = ow, tag = tagEntity))
}
}
}
if (imagePath != null) { if (imagePath != null) {
ow.imagePath = imagePath ow.imagePath = imagePath
} }
@@ -145,11 +197,17 @@ class AdminOriginalWorkService(
/** 단일 캐릭터를 지정 원작에 배정 */ /** 단일 캐릭터를 지정 원작에 배정 */
@Transactional @Transactional
fun assignOneCharacter(originalWorkId: Long, characterId: Long) { fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
val character = chatCharacterRepository.findById(characterId) val character = chatCharacterRepository.findById(characterId)
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") } .orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
character.originalWork = ow
if (originalWorkId == 0L) {
character.originalWork = null
} else {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
character.originalWork = ow
}
chatCharacterRepository.save(character) chatCharacterRepository.save(character)
} }
} }

View File

@@ -12,7 +12,6 @@ import org.springframework.stereotype.Repository
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
fun findByName(name: String): ChatCharacter? fun findByName(name: String): ChatCharacter?
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter> fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
fun findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId: Long): List<ChatCharacter>
fun findByOriginalWorkIdAndIsActiveTrue(originalWorkId: Long, pageable: Pageable): Page<ChatCharacter> fun findByOriginalWorkIdAndIsActiveTrue(originalWorkId: Long, pageable: Pageable): Page<ChatCharacter>
/** /**
@@ -54,28 +53,6 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
pageable: Pageable pageable: Pageable
): Page<ChatCharacter> ): Page<ChatCharacter>
/**
* 이름, 설명, MBTI, 태그로 캐릭터 검색 - 무페이징 전체 목록
*/
@Query(
"""
SELECT DISTINCT c FROM ChatCharacter c
LEFT JOIN c.tagMappings tm
LEFT JOIN tm.tag t
WHERE c.isActive = true AND
(
LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
(c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR
(t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%')))
)
ORDER BY c.createdAt DESC
"""
)
fun searchCharactersNoPaging(
@Param("searchTerm") searchTerm: String
): List<ChatCharacter>
/** /**
* 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외) * 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외)
*/ */

View File

@@ -1,8 +1,10 @@
package kr.co.vividnext.sodalive.chat.original package kr.co.vividnext.sodalive.chat.original
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.CascadeType
import javax.persistence.Column import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.OneToMany
/** /**
* 원작(오리지널 작품) 엔티티 * 원작(오리지널 작품) 엔티티
@@ -31,13 +33,33 @@ class OriginalWork(
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
var description: String = "", var description: String = "",
/** 원작 링크 */ /** 원천 원작 */
@Column(nullable = true) @Column(nullable = true)
var originalLink: String? = null var originalWork: String? = null,
/** 원천 원작 링크(단일) */
@Column(nullable = true)
var originalLink: String? = null,
/** 작가 */
@Column(nullable = true)
var writer: String? = null,
/** 제작사 */
@Column(nullable = true)
var studio: String? = null
) : BaseEntity() { ) : BaseEntity() {
/** 원작 대표 이미지 S3 경로 */ /** 원작 대표 이미지 S3 경로 */
var imagePath: String? = null var imagePath: String? = null
/** 소프트 삭제 여부 (true면 삭제된 것으로 간주) */ /** 소프트 삭제 여부 (true면 삭제된 것으로 간주) */
var isDeleted: Boolean = false var isDeleted: Boolean = false
/** 원작 링크들 (1:N) */
@OneToMany(mappedBy = "originalWork", cascade = [CascadeType.ALL], orphanRemoval = true)
var originalLinks: MutableList<OriginalWorkLink> = mutableListOf()
/** 원작 태그 매핑들 (1:N) */
@OneToMany(mappedBy = "originalWork", cascade = [CascadeType.ALL], orphanRemoval = true)
var tagMappings: MutableList<OriginalWorkTagMapping> = mutableListOf()
} }

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.chat.original
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
/**
* 원작 원본 링크 엔티티
* - 하나의 원작(OriginalWork)에 여러 개의 링크가 연결될 수 있음 (1:N)
*/
@Entity
class OriginalWorkLink(
@Column(nullable = false)
var url: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "original_work_id")
var originalWork: OriginalWork? = null
) : BaseEntity()

View File

@@ -33,13 +33,13 @@ interface OriginalWorkRepository : JpaRepository<OriginalWork, Long> {
): List<OriginalWork> ): List<OriginalWork>
/** /**
* 앱용 원작 목록 조회 * 앱용 원작 목록 조회 (페이징)
* - 소프트 삭제 제외 * - 소프트 삭제 제외
* - includeAdult=false이면 19금 제외 * - includeAdult=false이면 19금 제외
* - 활성 캐릭터가 하나라도 연결된 원작만 조회 * - 활성 캐릭터가 하나라도 연결된 원작만 조회
*/ */
@Query( @Query(
""" value = """
SELECT ow FROM OriginalWork ow SELECT ow FROM OriginalWork ow
WHERE ow.isDeleted = false WHERE ow.isDeleted = false
AND (:includeAdult = true OR ow.isAdult = false) AND (:includeAdult = true OR ow.isAdult = false)
@@ -48,7 +48,16 @@ interface OriginalWorkRepository : JpaRepository<OriginalWork, Long> {
WHERE c.originalWork = ow AND c.isActive = true WHERE c.originalWork = ow AND c.isActive = true
) )
ORDER BY ow.createdAt DESC ORDER BY ow.createdAt DESC
""",
countQuery = """
SELECT COUNT(ow) FROM OriginalWork ow
WHERE ow.isDeleted = false
AND (:includeAdult = true OR ow.isAdult = false)
AND EXISTS (
SELECT 1 FROM ChatCharacter c
WHERE c.originalWork = ow AND c.isActive = true
)
""" """
) )
fun findAllForApp(@Param("includeAdult") includeAdult: Boolean): List<OriginalWork> fun findAllForAppPage(@Param("includeAdult") includeAdult: Boolean, pageable: Pageable): Page<OriginalWork>
} }

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.chat.original
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.OneToMany
import javax.persistence.Table
import javax.persistence.UniqueConstraint
/**
* 원작 태그 엔티티 (작품/시리즈 태그와 분리)
*/
@Entity
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["tag"])])
class OriginalWorkTag(
@Column(nullable = false)
val tag: String
) : BaseEntity() {
@OneToMany(mappedBy = "tag")
var tagMappings: MutableList<OriginalWorkTagMapping> = mutableListOf()
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.chat.original
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
/**
* OriginalWork 와 OriginalWorkTag 매핑 엔티티
*/
@Entity
class OriginalWorkTagMapping(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "original_work_id")
val originalWork: OriginalWork,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
val tag: OriginalWorkTag
) : BaseEntity()

View File

@@ -13,6 +13,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
/** /**
@@ -29,20 +30,23 @@ class OriginalWorkController(
) { ) {
/** /**
* 원작 목록 * 원작 목록 (페이징)
* - 로그인 불필요 * - 로그인 불필요
* - 본인인증하지 않은 경우 19금 제외 * - 본인인증하지 않은 경우 19금 제외
* - 활성 캐릭터가 하나라도 연결된 원작만 노출 * - 활성 캐릭터가 하나라도 연결된 원작만 노출
* - 요청: page(기본 0), size(기본 20)
* - 반환: totalCount + [imageUrl, title, contentType] * - 반환: totalCount + [imageUrl, title, contentType]
*/ */
@GetMapping("/list") @GetMapping("/list")
fun list( fun list(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
val includeAdult = member?.auth != null val includeAdult = member?.auth != null
val list = queryService.listForApp(includeAdult) val pageRes = queryService.listForAppPage(includeAdult, page, size)
val content = list.map { OriginalWorkListItemResponse.from(it, imageHost) } val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) }
ApiResponse.ok(OriginalWorkListResponse(totalCount = content.size.toLong(), content = content)) ApiResponse.ok(OriginalWorkListResponse(totalCount = pageRes.totalElements, content = content))
} }
/** /**
@@ -50,6 +54,7 @@ class OriginalWorkController(
* - 로그인 및 본인인증 필수 * - 로그인 및 본인인증 필수
* - 반환: 이미지, 제목, 콘텐츠 타입, 카테고리, 19금 여부, 작품 소개, 원작 링크 * - 반환: 이미지, 제목, 콘텐츠 타입, 카테고리, 19금 여부, 작품 소개, 원작 링크
* - 해당 원작의 활성 캐릭터 리스트 [imageUrl, name, description] * - 해당 원작의 활성 캐릭터 리스트 [imageUrl, name, description]
* - 캐릭터는 페이징 적용: 첫 페이지 20개
*/ */
@GetMapping("/{id}") @GetMapping("/{id}")
fun detail( fun detail(
@@ -60,7 +65,8 @@ class OriginalWorkController(
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val ow = queryService.getOriginalWork(id) val ow = queryService.getOriginalWork(id)
val characters = queryService.getActiveCharacters(id).map { val pageRes = queryService.getActiveCharactersPage(id, page = 0, size = 20)
val characters = pageRes.content.map {
val path = it.imagePath ?: "profile/default-profile.png" val path = it.imagePath ?: "profile/default-profile.png"
Character( Character(
characterId = it.id!!, characterId = it.id!!,

View File

@@ -48,7 +48,12 @@ data class OriginalWorkDetailResponse(
@JsonProperty("category") val category: String, @JsonProperty("category") val category: String,
@JsonProperty("isAdult") val isAdult: Boolean, @JsonProperty("isAdult") val isAdult: Boolean,
@JsonProperty("description") val description: String, @JsonProperty("description") val description: String,
@JsonProperty("originalWork") val originalWork: String?,
@JsonProperty("originalLink") val originalLink: String?, @JsonProperty("originalLink") val originalLink: String?,
@JsonProperty("writer") val writer: String?,
@JsonProperty("studio") val studio: String?,
@JsonProperty("originalLinks") val originalLinks: List<String>,
@JsonProperty("tags") val tags: List<String>,
@JsonProperty("characters") val characters: List<Character> @JsonProperty("characters") val characters: List<Character>
) { ) {
companion object { companion object {
@@ -69,9 +74,22 @@ data class OriginalWorkDetailResponse(
category = entity.category, category = entity.category,
isAdult = entity.isAdult, isAdult = entity.isAdult,
description = entity.description, description = entity.description,
originalWork = entity.originalWork,
originalLink = entity.originalLink, originalLink = entity.originalLink,
writer = entity.writer,
studio = entity.studio,
originalLinks = entity.originalLinks.map { it.url },
tags = entity.tagMappings.map { it.tag.tag },
characters = characters characters = characters
) )
} }
} }
} }
/**
* 앱용: 원작별 활성 캐릭터 페이징 응답 DTO
*/
data class OriginalWorkCharactersPageResponse(
@JsonProperty("totalCount") val totalCount: Long,
@JsonProperty("content") val content: List<Character>
)

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.chat.original.repository
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface OriginalWorkTagRepository : JpaRepository<OriginalWorkTag, Long> {
fun findByTag(tag: String): OriginalWorkTag?
}

View File

@@ -5,6 +5,9 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepositor
import kr.co.vividnext.sodalive.chat.original.OriginalWork import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -18,12 +21,21 @@ class OriginalWorkQueryService(
private val chatCharacterRepository: ChatCharacterRepository private val chatCharacterRepository: ChatCharacterRepository
) { ) {
/** /**
* 앱용 원작 목록 조회 * 앱용 원작 목록 조회 (페이징)
* @param includeAdult true면 19금 포함, false면 제외 * @param includeAdult true면 19금 포함, false면 제외
* @param page 페이지 번호(0부터)
* @param size 페이지 크기(기본 20, 최대 50)
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun listForApp(includeAdult: Boolean): List<OriginalWork> { fun listForAppPage(includeAdult: Boolean, page: Int = 0, size: Int = 20): Page<OriginalWork> {
return originalWorkRepository.findAllForApp(includeAdult) val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 50 -> 50
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return originalWorkRepository.findAllForAppPage(includeAdult, pageable)
} }
/** /**
@@ -36,10 +48,21 @@ class OriginalWorkQueryService(
} }
/** /**
* 지정 원작에 속한 활성 캐릭터 목록 조회 (최신순) * 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순)
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getActiveCharacters(originalWorkId: Long): List<ChatCharacter> { fun getActiveCharactersPage(originalWorkId: Long, page: Int = 0, size: Int = 20): Page<ChatCharacter> {
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId) // 원작 존재 및 소프트 삭제 여부 확인
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 50 -> 50
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
} }
} }

View File

@@ -53,7 +53,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
.innerJoin(useCan.room, liveRoom) .innerJoin(useCan.room, liveRoom)
.innerJoin(liveRoom.member, member) .innerJoin(liveRoom.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.createdAt.goe(startDate)) .and(useCan.createdAt.goe(startDate))
@@ -119,7 +122,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
.innerJoin(order.audioContent, audioContent) .innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .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))
@@ -196,7 +202,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
.innerJoin(order.audioContent, audioContent) .innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .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)
@@ -318,7 +327,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
.innerJoin(useCan.communityPost, creatorCommunity) .innerJoin(useCan.communityPost, creatorCommunity)
.innerJoin(creatorCommunity.member, member) .innerJoin(creatorCommunity.member, member)
.leftJoin(creatorSettlementRatio) .leftJoin(creatorSettlementRatio)
.on(member.id.eq(creatorSettlementRatio.member.id)) .on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where( .where(
useCan.isRefund.isFalse useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))