Compare commits
33 Commits
a27852ed44
...
main
Author | SHA1 | Date | |
---|---|---|---|
b69756ef81 | |||
dad517a953 | |||
eb2d093b02 | |||
67186bba55 | |||
1a3a9149a2 | |||
edeecad2ce | |||
387f5388d9 | |||
adcaa0a5fd | |||
47b2c1cb93 | |||
ce120a6d5d | |||
7f3589dcfb | |||
b134c28c10 | |||
41c8d0367d | |||
3b148d549e | |||
b6c96af8a2 | |||
4904625488 | |||
08b5fd23ab | |||
0574f4f629 | |||
4adc3e127c | |||
dd0a1c2293 | |||
a07407417c | |||
e33e3b43b7 | |||
634bf759ca | |||
0ed29c6097 | |||
b752434fbb | |||
eec63cc7b2 | |||
3dc9dd1f35 | |||
88e287067b | |||
eb18e2d009 | |||
27a3f450ef | |||
58a46a09c3 | |||
83a1316a64 | |||
f05f146c89 |
@@ -7,5 +7,5 @@ indent_size = 4
|
|||||||
indent_style = space
|
indent_style = space
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 120
|
max_line_length = 130
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
@@ -0,0 +1,32 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
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.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/chat/calculate")
|
||||||
|
class AdminChatCalculateController(
|
||||||
|
private val service: AdminChatCalculateService
|
||||||
|
) {
|
||||||
|
@GetMapping("/characters")
|
||||||
|
fun getCharacterCalculate(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String,
|
||||||
|
@RequestParam(required = false, defaultValue = "TOTAL_SALES_DESC") sort: ChatCharacterCalculateSort,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getCharacterCalculate(
|
||||||
|
startDateStr,
|
||||||
|
endDateStr,
|
||||||
|
sort,
|
||||||
|
pageable.offset,
|
||||||
|
pageable.pageSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,139 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import com.querydsl.core.types.Projections
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AdminChatCalculateQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
fun getCharacterCalculate(
|
||||||
|
startUtc: LocalDateTime,
|
||||||
|
endInclusiveUtc: LocalDateTime,
|
||||||
|
sort: ChatCharacterCalculateSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<ChatCharacterCalculateQueryData> {
|
||||||
|
val imageCanExpr = CaseBuilder()
|
||||||
|
.`when`(useCan.canUsage.eq(CanUsage.CHARACTER_IMAGE_PURCHASE))
|
||||||
|
.then(useCan.can.add(useCan.rewardCan))
|
||||||
|
.otherwise(0)
|
||||||
|
|
||||||
|
val messageCanExpr = CaseBuilder()
|
||||||
|
.`when`(useCan.canUsage.eq(CanUsage.CHAT_MESSAGE_PURCHASE))
|
||||||
|
.then(useCan.can.add(useCan.rewardCan))
|
||||||
|
.otherwise(0)
|
||||||
|
|
||||||
|
val quotaCanExpr = CaseBuilder()
|
||||||
|
.`when`(useCan.canUsage.eq(CanUsage.CHAT_QUOTA_PURCHASE))
|
||||||
|
.then(useCan.can.add(useCan.rewardCan))
|
||||||
|
.otherwise(0)
|
||||||
|
|
||||||
|
val imageSum = imageCanExpr.sum()
|
||||||
|
val messageSum = messageCanExpr.sum()
|
||||||
|
val quotaSum = quotaCanExpr.sum()
|
||||||
|
val totalSum = imageSum.add(messageSum).add(quotaSum)
|
||||||
|
|
||||||
|
// 캐릭터 조인: 이미지 경로를 통한 캐릭터(c1) + characterId 직접 지정(c2)
|
||||||
|
val c1 = QChatCharacter("c1")
|
||||||
|
val c2 = QChatCharacter("c2")
|
||||||
|
|
||||||
|
val characterIdExpr = c1.id.coalesce(c2.id)
|
||||||
|
val characterNameAgg = Expressions.stringTemplate(
|
||||||
|
"coalesce(max({0}), max({1}), '')",
|
||||||
|
c1.name,
|
||||||
|
c2.name
|
||||||
|
)
|
||||||
|
val characterImagePathAgg = Expressions.stringTemplate(
|
||||||
|
"coalesce(max({0}), max({1}))",
|
||||||
|
c1.imagePath,
|
||||||
|
c2.imagePath
|
||||||
|
)
|
||||||
|
|
||||||
|
val query = queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
ChatCharacterCalculateQueryData::class.java,
|
||||||
|
characterIdExpr,
|
||||||
|
characterNameAgg,
|
||||||
|
characterImagePathAgg.prepend("/").prepend(imageHost),
|
||||||
|
imageSum,
|
||||||
|
messageSum,
|
||||||
|
quotaSum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(useCan)
|
||||||
|
.leftJoin(useCan.characterImage, characterImage)
|
||||||
|
.leftJoin(characterImage.chatCharacter, c1)
|
||||||
|
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(
|
||||||
|
useCan.canUsage.`in`(
|
||||||
|
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_QUOTA_PURCHASE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.and(useCan.createdAt.goe(startUtc))
|
||||||
|
.and(useCan.createdAt.loe(endInclusiveUtc))
|
||||||
|
)
|
||||||
|
.groupBy(characterIdExpr)
|
||||||
|
|
||||||
|
when (sort) {
|
||||||
|
ChatCharacterCalculateSort.TOTAL_SALES_DESC ->
|
||||||
|
query.orderBy(totalSum.desc(), characterIdExpr.desc())
|
||||||
|
|
||||||
|
ChatCharacterCalculateSort.LATEST_DESC ->
|
||||||
|
query.orderBy(characterIdExpr.desc(), totalSum.desc())
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCharacterCalculateTotalCount(
|
||||||
|
startUtc: LocalDateTime,
|
||||||
|
endInclusiveUtc: LocalDateTime
|
||||||
|
): Int {
|
||||||
|
val c1 = QChatCharacter("c1")
|
||||||
|
val c2 = QChatCharacter("c2")
|
||||||
|
val characterIdExpr = c1.id.coalesce(c2.id)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(characterIdExpr)
|
||||||
|
.from(useCan)
|
||||||
|
.leftJoin(useCan.characterImage, characterImage)
|
||||||
|
.leftJoin(characterImage.chatCharacter, c1)
|
||||||
|
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(
|
||||||
|
useCan.canUsage.`in`(
|
||||||
|
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_QUOTA_PURCHASE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.and(useCan.createdAt.goe(startUtc))
|
||||||
|
.and(useCan.createdAt.loe(endInclusiveUtc))
|
||||||
|
)
|
||||||
|
.groupBy(characterIdExpr)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminChatCalculateService(
|
||||||
|
private val repository: AdminChatCalculateQueryRepository
|
||||||
|
) {
|
||||||
|
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
|
private val kstZone: ZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getCharacterCalculate(
|
||||||
|
startDateStr: String,
|
||||||
|
endDateStr: String,
|
||||||
|
sort: ChatCharacterCalculateSort,
|
||||||
|
offset: Long,
|
||||||
|
pageSize: Int
|
||||||
|
): ChatCharacterCalculateResponse {
|
||||||
|
// 날짜 유효성 검증 (KST 기준)
|
||||||
|
val startDate = LocalDate.parse(startDateStr, dateFormatter)
|
||||||
|
val endDate = LocalDate.parse(endDateStr, dateFormatter)
|
||||||
|
val todayKst = LocalDate.now(kstZone)
|
||||||
|
|
||||||
|
if (endDate.isAfter(todayKst)) {
|
||||||
|
throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.")
|
||||||
|
}
|
||||||
|
if (startDate.isAfter(endDate)) {
|
||||||
|
throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.")
|
||||||
|
}
|
||||||
|
if (endDate.isAfter(startDate.plusMonths(6))) {
|
||||||
|
throw SodaException("조회 가능 기간은 최대 6개월입니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val startUtc = startDateStr.convertLocalDateTime()
|
||||||
|
val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getCharacterCalculateTotalCount(startUtc, endInclusiveUtc)
|
||||||
|
val rows = repository.getCharacterCalculate(startUtc, endInclusiveUtc, sort, offset, pageSize.toLong())
|
||||||
|
val items = rows.map { it.toItem() }
|
||||||
|
return ChatCharacterCalculateResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,62 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
// 정렬 옵션
|
||||||
|
enum class ChatCharacterCalculateSort {
|
||||||
|
TOTAL_SALES_DESC,
|
||||||
|
LATEST_DESC
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryDSL 프로젝션용 DTO
|
||||||
|
data class ChatCharacterCalculateQueryData @QueryProjection constructor(
|
||||||
|
val characterId: Long,
|
||||||
|
val characterName: String,
|
||||||
|
val characterImagePath: String?,
|
||||||
|
val imagePurchaseCan: Int?,
|
||||||
|
val messagePurchaseCan: Int?,
|
||||||
|
val quotaPurchaseCan: Int?
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 DTO (아이템)
|
||||||
|
data class ChatCharacterCalculateItem(
|
||||||
|
@JsonProperty("characterId") val characterId: Long,
|
||||||
|
@JsonProperty("characterImage") val characterImage: String?,
|
||||||
|
@JsonProperty("name") val name: String,
|
||||||
|
@JsonProperty("imagePurchaseCan") val imagePurchaseCan: Int,
|
||||||
|
@JsonProperty("messagePurchaseCan") val messagePurchaseCan: Int,
|
||||||
|
@JsonProperty("quotaPurchaseCan") val quotaPurchaseCan: Int,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||||
|
@JsonProperty("settlementKrw") val settlementKrw: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 DTO (전체)
|
||||||
|
data class ChatCharacterCalculateResponse(
|
||||||
|
@JsonProperty("totalCount") val totalCount: Int,
|
||||||
|
@JsonProperty("items") val items: List<ChatCharacterCalculateItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem {
|
||||||
|
val image = imagePurchaseCan ?: 0
|
||||||
|
val message = messagePurchaseCan ?: 0
|
||||||
|
val quota = quotaPurchaseCan ?: 0
|
||||||
|
val total = image + message + quota
|
||||||
|
val totalKrw = BigDecimal(total).multiply(BigDecimal(100))
|
||||||
|
val settlement = totalKrw.multiply(BigDecimal("0.10")).setScale(0, RoundingMode.HALF_UP)
|
||||||
|
|
||||||
|
return ChatCharacterCalculateItem(
|
||||||
|
characterId = characterId,
|
||||||
|
characterImage = characterImagePath,
|
||||||
|
name = characterName,
|
||||||
|
imagePurchaseCan = image,
|
||||||
|
messagePurchaseCan = message,
|
||||||
|
quotaPurchaseCan = quota,
|
||||||
|
totalCan = total,
|
||||||
|
totalKrw = totalKrw.toInt(),
|
||||||
|
settlementKrw = settlement.toInt()
|
||||||
|
)
|
||||||
|
}
|
@@ -3,9 +3,11 @@ 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
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
@@ -37,6 +39,7 @@ class AdminChatCharacterController(
|
|||||||
private val service: ChatCharacterService,
|
private val service: ChatCharacterService,
|
||||||
private val adminService: AdminChatCharacterService,
|
private val adminService: AdminChatCharacterService,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val originalWorkService: AdminOriginalWorkService,
|
||||||
|
|
||||||
@Value("\${weraser.api-key}")
|
@Value("\${weraser.api-key}")
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
@@ -68,6 +71,26 @@ class AdminChatCharacterController(
|
|||||||
ApiResponse.ok(response)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 검색(관리자)
|
||||||
|
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
|
||||||
|
* - 페이징 지원: page, size 파라미터 사용
|
||||||
|
*/
|
||||||
|
@GetMapping("/search")
|
||||||
|
fun searchCharacters(
|
||||||
|
@RequestParam("searchTerm") searchTerm: String,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageable = adminService.createDefaultPageRequest(page, size)
|
||||||
|
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
|
||||||
|
val response = ChatCharacterSearchListPageResponse(
|
||||||
|
totalCount = resultPage.totalElements,
|
||||||
|
content = resultPage.content
|
||||||
|
)
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐릭터 상세 정보 조회 API
|
* 캐릭터 상세 정보 조회 API
|
||||||
*
|
*
|
||||||
@@ -137,6 +160,11 @@ class AdminChatCharacterController(
|
|||||||
chatCharacter.imagePath = imagePath
|
chatCharacter.imagePath = imagePath
|
||||||
service.saveChatCharacter(chatCharacter)
|
service.saveChatCharacter(chatCharacter)
|
||||||
|
|
||||||
|
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||||
|
if (request.originalWorkId != null) {
|
||||||
|
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
ApiResponse.ok(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +275,8 @@ class AdminChatCharacterController(
|
|||||||
val hasDbOnlyChanges =
|
val hasDbOnlyChanges =
|
||||||
request.originalTitle != null ||
|
request.originalTitle != null ||
|
||||||
request.originalLink != null ||
|
request.originalLink != null ||
|
||||||
request.characterType != null
|
request.characterType != null ||
|
||||||
|
request.originalWorkId != null
|
||||||
|
|
||||||
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
||||||
throw SodaException("변경된 데이터가 없습니다.")
|
throw SodaException("변경된 데이터가 없습니다.")
|
||||||
@@ -286,6 +315,12 @@ class AdminChatCharacterController(
|
|||||||
request = request
|
request = request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||||
|
if (request.originalWorkId != null) {
|
||||||
|
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||||
|
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
|
||||||
|
}
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
ApiResponse.ok(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -2,6 +2,10 @@ 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
|
||||||
|
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
|
||||||
|
*/
|
||||||
data class ChatCharacterDetailResponse(
|
data class ChatCharacterDetailResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val characterUUID: String,
|
val characterUUID: String,
|
||||||
@@ -24,7 +28,8 @@ data class ChatCharacterDetailResponse(
|
|||||||
val relationships: List<RelationshipResponse>,
|
val relationships: List<RelationshipResponse>,
|
||||||
val personalities: List<PersonalityResponse>,
|
val personalities: List<PersonalityResponse>,
|
||||||
val backgrounds: List<BackgroundResponse>,
|
val backgrounds: List<BackgroundResponse>,
|
||||||
val memories: List<MemoryResponse>
|
val memories: List<MemoryResponse>,
|
||||||
|
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||||
@@ -34,6 +39,20 @@ data class ChatCharacterDetailResponse(
|
|||||||
chatCharacter.imagePath ?: ""
|
chatCharacter.imagePath ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val ow = chatCharacter.originalWork
|
||||||
|
val originalWorkBrief = ow?.let {
|
||||||
|
val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${it.imagePath}"
|
||||||
|
} else {
|
||||||
|
it.imagePath
|
||||||
|
}
|
||||||
|
OriginalWorkBriefResponse(
|
||||||
|
id = it.id!!,
|
||||||
|
imageUrl = owImage,
|
||||||
|
title = it.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return ChatCharacterDetailResponse(
|
return ChatCharacterDetailResponse(
|
||||||
id = chatCharacter.id!!,
|
id = chatCharacter.id!!,
|
||||||
characterUUID = chatCharacter.characterUUID,
|
characterUUID = chatCharacter.characterUUID,
|
||||||
@@ -71,7 +90,8 @@ data class ChatCharacterDetailResponse(
|
|||||||
},
|
},
|
||||||
memories = chatCharacter.memories.map {
|
memories = chatCharacter.memories.map {
|
||||||
MemoryResponse(it.title, it.content, it.emotion)
|
MemoryResponse(it.title, it.content, it.emotion)
|
||||||
}
|
},
|
||||||
|
originalWork = originalWorkBrief
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,3 +121,12 @@ data class RelationshipResponse(
|
|||||||
val relationshipType: String,
|
val relationshipType: String,
|
||||||
val currentStatus: String
|
val currentStatus: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
|
||||||
|
*/
|
||||||
|
data class OriginalWorkBriefResponse(
|
||||||
|
val id: Long,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val title: String
|
||||||
|
)
|
||||||
|
@@ -40,6 +40,7 @@ data class ChatCharacterRegisterRequest(
|
|||||||
@JsonProperty("appearance") val appearance: String?,
|
@JsonProperty("appearance") val appearance: String?,
|
||||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
|
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||||
@JsonProperty("characterType") val characterType: String? = null,
|
@JsonProperty("characterType") val characterType: String? = null,
|
||||||
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
||||||
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
||||||
@@ -75,6 +76,7 @@ data class ChatCharacterUpdateRequest(
|
|||||||
@JsonProperty("appearance") val appearance: String? = null,
|
@JsonProperty("appearance") val appearance: String? = null,
|
||||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
|
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||||
@JsonProperty("characterType") val characterType: String? = null,
|
@JsonProperty("characterType") val characterType: String? = null,
|
||||||
@JsonProperty("isActive") val isActive: Boolean? = null,
|
@JsonProperty("isActive") val isActive: Boolean? = null,
|
||||||
@JsonProperty("tags") val tags: List<String>? = null,
|
@JsonProperty("tags") val tags: List<String>? = null,
|
||||||
|
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 검색 결과 페이지 응답 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterSearchListPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<ChatCharacterListResponse>
|
||||||
|
)
|
@@ -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>
|
||||||
)
|
)
|
@@ -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
|
||||||
@@ -65,20 +64,15 @@ class AdminChatCharacterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반)
|
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
|
||||||
*
|
|
||||||
* @param searchTerm 검색어
|
|
||||||
* @param pageable 페이징 정보
|
|
||||||
* @param imageHost 이미지 호스트 URL
|
|
||||||
* @return 검색된 캐릭터 목록 (페이징)
|
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun searchCharacters(
|
fun searchCharacters(
|
||||||
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,199 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.original
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterListPageResponse
|
||||||
|
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.OriginalWorkPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
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.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작(오리지널 작품) 관리자 API
|
||||||
|
* - 원작 등록/수정/삭제
|
||||||
|
* - 원작과 캐릭터 연결(배정) 및 해제
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/original")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminOriginalWorkController(
|
||||||
|
private val originalWorkService: AdminOriginalWorkService,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val s3Bucket: String,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 등록
|
||||||
|
* - 이미지 파일과 JSON 요청을 멀티파트로 받는다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun register(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java)
|
||||||
|
|
||||||
|
// 서비스 계층을 통해 원작을 생성
|
||||||
|
val saved = originalWorkService.createOriginalWork(request)
|
||||||
|
|
||||||
|
// 이미지 업로드 후 이미지 경로 업데이트
|
||||||
|
val imagePath = uploadImage(saved.id!!, image)
|
||||||
|
originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath)
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 수정
|
||||||
|
* - 이미지가 있으면 교체, 없으면 유지
|
||||||
|
*/
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun update(
|
||||||
|
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java)
|
||||||
|
|
||||||
|
// 이미지가 전달된 경우 먼저 업로드하여 경로를 생성
|
||||||
|
val imagePath = if (image != null && !image.isEmpty) {
|
||||||
|
uploadImage(request.id, image)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
originalWorkService.updateOriginalWork(request, imagePath)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 삭제
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
fun delete(@PathVariable id: Long) = run {
|
||||||
|
originalWorkService.deleteOriginalWork(id)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 목록(페이징)
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun list(
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageRes = originalWorkService.getOriginalWorkPage(page, size)
|
||||||
|
val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) }
|
||||||
|
ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 검색(관리자)
|
||||||
|
* - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외
|
||||||
|
* - 페이징 제거: 전체 목록 반환
|
||||||
|
*/
|
||||||
|
@GetMapping("/search")
|
||||||
|
fun search(
|
||||||
|
@RequestParam("searchTerm") searchTerm: String
|
||||||
|
) = run {
|
||||||
|
val list = originalWorkService.searchOriginalWorksAll(searchTerm)
|
||||||
|
val content = list.map { OriginalWorkResponse.from(it, imageHost) }
|
||||||
|
ApiResponse.ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 상세
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun detail(@PathVariable id: Long) = run {
|
||||||
|
ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작에 기존 캐릭터들을 배정
|
||||||
|
* - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 이 원작으로 설정
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/assign-characters")
|
||||||
|
fun assignCharacters(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestBody body: OriginalWorkAssignCharactersRequest
|
||||||
|
) = run {
|
||||||
|
originalWorkService.assignCharacters(id, body.characterIds)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작에서 캐릭터들 해제
|
||||||
|
* - 캐릭터들의 originalWork를 null로 설정
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/unassign-characters")
|
||||||
|
fun unassignCharacters(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestBody body: OriginalWorkAssignCharactersRequest
|
||||||
|
) = run {
|
||||||
|
originalWorkService.unassignCharacters(id, body.characterIds)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회
|
||||||
|
* - 활성 캐릭터만 포함
|
||||||
|
* - 응답 항목: 캐릭터 이미지(URL), 이름
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/characters")
|
||||||
|
fun listCharactersOfOriginal(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
|
||||||
|
val content = pageRes.content.map { OriginalWorkChatCharacterResponse.from(it, imageHost) }
|
||||||
|
ApiResponse.ok(
|
||||||
|
OriginalWorkChatCharacterListPageResponse(
|
||||||
|
totalCount = pageRes.totalElements,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이미지 업로드 공통 처리 */
|
||||||
|
private fun uploadImage(originalWorkId: Long, image: MultipartFile): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = s3Bucket,
|
||||||
|
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,95 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.original.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 등록 요청 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkRegisterRequest(
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("contentType") val contentType: String,
|
||||||
|
@JsonProperty("category") val category: String,
|
||||||
|
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
||||||
|
@JsonProperty("description") val description: String = "",
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 수정 요청 DTO (부분 수정 가능)
|
||||||
|
*/
|
||||||
|
data class OriginalWorkUpdateRequest(
|
||||||
|
@JsonProperty("id") val id: Long,
|
||||||
|
@JsonProperty("title") val title: String? = null,
|
||||||
|
@JsonProperty("contentType") val contentType: String? = null,
|
||||||
|
@JsonProperty("category") val category: String? = null,
|
||||||
|
@JsonProperty("isAdult") val isAdult: Boolean? = null,
|
||||||
|
@JsonProperty("description") val description: 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
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 상세/목록 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkResponse(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val contentType: String,
|
||||||
|
val category: String,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val description: String,
|
||||||
|
val originalWork: String?,
|
||||||
|
val originalLink: String?,
|
||||||
|
val writer: String?,
|
||||||
|
val studio: String?,
|
||||||
|
val originalLinks: List<String>,
|
||||||
|
val tags: List<String>,
|
||||||
|
val imageUrl: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkResponse {
|
||||||
|
val fullImagePath = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${entity.imagePath}"
|
||||||
|
} else {
|
||||||
|
entity.imagePath
|
||||||
|
}
|
||||||
|
return OriginalWorkResponse(
|
||||||
|
id = entity.id!!,
|
||||||
|
title = entity.title,
|
||||||
|
contentType = entity.contentType,
|
||||||
|
category = entity.category,
|
||||||
|
isAdult = entity.isAdult,
|
||||||
|
description = entity.description,
|
||||||
|
originalWork = entity.originalWork,
|
||||||
|
originalLink = entity.originalLink,
|
||||||
|
writer = entity.writer,
|
||||||
|
studio = entity.studio,
|
||||||
|
originalLinks = entity.originalLinks.map { it.url },
|
||||||
|
tags = entity.tagMappings.map { it.tag.tag },
|
||||||
|
imageUrl = fullImagePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class OriginalWorkPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<OriginalWorkResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작-캐릭터 연결/해제 요청 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkAssignCharactersRequest(
|
||||||
|
@JsonProperty("characterIds") val characterIds: List<Long>
|
||||||
|
)
|
@@ -0,0 +1,213 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.original.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
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 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.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작(오리지널 작품) 관련 관리자 서비스
|
||||||
|
* - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class AdminOriginalWorkService(
|
||||||
|
private val originalWorkRepository: OriginalWorkRepository,
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
private val originalWorkTagRepository: OriginalWorkTagRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 원작 등록 (중복 제목 방지 포함) */
|
||||||
|
@Transactional
|
||||||
|
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
|
||||||
|
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
|
||||||
|
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
|
||||||
|
}
|
||||||
|
val entity = OriginalWork(
|
||||||
|
title = request.title,
|
||||||
|
contentType = request.contentType,
|
||||||
|
category = request.category,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
description = request.description,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
||||||
|
@Transactional
|
||||||
|
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
|
||||||
|
request.title?.let { ow.title = it }
|
||||||
|
request.contentType?.let { ow.contentType = it }
|
||||||
|
request.category?.let { ow.category = it }
|
||||||
|
request.isAdult?.let { ow.isAdult = it }
|
||||||
|
request.description?.let { ow.description = it }
|
||||||
|
request.originalWork?.let { ow.originalWork = 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) {
|
||||||
|
ow.imagePath = imagePath
|
||||||
|
}
|
||||||
|
return originalWorkRepository.save(ow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 이미지 경로만 별도 갱신 */
|
||||||
|
@Transactional
|
||||||
|
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
ow.imagePath = imagePath
|
||||||
|
return originalWorkRepository.save(ow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 삭제 (소프트 삭제) */
|
||||||
|
@Transactional
|
||||||
|
fun deleteOriginalWork(id: Long) {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
|
||||||
|
ow.isDeleted = true
|
||||||
|
originalWorkRepository.save(ow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 상세 조회 (소프트 삭제 제외) */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getOriginalWork(id: Long): OriginalWork {
|
||||||
|
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 페이징 조회 */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> {
|
||||||
|
val safePage = if (page < 0) 0 else page
|
||||||
|
val safeSize = when {
|
||||||
|
size <= 0 -> 20
|
||||||
|
size > 100 -> 100
|
||||||
|
else -> size
|
||||||
|
}
|
||||||
|
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||||
|
return originalWorkRepository.findByIsDeletedFalse(pageable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
|
||||||
|
// 원작 존재 및 소프트 삭제 여부 확인
|
||||||
|
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
|
||||||
|
val safePage = if (page < 0) 0 else page
|
||||||
|
val safeSize = when {
|
||||||
|
size <= 0 -> 20
|
||||||
|
size > 100 -> 100
|
||||||
|
else -> size
|
||||||
|
}
|
||||||
|
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||||
|
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
|
||||||
|
return originalWorkRepository.searchNoPaging(searchTerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작에 기존 캐릭터들을 배정 */
|
||||||
|
@Transactional
|
||||||
|
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
if (characterIds.isEmpty()) return
|
||||||
|
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||||
|
characters.forEach { it.originalWork = ow }
|
||||||
|
chatCharacterRepository.saveAll(characters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작에서 캐릭터들 해제 */
|
||||||
|
@Transactional
|
||||||
|
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||||
|
// 원작 존재 확인 (소프트 삭제 제외)
|
||||||
|
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
if (characterIds.isEmpty()) return
|
||||||
|
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||||
|
characters.forEach { it.originalWork = null }
|
||||||
|
chatCharacterRepository.saveAll(characters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단일 캐릭터를 지정 원작에 배정 */
|
||||||
|
@Transactional
|
||||||
|
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
|
||||||
|
val character = chatCharacterRepository.findById(characterId)
|
||||||
|
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
|
||||||
|
|
||||||
|
if (originalWorkId == 0L) {
|
||||||
|
character.originalWork = null
|
||||||
|
} else {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
character.originalWork = ow
|
||||||
|
}
|
||||||
|
|
||||||
|
chatCharacterRepository.save(character)
|
||||||
|
}
|
||||||
|
}
|
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character
|
package kr.co.vividnext.sodalive.chat.character
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import javax.persistence.CascadeType
|
import javax.persistence.CascadeType
|
||||||
import javax.persistence.Column
|
import javax.persistence.Column
|
||||||
@@ -7,6 +8,8 @@ import javax.persistence.Entity
|
|||||||
import javax.persistence.EnumType
|
import javax.persistence.EnumType
|
||||||
import javax.persistence.Enumerated
|
import javax.persistence.Enumerated
|
||||||
import javax.persistence.FetchType
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.JoinColumn
|
||||||
|
import javax.persistence.ManyToOne
|
||||||
import javax.persistence.OneToMany
|
import javax.persistence.OneToMany
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -44,14 +47,19 @@ class ChatCharacter(
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
var appearance: String? = null,
|
var appearance: String? = null,
|
||||||
|
|
||||||
// 원작 (optional)
|
// 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var originalTitle: String? = null,
|
var originalTitle: String? = null,
|
||||||
|
|
||||||
// 원작 링크 (optional)
|
// 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var originalLink: String? = null,
|
var originalLink: String? = null,
|
||||||
|
|
||||||
|
// 연관 원작 (한 캐릭터는 하나의 원작에만 속함)
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "original_work_id")
|
||||||
|
var originalWork: OriginalWork? = null,
|
||||||
|
|
||||||
// 캐릭터 유형
|
// 캐릭터 유형
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
@@ -22,6 +22,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
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -64,27 +65,14 @@ class ChatCharacterController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인기 캐릭터 조회 (현재는 빈 리스트)
|
// 인기 캐릭터 조회
|
||||||
val popularCharacters = service.getPopularCharacters()
|
val popularCharacters = service.getPopularCharacters()
|
||||||
.map {
|
|
||||||
Character(
|
|
||||||
characterId = it.id!!,
|
|
||||||
name = it.name,
|
|
||||||
description = it.description,
|
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최신 캐릭터 조회 (최대 10개)
|
// 최근 등록된 캐릭터 리스트 조회
|
||||||
val newCharacters = service.getNewCharacters(50)
|
val newCharacters = service.getRecentCharactersPage(
|
||||||
.map {
|
page = 0,
|
||||||
Character(
|
size = 50
|
||||||
characterId = it.id!!,
|
).content
|
||||||
name = it.name,
|
|
||||||
description = it.description,
|
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
||||||
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
||||||
@@ -190,4 +178,19 @@ class ChatCharacterController(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 등록된 캐릭터 전체보기
|
||||||
|
* - 기준: 2주 이내 등록된 캐릭터만 페이징 조회
|
||||||
|
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
|
||||||
|
*/
|
||||||
|
@GetMapping("/recent")
|
||||||
|
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
service.getRecentCharactersPage(
|
||||||
|
page = page ?: 0,
|
||||||
|
size = 20
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.dto
|
package kr.co.vividnext.sodalive.chat.character.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
data class CharacterMainResponse(
|
data class CharacterMainResponse(
|
||||||
val banners: List<CharacterBannerResponse>,
|
val banners: List<CharacterBannerResponse>,
|
||||||
val recentCharacters: List<RecentCharacter>,
|
val recentCharacters: List<RecentCharacter>,
|
||||||
@@ -15,10 +17,10 @@ data class CurationSection(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class Character(
|
data class Character(
|
||||||
val characterId: Long,
|
@JsonProperty("characterId") val characterId: Long,
|
||||||
val name: String,
|
@JsonProperty("name") val name: String,
|
||||||
val description: String,
|
@JsonProperty("description") val description: String,
|
||||||
val imageUrl: String
|
@JsonProperty("imageUrl") val imageUrl: String
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RecentCharacter(
|
data class RecentCharacter(
|
||||||
|
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 등록된 캐릭터 전체보기 페이지 응답 DTO
|
||||||
|
*/
|
||||||
|
data class RecentCharactersResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<Character>
|
||||||
|
)
|
@@ -10,17 +10,29 @@ import org.springframework.stereotype.Repository
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||||
fun findByCharacterUUID(characterUUID: String): ChatCharacter?
|
|
||||||
fun findByName(name: String): ChatCharacter?
|
fun findByName(name: String): ChatCharacter?
|
||||||
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
||||||
|
fun findByOriginalWorkIdAndIsActiveTrue(originalWorkId: Long, pageable: Pageable): Page<ChatCharacter>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 활성화된 캐릭터를 생성일 기준 내림차순으로 조회
|
* 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회
|
||||||
*/
|
*/
|
||||||
fun findByIsActiveTrueOrderByCreatedAtDesc(pageable: Pageable): List<ChatCharacter>
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT c FROM ChatCharacter c
|
||||||
|
WHERE c.isActive = true AND c.createdAt >= :since
|
||||||
|
ORDER BY c.createdAt DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findRecentSince(@Param("since") since: java.time.LocalDateTime, pageable: Pageable): Page<ChatCharacter>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이름, 설명, MBTI, 태그로 캐릭터 검색
|
* 2주 이내(파라미터 since 이상) 활성 캐릭터 개수
|
||||||
|
*/
|
||||||
|
fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이름, 설명, MBTI, 태그로 캐릭터 검색 - 페이징
|
||||||
*/
|
*/
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
|
@@ -11,14 +11,20 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal
|
|||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.cache.annotation.Cacheable
|
||||||
import org.springframework.data.domain.PageRequest
|
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
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ChatCharacterService(
|
class ChatCharacterService(
|
||||||
@@ -26,24 +32,99 @@ class ChatCharacterService(
|
|||||||
private val tagRepository: ChatCharacterTagRepository,
|
private val tagRepository: ChatCharacterTagRepository,
|
||||||
private val valueRepository: ChatCharacterValueRepository,
|
private val valueRepository: ChatCharacterValueRepository,
|
||||||
private val hobbyRepository: ChatCharacterHobbyRepository,
|
private val hobbyRepository: ChatCharacterHobbyRepository,
|
||||||
private val goalRepository: ChatCharacterGoalRepository
|
private val goalRepository: ChatCharacterGoalRepository,
|
||||||
|
private val popularCharacterQuery: PopularCharacterQuery,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회
|
* UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회
|
||||||
* 현재는 채팅방 구현 전이므로 빈 리스트 반환
|
* Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getPopularCharacters(): List<ChatCharacter> {
|
@Cacheable(
|
||||||
// 채팅방 구현 전이므로 빈 리스트 반환
|
cacheNames = ["popularCharacters_24h"],
|
||||||
return emptyList()
|
key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-character').cacheKey"
|
||||||
|
)
|
||||||
|
fun getPopularCharacters(limit: Long = 20): List<Character> {
|
||||||
|
val window = RankingWindowCalculator.now("popular-character")
|
||||||
|
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
|
||||||
|
val list = loadCharactersInOrder(topIds)
|
||||||
|
return list.map {
|
||||||
|
Character(
|
||||||
|
characterId = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCharactersInOrder(ids: List<Long>): List<ChatCharacter> {
|
||||||
|
if (ids.isEmpty()) return emptyList()
|
||||||
|
val list = chatCharacterRepository.findAllById(ids)
|
||||||
|
val map = list.associateBy { it.id }
|
||||||
|
return ids.mapNotNull { map[it] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최근 등록된 캐릭터 목록 조회 (최대 10개)
|
* 최근 등록된 캐릭터 전체보기 (페이징) - 전체 개수 포함
|
||||||
|
* - 기준: 현재 시각 기준 2주 이내 생성된 활성 캐릭터
|
||||||
|
* - 2주 이내 캐릭터가 0개라면: totalCount=20, 첫 페이지는 최근 등록 활성 캐릭터 20개, 그 외 페이지는 빈 리스트
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getNewCharacters(limit: Int = 10): List<ChatCharacter> {
|
fun getRecentCharactersPage(page: Int = 0, size: Int = 20): RecentCharactersResponse {
|
||||||
return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit))
|
val safePage = if (page < 0) 0 else page
|
||||||
|
val safeSize = when {
|
||||||
|
size <= 0 -> 20
|
||||||
|
size > 50 -> 50 // 과도한 page size 방지
|
||||||
|
else -> size
|
||||||
|
}
|
||||||
|
val since = LocalDateTime.now().minusWeeks(2)
|
||||||
|
|
||||||
|
val totalRecent = chatCharacterRepository.countByIsActiveTrueAndCreatedAtGreaterThanEqual(since)
|
||||||
|
if (totalRecent == 0L) {
|
||||||
|
if (safePage > 0) {
|
||||||
|
return RecentCharactersResponse(
|
||||||
|
totalCount = 20,
|
||||||
|
content = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val fallback = chatCharacterRepository.findByIsActiveTrue(
|
||||||
|
PageRequest.of(0, 20, Sort.by("createdAt").descending())
|
||||||
|
)
|
||||||
|
val content = fallback.content.map {
|
||||||
|
Character(
|
||||||
|
characterId = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return RecentCharactersResponse(
|
||||||
|
totalCount = 20,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pageResult = chatCharacterRepository.findRecentSince(
|
||||||
|
since,
|
||||||
|
PageRequest.of(safePage, safeSize)
|
||||||
|
)
|
||||||
|
val content = pageResult.content.map {
|
||||||
|
Character(
|
||||||
|
characterId = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RecentCharactersResponse(
|
||||||
|
totalCount = totalRecent,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -0,0 +1,54 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.service
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.QChatMessage
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.QChatParticipant
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class PopularCharacterQuery(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 집계 기준: "채팅방 전체 메시지 수"로 캐릭터 인기 집계
|
||||||
|
* - 메시지 작성자(pMsg)가 누가 되었든 해당 방의 소유 캐릭터(p=CHARACTER)의 id로 그룹핑
|
||||||
|
* - 시간 종료 경계는 배타적(<) 비교로 단순화
|
||||||
|
*/
|
||||||
|
fun findPopularCharacterIds(
|
||||||
|
windowStart: Instant,
|
||||||
|
endExclusive: Instant,
|
||||||
|
limit: Long
|
||||||
|
): List<Long> {
|
||||||
|
val m = QChatMessage.chatMessage
|
||||||
|
val p = QChatParticipant.chatParticipant
|
||||||
|
val c = QChatCharacter.chatCharacter
|
||||||
|
|
||||||
|
val start = LocalDateTime.ofInstant(windowStart, ZoneOffset.UTC)
|
||||||
|
val end = LocalDateTime.ofInstant(endExclusive, ZoneOffset.UTC)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(c.id)
|
||||||
|
.from(m)
|
||||||
|
// 방의 캐릭터 소유자 참가자(p=CHARACTER)를 통해 캐릭터 기준으로 그룹핑
|
||||||
|
.join(p).on(
|
||||||
|
p.chatRoom.id.eq(m.chatRoom.id)
|
||||||
|
.and(p.participantType.eq(ParticipantType.CHARACTER))
|
||||||
|
)
|
||||||
|
.join(c).on(c.id.eq(p.character.id))
|
||||||
|
.where(
|
||||||
|
m.createdAt.goe(start)
|
||||||
|
.and(m.createdAt.lt(end)) // 배타적 종료
|
||||||
|
.and(m.isActive.isTrue)
|
||||||
|
.and(c.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.groupBy(c.id)
|
||||||
|
.orderBy(m.id.count().desc())
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,46 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.service
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC 20:00:00을 경계로 집계 윈도우와 캐시 키를 계산한다.
|
||||||
|
*/
|
||||||
|
data class RankingWindow(
|
||||||
|
val windowStart: Instant,
|
||||||
|
val windowEnd: Instant,
|
||||||
|
val nextBoundary: Instant,
|
||||||
|
val cacheKey: String
|
||||||
|
)
|
||||||
|
|
||||||
|
object RankingWindowCalculator {
|
||||||
|
private val ZONE: ZoneId = ZoneOffset.UTC
|
||||||
|
private const val BOUNDARY_HOUR = 20 // 20:00:00 UTC
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun now(prefix: String = "popular-character"): RankingWindow {
|
||||||
|
val now = ZonedDateTime.now(ZONE)
|
||||||
|
val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE)
|
||||||
|
|
||||||
|
// 일일 순위는 "전날" 완료 구간을 보여주기 위해, 언제든 직전 경계까지만 집계한다.
|
||||||
|
// 예) 2025-09-14 20:00:00 직후에도 [2025-09-13 20:00, 2025-09-14 20:00) 윈도우를 사용
|
||||||
|
val lastBoundary = if (now.isBefore(todayBoundary)) {
|
||||||
|
// 아직 오늘 20:00 이전이면, 직전 경계는 어제 20:00
|
||||||
|
todayBoundary.minusDays(1)
|
||||||
|
} else {
|
||||||
|
// 오늘 20:00을 지났거나 같으면, 직전 경계는 오늘 20:00
|
||||||
|
todayBoundary
|
||||||
|
}
|
||||||
|
|
||||||
|
val start = lastBoundary.minusDays(1)
|
||||||
|
val endExclusive = lastBoundary
|
||||||
|
|
||||||
|
val windowStart = start.toInstant()
|
||||||
|
val windowEnd = endExclusive.minusSeconds(1).toInstant() // [start, end]
|
||||||
|
val cacheKey = "$prefix:${windowStart.epochSecond}"
|
||||||
|
// nextBoundary 필드는 기존 시그니처 유지를 위해 endExclusive(=lastBoundary)를 그대로 전달한다.
|
||||||
|
return RankingWindow(windowStart, windowEnd, endExclusive.toInstant(), cacheKey)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,65 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.original
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.CascadeType
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.OneToMany
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작(오리지널 작품) 엔티티
|
||||||
|
* - 캐릭터를 원작별로 묶기 위한 기준 엔티티
|
||||||
|
* - 각 필드는 운영에서 관리자가 입력/수정한다.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
class OriginalWork(
|
||||||
|
/** 원작 제목 */
|
||||||
|
@Column(nullable = false)
|
||||||
|
var title: String,
|
||||||
|
|
||||||
|
/** 콘텐츠 타입 (예: 웹소설, 웹툰 등) */
|
||||||
|
@Column(nullable = false)
|
||||||
|
var contentType: String,
|
||||||
|
|
||||||
|
/** 카테고리/장르 (예: 로맨스, 판타지 등) */
|
||||||
|
@Column(nullable = false)
|
||||||
|
var category: String,
|
||||||
|
|
||||||
|
/** 19금 여부 */
|
||||||
|
@Column(nullable = false)
|
||||||
|
var isAdult: Boolean = false,
|
||||||
|
|
||||||
|
/** 작품 소개 */
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
var description: String = "",
|
||||||
|
|
||||||
|
/** 원천 원작 */
|
||||||
|
@Column(nullable = true)
|
||||||
|
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() {
|
||||||
|
/** 원작 대표 이미지 S3 경로 */
|
||||||
|
var imagePath: String? = null
|
||||||
|
|
||||||
|
/** 소프트 삭제 여부 (true면 삭제된 것으로 간주) */
|
||||||
|
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()
|
||||||
|
}
|
@@ -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()
|
@@ -0,0 +1,63 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.original
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.util.Optional
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface OriginalWorkRepository : JpaRepository<OriginalWork, Long> {
|
||||||
|
fun findByTitleAndIsDeletedFalse(title: String): OriginalWork?
|
||||||
|
fun findByIdAndIsDeletedFalse(id: Long): Optional<OriginalWork>
|
||||||
|
fun findByIsDeletedFalse(pageable: Pageable): Page<OriginalWork>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제목/콘텐츠타입/카테고리 기준 부분 검색 (소프트 삭제 제외) - 무페이징 전체 목록
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT ow FROM OriginalWork ow
|
||||||
|
WHERE ow.isDeleted = false AND (
|
||||||
|
LOWER(ow.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
|
||||||
|
LOWER(ow.contentType) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
|
||||||
|
LOWER(ow.category) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
|
||||||
|
)
|
||||||
|
ORDER BY ow.createdAt DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun searchNoPaging(
|
||||||
|
@Param("searchTerm") searchTerm: String
|
||||||
|
): List<OriginalWork>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱용 원작 목록 조회 (페이징)
|
||||||
|
* - 소프트 삭제 제외
|
||||||
|
* - includeAdult=false이면 19금 제외
|
||||||
|
* - 활성 캐릭터가 하나라도 연결된 원작만 조회
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
value = """
|
||||||
|
SELECT 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
|
||||||
|
)
|
||||||
|
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 findAllForAppPage(@Param("includeAdult") includeAdult: Boolean, pageable: Pageable): Page<OriginalWork>
|
||||||
|
}
|
@@ -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()
|
||||||
|
}
|
@@ -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()
|
@@ -0,0 +1,115 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.original.controller
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkCharactersPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱용 원작(오리지널 작품) 공개 API
|
||||||
|
* 1) 목록: 로그인 불필요, 미인증 사용자는 19금 제외, 활성 캐릭터 연결된 원작만 노출
|
||||||
|
* 2) 상세: 로그인 + 본인인증 필수
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/chat/original")
|
||||||
|
class OriginalWorkController(
|
||||||
|
private val queryService: OriginalWorkQueryService,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 목록 (페이징)
|
||||||
|
* - 로그인 불필요
|
||||||
|
* - 본인인증하지 않은 경우 19금 제외
|
||||||
|
* - 활성 캐릭터가 하나라도 연결된 원작만 노출
|
||||||
|
* - 요청: page(기본 0), size(기본 20)
|
||||||
|
* - 반환: totalCount + [imageUrl, title, contentType]
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun list(
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
val includeAdult = member?.auth != null
|
||||||
|
val pageRes = queryService.listForAppPage(includeAdult, page, size)
|
||||||
|
val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) }
|
||||||
|
ApiResponse.ok(OriginalWorkListResponse(totalCount = pageRes.totalElements, content = content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 상세
|
||||||
|
* - 로그인 및 본인인증 필수
|
||||||
|
* - 반환: 이미지, 제목, 콘텐츠 타입, 카테고리, 19금 여부, 작품 소개, 원작 링크
|
||||||
|
* - 해당 원작의 활성 캐릭터 리스트 [imageUrl, name, description]
|
||||||
|
* - 캐릭터는 페이징 적용: 첫 페이지 20개
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun detail(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
|
|
||||||
|
val ow = queryService.getOriginalWork(id)
|
||||||
|
val pageRes = queryService.getActiveCharactersPage(id, page = 0, size = 20)
|
||||||
|
val characters = pageRes.content.map {
|
||||||
|
val path = it.imagePath ?: "profile/default-profile.png"
|
||||||
|
Character(
|
||||||
|
characterId = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/$path"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val response = OriginalWorkDetailResponse.from(ow, imageHost, characters)
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지정 원작에 속한 활성 캐릭터 목록 조회 (페이징)
|
||||||
|
* - 로그인 및 본인인증 필수
|
||||||
|
* - 기본 페이지 사이즈 20
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/characters")
|
||||||
|
fun listCharacters(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
|
|
||||||
|
val pageRes = queryService.getActiveCharactersPage(id, page, size)
|
||||||
|
val content = pageRes.content.map {
|
||||||
|
val path = it.imagePath ?: "profile/default-profile.png"
|
||||||
|
Character(
|
||||||
|
characterId = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/$path"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ApiResponse.ok(
|
||||||
|
OriginalWorkCharactersPageResponse(
|
||||||
|
totalCount = pageRes.totalElements,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,95 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.original.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱용 원작 목록 아이템 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkListItemResponse(
|
||||||
|
@JsonProperty("id") val id: Long,
|
||||||
|
@JsonProperty("imageUrl") val imageUrl: String?,
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("contentType") val contentType: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkListItemResponse {
|
||||||
|
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${entity.imagePath}"
|
||||||
|
} else {
|
||||||
|
entity.imagePath
|
||||||
|
}
|
||||||
|
return OriginalWorkListItemResponse(
|
||||||
|
id = entity.id!!,
|
||||||
|
imageUrl = fullImage,
|
||||||
|
title = entity.title,
|
||||||
|
contentType = entity.contentType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱용 원작 목록 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkListResponse(
|
||||||
|
@JsonProperty("totalCount") val totalCount: Long,
|
||||||
|
@JsonProperty("content") val content: List<OriginalWorkListItemResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱용 원작 상세 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkDetailResponse(
|
||||||
|
@JsonProperty("imageUrl") val imageUrl: String?,
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("contentType") val contentType: String,
|
||||||
|
@JsonProperty("category") val category: String,
|
||||||
|
@JsonProperty("isAdult") val isAdult: Boolean,
|
||||||
|
@JsonProperty("description") val description: String,
|
||||||
|
@JsonProperty("originalWork") val originalWork: 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>
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(
|
||||||
|
entity: OriginalWork,
|
||||||
|
imageHost: String = "",
|
||||||
|
characters: List<Character>
|
||||||
|
): OriginalWorkDetailResponse {
|
||||||
|
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${entity.imagePath}"
|
||||||
|
} else {
|
||||||
|
entity.imagePath
|
||||||
|
}
|
||||||
|
return OriginalWorkDetailResponse(
|
||||||
|
imageUrl = fullImage,
|
||||||
|
title = entity.title,
|
||||||
|
contentType = entity.contentType,
|
||||||
|
category = entity.category,
|
||||||
|
isAdult = entity.isAdult,
|
||||||
|
description = entity.description,
|
||||||
|
originalWork = entity.originalWork,
|
||||||
|
originalLink = entity.originalLink,
|
||||||
|
writer = entity.writer,
|
||||||
|
studio = entity.studio,
|
||||||
|
originalLinks = entity.originalLinks.map { it.url },
|
||||||
|
tags = entity.tagMappings.map { it.tag.tag },
|
||||||
|
characters = characters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱용: 원작별 활성 캐릭터 페이징 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkCharactersPageResponse(
|
||||||
|
@JsonProperty("totalCount") val totalCount: Long,
|
||||||
|
@JsonProperty("content") val content: List<Character>
|
||||||
|
)
|
@@ -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?
|
||||||
|
}
|
@@ -0,0 +1,68 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.original.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||||
|
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.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱 사용자용 원작(오리지널 작품) 조회 서비스
|
||||||
|
* - 목록/상세 조회 전용
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class OriginalWorkQueryService(
|
||||||
|
private val originalWorkRepository: OriginalWorkRepository,
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 앱용 원작 목록 조회 (페이징)
|
||||||
|
* @param includeAdult true면 19금 포함, false면 제외
|
||||||
|
* @param page 페이지 번호(0부터)
|
||||||
|
* @param size 페이지 크기(기본 20, 최대 50)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun listForAppPage(includeAdult: Boolean, page: Int = 0, size: Int = 20): Page<OriginalWork> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 상세 조회 (소프트 삭제 제외)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getOriginalWork(id: Long): OriginalWork {
|
||||||
|
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getActiveCharactersPage(originalWorkId: Long, page: Int = 0, size: Int = 20): Page<ChatCharacter> {
|
||||||
|
// 원작 존재 및 소프트 삭제 여부 확인
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@@ -86,6 +86,10 @@ class ChatRoomQuotaService(
|
|||||||
// 1) 유료 우선 사용: 글로벌에 영향 없음
|
// 1) 유료 우선 사용: 글로벌에 영향 없음
|
||||||
if (quota.remainingPaid > 0) {
|
if (quota.remainingPaid > 0) {
|
||||||
quota.remainingPaid -= 1
|
quota.remainingPaid -= 1
|
||||||
|
// 유료 차감 후, 무료와 유료가 모두 0이 되는 시점이면 다음 무료 충전을 예약한다.
|
||||||
|
if (quota.remainingPaid == 0 && quota.remainingFree == 0 && quota.nextRechargeAt == null) {
|
||||||
|
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
|
||||||
|
}
|
||||||
val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid)
|
val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid)
|
||||||
return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid)
|
return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid)
|
||||||
}
|
}
|
||||||
@@ -94,16 +98,16 @@ class ChatRoomQuotaService(
|
|||||||
val globalFree = globalFreeProvider()
|
val globalFree = globalFreeProvider()
|
||||||
if (globalFree <= 0) {
|
if (globalFree <= 0) {
|
||||||
// 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가
|
// 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가
|
||||||
throw SodaException("무료 쿼터가 소진되었습니다. 글로벌 무료 충전 이후 이용해 주세요.")
|
throw SodaException("오늘의 무료 채팅이 모두 소진되었습니다. 내일 다시 이용해 주세요.")
|
||||||
}
|
}
|
||||||
if (quota.remainingFree <= 0) {
|
if (quota.remainingFree <= 0) {
|
||||||
// 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가
|
// 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가
|
||||||
val waitMillis = quota.nextRechargeAt
|
val waitMillis = quota.nextRechargeAt
|
||||||
if (waitMillis != null && waitMillis > nowMillis) {
|
if (waitMillis == null) {
|
||||||
throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 무료 충전 이후 이용해 주세요.")
|
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
|
||||||
} else {
|
|
||||||
throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 잠시 후 다시 시도해 주세요.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw SodaException("무료 채팅이 모두 소진되었습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 둘 다 가능 → 차감
|
// 둘 다 가능 → 차감
|
||||||
|
@@ -123,6 +123,16 @@ class RedisConfig(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 24시간 TTL 캐시: 인기 캐릭터 집계용
|
||||||
|
cacheConfigMap["popularCharacters_24h"] = RedisCacheConfiguration.defaultCacheConfig()
|
||||||
|
.entryTtl(Duration.ofHours(24))
|
||||||
|
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
|
||||||
|
.serializeValuesWith(
|
||||||
|
RedisSerializationContext.SerializationPair.fromSerializer(
|
||||||
|
GenericJackson2JsonRedisSerializer()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return RedisCacheManager.builder(redisConnectionFactory)
|
return RedisCacheManager.builder(redisConnectionFactory)
|
||||||
.cacheDefaults(defaultCacheConfig)
|
.cacheDefaults(defaultCacheConfig)
|
||||||
.withInitialCacheConfigurations(cacheConfigMap)
|
.withInitialCacheConfigurations(cacheConfigMap)
|
||||||
|
@@ -95,6 +95,7 @@ class SecurityConfig(
|
|||||||
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
||||||
|
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
.and()
|
.and()
|
||||||
.build()
|
.build()
|
||||||
|
Reference in New Issue
Block a user