Compare commits
86 Commits
f61c45e89a
...
test
Author | SHA1 | Date | |
---|---|---|---|
59ca353b25 | |||
6bc65ec412 | |||
97e95b51ab | |||
a6dfa81ba6 | |||
dad517a953 | |||
eb2d093b02 | |||
67186bba55 | |||
edeecad2ce | |||
387f5388d9 | |||
adcaa0a5fd | |||
47b2c1cb93 | |||
7f3589dcfb | |||
b134c28c10 | |||
41c8d0367d | |||
3b148d549e | |||
b6c96af8a2 | |||
4904625488 | |||
0574f4f629 | |||
4adc3e127c | |||
dd0a1c2293 | |||
a07407417c | |||
e33e3b43b7 | |||
634bf759ca | |||
0ed29c6097 | |||
b752434fbb | |||
eec63cc7b2 | |||
3dc9dd1f35 | |||
88e287067b | |||
27a3f450ef | |||
58a46a09c3 | |||
83a1316a64 | |||
f05f146c89 | |||
3782062f4a | |||
fd83abb46c | |||
a9d1b9f4a6 | |||
ad69dad725 | |||
2f55303d16 | |||
3a9128a894 | |||
def6296d4d | |||
034472defa | |||
550e4ac9ce | |||
d26e0a89f6 | |||
6767afdd35 | |||
a58de0cf92 | |||
df93f0e0ce | |||
0b54b126db | |||
a94cf8dad9 | |||
2c3e12a42c | |||
c4dbdc1b8e | |||
42ed4692af | |||
258943535c | |||
0347d767f0 | |||
48b0190242 | |||
15d0952de8 | |||
84ebc1762b | |||
a096b16945 | |||
37ac52116a | |||
fcb68be006 | |||
048c48d754 | |||
6ecac8d331 | |||
8b1dd7cb95 | |||
5a58fe9077 | |||
12574dbe46 | |||
b3e7c00232 | |||
692e060f6d | |||
2ac0a5f896 | |||
f8be99547a | |||
7dd585c3dd | |||
7355949c1e | |||
539b9fb2b2 | |||
99386c6d53 | |||
abbd73ac00 | |||
4bee95c8a6 | |||
090fc81829 | |||
75100cacec | |||
13fd262c94 | |||
8451cdfb80 | |||
c8841856c0 | |||
2a30b28e43 | |||
dd6849b840 | |||
ca27903e45 | |||
aeab6eddc2 | |||
1c0d40aed9 | |||
1444afaae2 | |||
a05bc369b7 | |||
6c7f411869 |
@@ -7,5 +7,5 @@ indent_size = 4
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
max_line_length = 130
|
||||
tab_width = 4
|
||||
|
@@ -39,7 +39,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -75,7 +78,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -142,7 +148,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(order.isActive.isTrue)
|
||||
.groupBy(
|
||||
member.id,
|
||||
@@ -230,7 +239,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
@@ -251,7 +263,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -281,7 +296,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -301,7 +319,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -331,7 +352,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -351,7 +375,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
@@ -382,7 +409,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
|
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
@@ -9,12 +10,29 @@ import javax.persistence.OneToOne
|
||||
|
||||
@Entity
|
||||
data class CreatorSettlementRatio(
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
val contentSettlementRatio: Int,
|
||||
val communitySettlementRatio: Int
|
||||
var subsidy: Int,
|
||||
var liveSettlementRatio: Int,
|
||||
var contentSettlementRatio: Int,
|
||||
var communitySettlementRatio: Int
|
||||
) : BaseEntity() {
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
@@ -27,4 +28,14 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
|
||||
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))
|
||||
}
|
||||
|
@@ -7,7 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CreatorSettlementRatioRepository :
|
||||
JpaRepository<CreatorSettlementRatio, Long>,
|
||||
CreatorSettlementRatioQueryRepository
|
||||
CreatorSettlementRatioQueryRepository {
|
||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
||||
}
|
||||
|
||||
interface CreatorSettlementRatioQueryRepository {
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||
@@ -21,6 +23,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCreatorSettlementRatioItem(
|
||||
member.id,
|
||||
member.nickname,
|
||||
creatorSettlementRatio.subsidy,
|
||||
creatorSettlementRatio.liveSettlementRatio,
|
||||
@@ -30,6 +33,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
)
|
||||
.from(creatorSettlementRatio)
|
||||
.innerJoin(creatorSettlementRatio.member, member)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.orderBy(creatorSettlementRatio.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -40,6 +44,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(creatorSettlementRatio.id)
|
||||
.from(creatorSettlementRatio)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
@@ -14,8 +14,6 @@ class CreatorSettlementRatioService(
|
||||
) {
|
||||
@Transactional
|
||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||
val creatorSettlementRatio = request.toEntity()
|
||||
|
||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||
|
||||
@@ -23,10 +21,52 @@ class CreatorSettlementRatioService(
|
||||
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
|
||||
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)
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||
|
@@ -8,6 +8,7 @@ data class GetCreatorSettlementRatioResponse(
|
||||
)
|
||||
|
||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
|
@@ -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.fasterxml.jackson.databind.ObjectMapper
|
||||
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.ExternalApiResponse
|
||||
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.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
@@ -18,8 +20,6 @@ import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory
|
||||
import org.springframework.retry.annotation.Backoff
|
||||
import org.springframework.retry.annotation.Retryable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
@@ -39,6 +39,7 @@ class AdminChatCharacterController(
|
||||
private val service: ChatCharacterService,
|
||||
private val adminService: AdminChatCharacterService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val originalWorkService: AdminOriginalWorkService,
|
||||
|
||||
@Value("\${weraser.api-key}")
|
||||
private val apiKey: String,
|
||||
@@ -70,6 +71,26 @@ class AdminChatCharacterController(
|
||||
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
|
||||
*
|
||||
@@ -86,11 +107,6 @@ class AdminChatCharacterController(
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@Retryable(
|
||||
value = [Exception::class],
|
||||
maxAttempts = 3,
|
||||
backoff = Backoff(delay = 1000)
|
||||
)
|
||||
fun registerCharacter(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
@@ -144,6 +160,11 @@ class AdminChatCharacterController(
|
||||
chatCharacter.imagePath = imagePath
|
||||
service.saveChatCharacter(chatCharacter)
|
||||
|
||||
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||
if (request.originalWorkId != null) {
|
||||
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@@ -236,11 +257,6 @@ class AdminChatCharacterController(
|
||||
* @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
@Retryable(
|
||||
value = [Exception::class],
|
||||
maxAttempts = 3,
|
||||
backoff = Backoff(delay = 1000)
|
||||
)
|
||||
fun updateCharacter(
|
||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||
@RequestPart("request") requestString: String
|
||||
@@ -259,7 +275,8 @@ class AdminChatCharacterController(
|
||||
val hasDbOnlyChanges =
|
||||
request.originalTitle != null ||
|
||||
request.originalLink != null ||
|
||||
request.characterType != null
|
||||
request.characterType != null ||
|
||||
request.originalWorkId != null
|
||||
|
||||
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
||||
throw SodaException("변경된 데이터가 없습니다.")
|
||||
@@ -298,6 +315,12 @@ class AdminChatCharacterController(
|
||||
request = request
|
||||
)
|
||||
|
||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||
if (request.originalWorkId != null) {
|
||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,82 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
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.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/character/curation")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class CharacterCurationAdminController(
|
||||
private val service: CharacterCurationAdminService,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
@GetMapping("/list")
|
||||
fun listAll(): ApiResponse<List<CharacterCurationListItemResponse>> =
|
||||
ApiResponse.ok(service.listAll())
|
||||
|
||||
@GetMapping("/{curationId}/characters")
|
||||
fun listCharacters(
|
||||
@PathVariable curationId: Long
|
||||
): ApiResponse<List<CharacterCurationCharacterItemResponse>> {
|
||||
val characters = service.listCharacters(curationId)
|
||||
val items = characters.map {
|
||||
CharacterCurationCharacterItemResponse(
|
||||
id = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
return ApiResponse.ok(items)
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
fun register(@RequestBody request: CharacterCurationRegisterRequest) =
|
||||
ApiResponse.ok(service.register(request).id)
|
||||
|
||||
@PutMapping("/update")
|
||||
fun update(@RequestBody request: CharacterCurationUpdateRequest) =
|
||||
ApiResponse.ok(service.update(request).id)
|
||||
|
||||
@DeleteMapping("/{curationId}")
|
||||
fun delete(@PathVariable curationId: Long) =
|
||||
ApiResponse.ok(service.softDelete(curationId))
|
||||
|
||||
@PutMapping("/reorder")
|
||||
fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) =
|
||||
ApiResponse.ok(service.reorder(request.ids))
|
||||
|
||||
@PostMapping("/{curationId}/characters")
|
||||
fun addCharacter(
|
||||
@PathVariable curationId: Long,
|
||||
@RequestBody request: CharacterCurationAddCharacterRequest
|
||||
): ApiResponse<Boolean> {
|
||||
val ids = request.characterIds.filter { it > 0 }.distinct()
|
||||
if (ids.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
||||
service.addCharacters(curationId, ids)
|
||||
return ApiResponse.ok(true)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{curationId}/characters/{characterId}")
|
||||
fun removeCharacter(
|
||||
@PathVariable curationId: Long,
|
||||
@PathVariable characterId: Long
|
||||
) = ApiResponse.ok(service.removeCharacter(curationId, characterId))
|
||||
|
||||
@PutMapping("/{curationId}/characters/reorder")
|
||||
fun reorderCharacters(
|
||||
@PathVariable curationId: Long,
|
||||
@RequestBody request: CharacterCurationReorderCharactersRequest
|
||||
) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds))
|
||||
}
|
@@ -0,0 +1,45 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||
|
||||
data class CharacterCurationRegisterRequest(
|
||||
val title: String,
|
||||
val isAdult: Boolean = false,
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
|
||||
data class CharacterCurationUpdateRequest(
|
||||
val id: Long,
|
||||
val title: String? = null,
|
||||
val isAdult: Boolean? = null,
|
||||
val isActive: Boolean? = null
|
||||
)
|
||||
|
||||
data class CharacterCurationOrderUpdateRequest(
|
||||
val ids: List<Long>
|
||||
)
|
||||
|
||||
data class CharacterCurationAddCharacterRequest(
|
||||
val characterIds: List<Long>
|
||||
)
|
||||
|
||||
data class CharacterCurationReorderCharactersRequest(
|
||||
val characterIds: List<Long>
|
||||
)
|
||||
|
||||
data class CharacterCurationListItemResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val isAdult: Boolean,
|
||||
val isActive: Boolean,
|
||||
val characterCount: Int
|
||||
)
|
||||
|
||||
// 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO
|
||||
// id, name, description, 이미지 URL
|
||||
// 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성
|
||||
|
||||
data class CharacterCurationCharacterItemResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val imageUrl: String
|
||||
)
|
@@ -0,0 +1,153 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class CharacterCurationAdminService(
|
||||
private val curationRepository: CharacterCurationRepository,
|
||||
private val mappingRepository: CharacterCurationMappingRepository,
|
||||
private val characterRepository: ChatCharacterRepository
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun register(request: CharacterCurationRegisterRequest): CharacterCuration {
|
||||
val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1
|
||||
val curation = CharacterCuration(
|
||||
title = request.title,
|
||||
isAdult = request.isAdult,
|
||||
isActive = request.isActive,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
return curationRepository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
|
||||
val curation = curationRepository.findById(request.id)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") }
|
||||
|
||||
request.title?.let { curation.title = it }
|
||||
request.isAdult?.let { curation.isAdult = it }
|
||||
request.isActive?.let { curation.isActive = it }
|
||||
|
||||
return curationRepository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun softDelete(curationId: Long) {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
curation.isActive = false
|
||||
curationRepository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun reorder(ids: List<Long>) {
|
||||
ids.forEachIndexed { index, id ->
|
||||
val curation = curationRepository.findById(id)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
|
||||
curation.sortOrder = index + 1
|
||||
curationRepository.save(curation)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addCharacters(curationId: Long, characterIds: List<Long>) {
|
||||
if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
||||
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
|
||||
|
||||
val uniqueIds = characterIds.filter { it > 0 }.distinct()
|
||||
if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다")
|
||||
|
||||
// 활성 캐릭터만 조회 (조회 단계에서 검증 포함)
|
||||
val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds)
|
||||
val characterMap = characters.associateBy { it.id!! }
|
||||
|
||||
// 조회 결과에 존재하는 캐릭터만 유효
|
||||
val validIds = uniqueIds.filter { id -> characterMap.containsKey(id) }
|
||||
|
||||
val existingMappings = mappingRepository.findByCuration(curation)
|
||||
val existingCharacterIds = existingMappings.mapNotNull { it.chatCharacter.id }.toSet()
|
||||
var nextOrder = (existingMappings.maxOfOrNull { it.sortOrder } ?: 0) + 1
|
||||
|
||||
val toSave = mutableListOf<CharacterCurationMapping>()
|
||||
validIds.forEach { id ->
|
||||
if (!existingCharacterIds.contains(id)) {
|
||||
val character = characterMap[id] ?: return@forEach
|
||||
toSave += CharacterCurationMapping(
|
||||
curation = curation,
|
||||
chatCharacter = character,
|
||||
sortOrder = nextOrder++
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (toSave.isNotEmpty()) {
|
||||
mappingRepository.saveAll(toSave)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun removeCharacter(curationId: Long, characterId: Long) {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
val mappings = mappingRepository.findByCuration(curation)
|
||||
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
|
||||
?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId")
|
||||
mappingRepository.delete(target)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
val mappings = mappingRepository.findByCuration(curation)
|
||||
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
|
||||
|
||||
characterIds.forEachIndexed { index, cid ->
|
||||
val mapping = mappingByCharacterId[cid]
|
||||
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
|
||||
mapping.sortOrder = index + 1
|
||||
mappingRepository.save(mapping)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listAll(): List<CharacterCurationListItemResponse> {
|
||||
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
|
||||
if (curations.isEmpty()) return emptyList()
|
||||
|
||||
// DB 집계로 활성 캐릭터 수 카운트
|
||||
val counts = mappingRepository.countActiveCharactersByCurations(curations)
|
||||
val countByCurationId: Map<Long, Int> = counts.associate { it.curationId to it.count.toInt() }
|
||||
|
||||
return curations.map { curation ->
|
||||
CharacterCurationListItemResponse(
|
||||
id = curation.id!!,
|
||||
title = curation.title,
|
||||
isAdult = curation.isAdult,
|
||||
isActive = curation.isActive,
|
||||
characterCount = countByCurationId[curation.id!!] ?: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listCharacters(curationId: Long): List<ChatCharacter> {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
|
||||
return mappings.map { it.chatCharacter }
|
||||
}
|
||||
}
|
@@ -2,6 +2,10 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
|
||||
/**
|
||||
* 관리자 캐릭터 상세 응답 DTO
|
||||
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
|
||||
*/
|
||||
data class ChatCharacterDetailResponse(
|
||||
val id: Long,
|
||||
val characterUUID: String,
|
||||
@@ -24,7 +28,8 @@ data class ChatCharacterDetailResponse(
|
||||
val relationships: List<RelationshipResponse>,
|
||||
val personalities: List<PersonalityResponse>,
|
||||
val backgrounds: List<BackgroundResponse>,
|
||||
val memories: List<MemoryResponse>
|
||||
val memories: List<MemoryResponse>,
|
||||
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
|
||||
) {
|
||||
companion object {
|
||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||
@@ -34,6 +39,20 @@ data class ChatCharacterDetailResponse(
|
||||
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(
|
||||
id = chatCharacter.id!!,
|
||||
characterUUID = chatCharacter.characterUUID,
|
||||
@@ -71,7 +90,8 @@ data class ChatCharacterDetailResponse(
|
||||
},
|
||||
memories = chatCharacter.memories.map {
|
||||
MemoryResponse(it.title, it.content, it.emotion)
|
||||
}
|
||||
},
|
||||
originalWork = originalWorkBrief
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -101,3 +121,12 @@ data class RelationshipResponse(
|
||||
val relationshipType: 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("originalTitle") val originalTitle: String? = null,
|
||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||
@JsonProperty("characterType") val characterType: String? = null,
|
||||
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
||||
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
||||
@@ -75,6 +76,7 @@ data class ChatCharacterUpdateRequest(
|
||||
@JsonProperty("appearance") val appearance: String? = null,
|
||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||
@JsonProperty("characterType") val characterType: String? = null,
|
||||
@JsonProperty("isActive") val isActive: Boolean? = 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
|
||||
|
||||
/**
|
||||
* 캐릭터 검색 결과 응답 DTO
|
||||
* 원작 연결된 캐릭터 결과 응답 DTO
|
||||
*/
|
||||
data class ChatCharacterSearchResponse(
|
||||
data class OriginalWorkChatCharacterResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val imagePath: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(character: ChatCharacter, imageHost: String): ChatCharacterSearchResponse {
|
||||
return ChatCharacterSearchResponse(
|
||||
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
|
||||
return OriginalWorkChatCharacterResponse(
|
||||
id = character.id!!,
|
||||
name = character.name,
|
||||
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 content: List<ChatCharacterSearchResponse>
|
||||
val content: List<OriginalWorkChatCharacterResponse>
|
||||
)
|
@@ -0,0 +1,170 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.image
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.utils.ImageBlurUtil
|
||||
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
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/character/image")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminCharacterImageController(
|
||||
private val imageService: CharacterImageService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val imageCloudFront: ImageContentCloudFront,
|
||||
|
||||
@Value("\${cloud.aws.s3.content-bucket}")
|
||||
private val s3Bucket: String,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val freeBucket: String
|
||||
) {
|
||||
|
||||
@GetMapping("/list")
|
||||
fun list(@RequestParam characterId: Long) = run {
|
||||
val expiration = 5L * 60L * 1000L // 5분
|
||||
val list = imageService.listActiveByCharacter(characterId)
|
||||
.map { img ->
|
||||
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||
AdminCharacterImageResponse.fromWithUrl(img, signedUrl)
|
||||
}
|
||||
ApiResponse.ok(list)
|
||||
}
|
||||
|
||||
@GetMapping("/{imageId}")
|
||||
fun detail(@PathVariable imageId: Long) = run {
|
||||
val img = imageService.getById(imageId)
|
||||
val expiration = 5L * 60L * 1000L // 5분
|
||||
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||
ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl))
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
fun register(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java)
|
||||
|
||||
// 업로드 키 생성
|
||||
val s3Key = buildS3Key(characterId = request.characterId)
|
||||
|
||||
// 원본 저장 (content-bucket)
|
||||
val imagePath = saveImageToBucket(s3Key, image, s3Bucket)
|
||||
|
||||
// 블러 생성 및 저장 (무료 이미지 버킷)
|
||||
val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket)
|
||||
|
||||
imageService.registerImage(
|
||||
characterId = request.characterId,
|
||||
imagePath = imagePath,
|
||||
blurImagePath = blurImagePath,
|
||||
imagePriceCan = request.imagePriceCan,
|
||||
messagePriceCan = request.messagePriceCan,
|
||||
isAdult = request.isAdult,
|
||||
triggers = request.triggers ?: emptyList()
|
||||
)
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@PutMapping("/{imageId}/triggers")
|
||||
fun updateTriggers(
|
||||
@PathVariable imageId: Long,
|
||||
@RequestBody request: UpdateCharacterImageTriggersRequest
|
||||
) = run {
|
||||
if (!request.triggers.isNullOrEmpty()) {
|
||||
imageService.updateTriggers(imageId, request.triggers)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{imageId}")
|
||||
fun delete(@PathVariable imageId: Long) = run {
|
||||
imageService.deleteImage(imageId)
|
||||
ApiResponse.ok(null, "이미지가 삭제되었습니다.")
|
||||
}
|
||||
|
||||
@PutMapping("/orders")
|
||||
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
|
||||
if (request.characterId == null) throw SodaException("characterId는 필수입니다")
|
||||
imageService.updateOrders(request.characterId, request.ids)
|
||||
ApiResponse.ok(null, "정렬 순서가 변경되었습니다.")
|
||||
}
|
||||
|
||||
private fun buildS3Key(characterId: Long): String {
|
||||
val fileName = generateFileName("character-image")
|
||||
return "characters/$characterId/images/$fileName"
|
||||
}
|
||||
|
||||
private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = bucket,
|
||||
filePath = filePath,
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||
try {
|
||||
// 멀티파트를 BufferedImage로 읽기
|
||||
val bytes = image.bytes
|
||||
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
||||
?: throw SodaException("이미지 포맷을 인식할 수 없습니다.")
|
||||
val blurred = ImageBlurUtil.blurFast(bimg)
|
||||
|
||||
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
||||
val baos = java.io.ByteArrayOutputStream()
|
||||
val format = when (image.contentType?.lowercase()) {
|
||||
"image/png" -> "png"
|
||||
else -> "jpg"
|
||||
}
|
||||
javax.imageio.ImageIO.write(blurred, format, baos)
|
||||
val inputStream = java.io.ByteArrayInputStream(baos.toByteArray())
|
||||
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = baos.size().toLong()
|
||||
metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg"
|
||||
|
||||
return s3Uploader.upload(
|
||||
inputStream = inputStream,
|
||||
bucket = bucket,
|
||||
filePath = filePath,
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,53 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.image.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||
|
||||
// 요청 DTOs
|
||||
|
||||
data class RegisterCharacterImageRequest(
|
||||
@JsonProperty("characterId") val characterId: Long,
|
||||
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
|
||||
@JsonProperty("messagePriceCan") val messagePriceCan: Long,
|
||||
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
||||
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||
)
|
||||
|
||||
data class UpdateCharacterImageTriggersRequest(
|
||||
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||
)
|
||||
|
||||
data class UpdateCharacterImageOrdersRequest(
|
||||
@JsonProperty("characterId") val characterId: Long?,
|
||||
@JsonProperty("ids") val ids: List<Long>
|
||||
)
|
||||
|
||||
// 응답 DTOs
|
||||
|
||||
data class AdminCharacterImageResponse(
|
||||
val id: Long,
|
||||
val characterId: Long,
|
||||
val imagePriceCan: Long,
|
||||
val messagePriceCan: Long,
|
||||
val imageUrl: String,
|
||||
val triggers: List<String>,
|
||||
val isAdult: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse {
|
||||
return base(entity, signedUrl)
|
||||
}
|
||||
|
||||
private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse {
|
||||
return AdminCharacterImageResponse(
|
||||
id = entity.id!!,
|
||||
characterId = entity.chatCharacter.id!!,
|
||||
imagePriceCan = entity.imagePriceCan,
|
||||
messagePriceCan = entity.messagePriceCan,
|
||||
imageUrl = url,
|
||||
triggers = entity.triggerMappings.map { it.tag.word },
|
||||
isAdult = entity.isAdult
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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.ChatCharacterListPageResponse
|
||||
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.common.SodaException
|
||||
import org.springframework.data.domain.Page
|
||||
@@ -65,20 +64,15 @@ class AdminChatCharacterService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반)
|
||||
*
|
||||
* @param searchTerm 검색어
|
||||
* @param pageable 페이징 정보
|
||||
* @param imageHost 이미지 호스트 URL
|
||||
* @return 검색된 캐릭터 목록 (페이징)
|
||||
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun searchCharacters(
|
||||
searchTerm: String,
|
||||
pageable: Pageable,
|
||||
imageHost: String = ""
|
||||
): Page<ChatCharacterSearchResponse> {
|
||||
): Page<ChatCharacterListResponse> {
|
||||
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)
|
||||
}
|
||||
}
|
@@ -0,0 +1,48 @@
|
||||
package kr.co.vividnext.sodalive.aws.cloudfront
|
||||
|
||||
import com.amazonaws.services.cloudfront.CloudFrontUrlSigner
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import java.security.KeyFactory
|
||||
import java.security.PrivateKey
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* 이미지(CloudFront) 서명 URL 생성기
|
||||
* - cloud.aws.cloud-front.* 설정을 사용
|
||||
*/
|
||||
@Component
|
||||
class ImageContentCloudFront(
|
||||
@Value("\${cloud.aws.content-cloud-front.host}")
|
||||
private val cloudfrontDomain: String,
|
||||
|
||||
@Value("\${cloud.aws.content-cloud-front.private-key-file-path}")
|
||||
private val privateKeyFilePath: String,
|
||||
|
||||
@Value("\${cloud.aws.content-cloud-front.key-pair-id}")
|
||||
private val keyPairId: String
|
||||
) {
|
||||
fun generateSignedURL(
|
||||
resourcePath: String,
|
||||
expirationTimeMillis: Long
|
||||
): String {
|
||||
val privateKey = loadPrivateKey(privateKeyFilePath)
|
||||
return CloudFrontUrlSigner.getSignedURLWithCannedPolicy(
|
||||
"$cloudfrontDomain/$resourcePath",
|
||||
keyPairId,
|
||||
privateKey,
|
||||
Date(System.currentTimeMillis() + expirationTimeMillis)
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadPrivateKey(resourceName: String): PrivateKey {
|
||||
val path = Paths.get(resourceName)
|
||||
val bytes = Files.readAllBytes(path)
|
||||
val keySpec = PKCS8EncodedKeySpec(bytes)
|
||||
val keyFactory = KeyFactory.getInstance("RSA")
|
||||
return keyFactory.generatePrivate(keySpec)
|
||||
}
|
||||
}
|
@@ -72,6 +72,10 @@ class CanService(private val repository: CanRepository) {
|
||||
CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}"
|
||||
CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}"
|
||||
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
|
||||
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
|
||||
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
|
||||
}
|
||||
|
||||
val createdAt = it.createdAt!!
|
||||
|
@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.order.Order
|
||||
@@ -37,6 +38,8 @@ class CanPaymentService(
|
||||
memberId: Long,
|
||||
needCan: Int,
|
||||
canUsage: CanUsage,
|
||||
chatRoomId: Long? = null,
|
||||
characterId: Long? = null,
|
||||
isSecret: Boolean = false,
|
||||
liveRoom: LiveRoom? = null,
|
||||
order: Order? = null,
|
||||
@@ -109,6 +112,14 @@ class CanPaymentService(
|
||||
recipientId = liveRoom.member!!.id!!
|
||||
useCan.room = liveRoom
|
||||
useCan.member = member
|
||||
} else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE && chatRoomId != null && characterId != null) {
|
||||
useCan.member = member
|
||||
useCan.chatRoomId = chatRoomId
|
||||
useCan.characterId = characterId
|
||||
} else if (canUsage == CanUsage.CHAT_ROOM_RESET) {
|
||||
useCan.member = member
|
||||
useCan.chatRoomId = chatRoomId
|
||||
useCan.characterId = characterId
|
||||
} else {
|
||||
throw SodaException("잘못된 요청입니다.")
|
||||
}
|
||||
@@ -327,4 +338,98 @@ class CanPaymentService(
|
||||
chargeRepository.save(charge)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun spendCanForCharacterImage(
|
||||
memberId: Long,
|
||||
needCan: Int,
|
||||
image: CharacterImage,
|
||||
container: String
|
||||
) {
|
||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
|
||||
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
||||
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
||||
throw SodaException(
|
||||
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
|
||||
"캔이 부족합니다. 충전 후 이용해 주세요."
|
||||
)
|
||||
}
|
||||
|
||||
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
||||
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
}
|
||||
|
||||
val useCan = UseCan(
|
||||
canUsage = CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||
can = useChargeCan?.total ?: 0,
|
||||
rewardCan = useRewardCan.total,
|
||||
isSecret = false
|
||||
)
|
||||
useCan.member = member
|
||||
useCan.characterImage = image
|
||||
|
||||
useCanRepository.save(useCan)
|
||||
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun spendCanForChatMessage(
|
||||
memberId: Long,
|
||||
needCan: Int,
|
||||
message: kr.co.vividnext.sodalive.chat.room.ChatMessage,
|
||||
container: String
|
||||
) {
|
||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
|
||||
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
||||
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
||||
throw SodaException(
|
||||
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
|
||||
"캔이 부족합니다. 충전 후 이용해 주세요."
|
||||
)
|
||||
}
|
||||
|
||||
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
||||
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
}
|
||||
|
||||
val useCan = UseCan(
|
||||
canUsage = CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||
can = useChargeCan?.total ?: 0,
|
||||
rewardCan = useRewardCan.total,
|
||||
isSecret = false
|
||||
)
|
||||
useCan.member = member
|
||||
useCan.chatMessage = message
|
||||
// 이미지 메시지의 경우 이미지 연관도 함께 기록
|
||||
message.characterImage?.let { img ->
|
||||
useCan.characterImage = img
|
||||
}
|
||||
|
||||
useCanRepository.save(useCan)
|
||||
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||
}
|
||||
}
|
||||
|
@@ -9,5 +9,9 @@ enum class CanUsage {
|
||||
SPIN_ROULETTE,
|
||||
PAID_COMMUNITY_POST,
|
||||
ALARM_SLOT,
|
||||
AUDITION_VOTE
|
||||
AUDITION_VOTE,
|
||||
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
||||
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
|
||||
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
|
||||
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.can.use
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.AuditionApplicant
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||
import kr.co.vividnext.sodalive.chat.room.ChatMessage
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.order.Order
|
||||
@@ -28,7 +30,11 @@ data class UseCan(
|
||||
|
||||
var isRefund: Boolean = false,
|
||||
|
||||
val isSecret: Boolean = false
|
||||
val isSecret: Boolean = false,
|
||||
|
||||
// 채팅 연동을 위한 식별자 (옵션)
|
||||
var chatRoomId: Long? = null,
|
||||
var characterId: Long? = null
|
||||
) : BaseEntity() {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
@@ -58,6 +64,16 @@ data class UseCan(
|
||||
@JoinColumn(name = "audition_applicant_id", nullable = true)
|
||||
var auditionApplicant: AuditionApplicant? = null
|
||||
|
||||
// 메시지를 통한 구매 연관 (옵션)
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_message_id", nullable = true)
|
||||
var chatMessage: ChatMessage? = null
|
||||
|
||||
// 캐릭터 이미지 연관 (메시지 구매/단독 구매 공통 사용)
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "character_image_id", nullable = true)
|
||||
var characterImage: CharacterImage? = null
|
||||
|
||||
@OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL])
|
||||
val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf()
|
||||
}
|
||||
|
@@ -6,10 +6,22 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository
|
||||
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository {
|
||||
// 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외)
|
||||
fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn(
|
||||
memberId: Long,
|
||||
imageId: Long,
|
||||
usages: Collection<CanUsage>
|
||||
): Boolean
|
||||
}
|
||||
|
||||
interface UseCanQueryRepository {
|
||||
fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean
|
||||
fun countPurchasedActiveImagesByCharacter(
|
||||
memberId: Long,
|
||||
characterId: Long,
|
||||
usages: Collection<CanUsage>
|
||||
): Long
|
||||
}
|
||||
|
||||
class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository {
|
||||
@@ -26,4 +38,24 @@ class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Use
|
||||
|
||||
return useCanId != null && useCanId > 0
|
||||
}
|
||||
|
||||
override fun countPurchasedActiveImagesByCharacter(
|
||||
memberId: Long,
|
||||
characterId: Long,
|
||||
usages: Collection<CanUsage>
|
||||
): Long {
|
||||
val count = queryFactory
|
||||
.selectDistinct(useCan.characterImage.id)
|
||||
.from(useCan)
|
||||
.where(
|
||||
useCan.member.id.eq(memberId)
|
||||
.and(useCan.isRefund.isFalse)
|
||||
.and(useCan.characterImage.chatCharacter.id.eq(characterId))
|
||||
.and(useCan.characterImage.isActive.isTrue)
|
||||
.and(useCan.canUsage.`in`(usages))
|
||||
)
|
||||
.fetch()
|
||||
.size
|
||||
return count.toLong()
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.CascadeType
|
||||
import javax.persistence.Column
|
||||
@@ -7,6 +8,8 @@ import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
import javax.persistence.OneToMany
|
||||
|
||||
@Entity
|
||||
@@ -37,20 +40,26 @@ class ChatCharacter(
|
||||
var speechPattern: String? = null,
|
||||
|
||||
// 대화 스타일
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var speechStyle: String? = null,
|
||||
|
||||
// 외모 설명
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var appearance: String? = null,
|
||||
|
||||
// 원작 (optional)
|
||||
// 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
||||
@Column(nullable = true)
|
||||
var originalTitle: String? = null,
|
||||
|
||||
// 원작 링크 (optional)
|
||||
// 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
||||
@Column(nullable = true)
|
||||
var originalLink: String? = null,
|
||||
|
||||
// 연관 원작 (한 캐릭터는 하나의 원작에만 속함)
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "original_work_id")
|
||||
var originalWork: OriginalWork? = null,
|
||||
|
||||
// 캐릭터 유형
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@@ -60,16 +69,16 @@ class ChatCharacter(
|
||||
) : BaseEntity() {
|
||||
var imagePath: String? = null
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var memories: MutableList<ChatCharacterMemory> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var personalities: MutableList<ChatCharacterPersonality> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var backgrounds: MutableList<ChatCharacterBackground> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var relationships: MutableList<ChatCharacterRelationship> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
|
@@ -18,7 +18,7 @@ class ChatCharacterBackground(
|
||||
|
||||
// 배경 설명
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
val description: String,
|
||||
var description: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
|
@@ -18,10 +18,10 @@ class ChatCharacterMemory(
|
||||
|
||||
// 기억 내용
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
val content: String,
|
||||
var content: String,
|
||||
|
||||
// 감정
|
||||
val emotion: String,
|
||||
var emotion: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
|
@@ -18,7 +18,7 @@ class ChatCharacterPersonality(
|
||||
|
||||
// 성격 특성 설명
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
val description: String,
|
||||
var description: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
|
@@ -5,6 +5,7 @@ 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.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
@@ -54,12 +55,13 @@ class CharacterCommentController(
|
||||
fun listComments(
|
||||
@PathVariable characterId: Long,
|
||||
@RequestParam(required = false, defaultValue = "20") limit: Int,
|
||||
@RequestParam(required = false) cursor: Long?,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val data = service.listComments(imageHost, characterId, limit)
|
||||
val data = service.listComments(imageHost, characterId, cursor, limit)
|
||||
ApiResponse.ok(data)
|
||||
}
|
||||
|
||||
@@ -68,13 +70,39 @@ class CharacterCommentController(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@RequestParam(required = false, defaultValue = "20") limit: Int,
|
||||
@RequestParam(required = false) cursor: Long?,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
// characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨
|
||||
val data = service.getReplies(imageHost, commentId, limit)
|
||||
val data = service.getReplies(imageHost, commentId, cursor, limit)
|
||||
ApiResponse.ok(data)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{characterId}/comments/{commentId}")
|
||||
fun deleteComment(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
service.deleteComment(characterId, commentId, member)
|
||||
ApiResponse.ok(true, "댓글이 삭제되었습니다.")
|
||||
}
|
||||
|
||||
@PostMapping("/{characterId}/comments/{commentId}/reports")
|
||||
fun reportComment(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@RequestBody request: ReportCharacterCommentRequest,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
service.reportComment(characterId, commentId, member, request.content)
|
||||
ApiResponse.ok(true, "신고가 접수되었습니다.")
|
||||
}
|
||||
}
|
||||
|
@@ -15,6 +15,7 @@ data class CreateCharacterCommentRequest(
|
||||
|
||||
data class CharacterCommentResponse(
|
||||
val commentId: Long,
|
||||
val memberId: Long,
|
||||
val memberProfileImage: String,
|
||||
val memberNickname: String,
|
||||
val createdAt: Long,
|
||||
@@ -30,9 +31,11 @@ data class CharacterCommentResponse(
|
||||
|
||||
data class CharacterReplyResponse(
|
||||
val replyId: Long,
|
||||
val memberId: Long,
|
||||
val memberProfileImage: String,
|
||||
val memberNickname: String,
|
||||
val createdAt: Long
|
||||
val createdAt: Long,
|
||||
val comment: String
|
||||
)
|
||||
|
||||
// 댓글의 답글 조회 Response 컨테이너
|
||||
@@ -41,5 +44,21 @@ data class CharacterReplyResponse(
|
||||
|
||||
data class CharacterCommentRepliesResponse(
|
||||
val original: CharacterCommentResponse,
|
||||
val replies: List<CharacterReplyResponse>
|
||||
val replies: List<CharacterReplyResponse>,
|
||||
val cursor: Long?
|
||||
)
|
||||
|
||||
// 댓글 리스트 조회 Response 컨테이너
|
||||
// - 전체 댓글 개수(totalCount)
|
||||
// - 댓글 목록(comments)
|
||||
|
||||
data class CharacterCommentListResponse(
|
||||
val totalCount: Int,
|
||||
val comments: List<CharacterCommentResponse>,
|
||||
val cursor: Long?
|
||||
)
|
||||
|
||||
// 신고 Request
|
||||
data class ReportCharacterCommentRequest(
|
||||
val content: String
|
||||
)
|
||||
|
@@ -0,0 +1,25 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
import javax.persistence.Table
|
||||
|
||||
@Entity
|
||||
@Table(name = "character_comment_report")
|
||||
data class CharacterCommentReport(
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
val content: String
|
||||
) : BaseEntity() {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "comment_id", nullable = false)
|
||||
var comment: CharacterComment? = null
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
var member: Member? = null
|
||||
}
|
@@ -0,0 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CharacterCommentReportRepository : JpaRepository<CharacterCommentReport, Long>
|
@@ -9,10 +9,30 @@ interface CharacterCommentRepository : JpaRepository<CharacterComment, Long> {
|
||||
pageable: Pageable
|
||||
): List<CharacterComment>
|
||||
|
||||
fun countByParent_IdAndIsActiveTrue(parentId: Long): Int
|
||||
fun findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(parentId: Long, pageable: Pageable): List<CharacterComment>
|
||||
fun findFirstByChatCharacter_IdAndIsActiveTrueOrderByCreatedAtDesc(chatCharacterId: Long): CharacterComment?
|
||||
fun findByChatCharacter_IdAndIsActiveTrueAndParentIsNullAndIdLessThanOrderByCreatedAtDesc(
|
||||
chatCharacterId: Long,
|
||||
id: Long,
|
||||
pageable: Pageable
|
||||
): List<CharacterComment>
|
||||
|
||||
// 전체(상위+답글) 활성 댓글 총 개수
|
||||
fun countByChatCharacter_IdAndIsActiveTrue(chatCharacterId: Long): Int
|
||||
fun countByParent_IdAndIsActiveTrue(parentId: Long): Int
|
||||
|
||||
fun findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(parentId: Long, pageable: Pageable): List<CharacterComment>
|
||||
|
||||
fun findByParent_IdAndIsActiveTrueAndIdLessThanOrderByCreatedAtDesc(
|
||||
parentId: Long,
|
||||
id: Long,
|
||||
pageable: Pageable
|
||||
): List<CharacterComment>
|
||||
|
||||
// 최신 원댓글만 조회
|
||||
fun findFirstByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(
|
||||
chatCharacterId: Long
|
||||
): CharacterComment?
|
||||
|
||||
// 활성 원댓글 수
|
||||
fun countByChatCharacter_IdAndIsActiveTrueAndParentIsNull(chatCharacterId: Long): Int
|
||||
|
||||
// 활성 부모를 가진 활성 답글 수 (부모가 null인 경우 제외됨)
|
||||
fun countByChatCharacter_IdAndIsActiveTrueAndParent_IsActiveTrue(chatCharacterId: Long): Int
|
||||
}
|
||||
|
@@ -11,7 +11,8 @@ import java.time.ZoneId
|
||||
@Service
|
||||
class CharacterCommentService(
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val commentRepository: CharacterCommentRepository
|
||||
private val commentRepository: CharacterCommentRepository,
|
||||
private val reportRepository: CharacterCommentReportRepository
|
||||
) {
|
||||
|
||||
private fun profileUrl(imageHost: String, profileImage: String?): String {
|
||||
@@ -34,6 +35,7 @@ class CharacterCommentService(
|
||||
val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.")
|
||||
return CharacterCommentResponse(
|
||||
commentId = entity.id!!,
|
||||
memberId = member.id!!,
|
||||
memberProfileImage = profileUrl(imageHost, member.profileImage),
|
||||
memberNickname = member.nickname,
|
||||
createdAt = toEpochMilli(entity.createdAt),
|
||||
@@ -46,9 +48,11 @@ class CharacterCommentService(
|
||||
val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.")
|
||||
return CharacterReplyResponse(
|
||||
replyId = entity.id!!,
|
||||
memberId = member.id!!,
|
||||
memberProfileImage = profileUrl(imageHost, member.profileImage),
|
||||
memberNickname = member.nickname,
|
||||
createdAt = toEpochMilli(entity.createdAt)
|
||||
createdAt = toEpochMilli(entity.createdAt),
|
||||
comment = entity.comment
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,37 +87,108 @@ class CharacterCommentService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listComments(imageHost: String, characterId: Long, limit: Int = 20): List<CharacterCommentResponse> {
|
||||
fun listComments(
|
||||
imageHost: String,
|
||||
characterId: Long,
|
||||
cursor: Long?,
|
||||
limit: Int = 20
|
||||
): CharacterCommentListResponse {
|
||||
val pageable = PageRequest.of(0, limit)
|
||||
val comments = commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(
|
||||
val comments = if (cursor == null) {
|
||||
commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(
|
||||
characterId,
|
||||
pageable
|
||||
)
|
||||
return comments.map { toCommentResponse(imageHost, it) }
|
||||
} else {
|
||||
commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullAndIdLessThanOrderByCreatedAtDesc(
|
||||
characterId,
|
||||
cursor,
|
||||
pageable
|
||||
)
|
||||
}
|
||||
|
||||
val items = comments.map { toCommentResponse(imageHost, it) }
|
||||
val total = getTotalCommentCount(characterId)
|
||||
val nextCursor = if (items.size == limit) items.lastOrNull()?.commentId else null
|
||||
|
||||
return CharacterCommentListResponse(
|
||||
totalCount = total,
|
||||
comments = items,
|
||||
cursor = nextCursor
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getReplies(imageHost: String, commentId: Long, limit: Int = 20): CharacterCommentRepliesResponse {
|
||||
val original = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
||||
fun getReplies(
|
||||
imageHost: String,
|
||||
commentId: Long,
|
||||
cursor: Long?,
|
||||
limit: Int = 20
|
||||
): CharacterCommentRepliesResponse {
|
||||
val original = commentRepository.findById(commentId).orElseThrow {
|
||||
SodaException("댓글을 찾을 수 없습니다.")
|
||||
}
|
||||
if (!original.isActive) throw SodaException("비활성화된 댓글입니다.")
|
||||
|
||||
val pageable = PageRequest.of(0, limit)
|
||||
val replies = commentRepository.findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(commentId, pageable)
|
||||
val replies = if (cursor == null) {
|
||||
commentRepository.findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(commentId, pageable)
|
||||
} else {
|
||||
commentRepository.findByParent_IdAndIsActiveTrueAndIdLessThanOrderByCreatedAtDesc(
|
||||
commentId,
|
||||
cursor,
|
||||
pageable
|
||||
)
|
||||
}
|
||||
|
||||
val items = replies.map { toReplyResponse(imageHost, it) }
|
||||
val nextCursor = if (items.size == limit) items.lastOrNull()?.replyId else null
|
||||
|
||||
return CharacterCommentRepliesResponse(
|
||||
original = toCommentResponse(imageHost, original, 0),
|
||||
replies = replies.map { toReplyResponse(imageHost, it) }
|
||||
replies = items,
|
||||
cursor = nextCursor
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getLatestComment(imageHost: String, characterId: Long): CharacterCommentResponse? {
|
||||
val last = commentRepository.findFirstByChatCharacter_IdAndIsActiveTrueOrderByCreatedAtDesc(characterId)
|
||||
return last?.let { toCommentResponse(imageHost, it) }
|
||||
return commentRepository
|
||||
.findFirstByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(characterId)
|
||||
?.let { toCommentResponse(imageHost, it) }
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getTotalCommentCount(characterId: Long): Int {
|
||||
return commentRepository.countByChatCharacter_IdAndIsActiveTrue(characterId)
|
||||
// 활성 원댓글 수 + 활성 부모를 가진 활성 답글 수
|
||||
val originalCount = commentRepository
|
||||
.countByChatCharacter_IdAndIsActiveTrueAndParentIsNull(characterId)
|
||||
val replyWithActiveParentCount = commentRepository
|
||||
.countByChatCharacter_IdAndIsActiveTrueAndParent_IsActiveTrue(characterId)
|
||||
|
||||
return originalCount + replyWithActiveParentCount
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteComment(characterId: Long, commentId: Long, member: Member) {
|
||||
val comment = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
||||
if (comment.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.")
|
||||
if (!comment.isActive) return
|
||||
val ownerId = comment.member?.id ?: throw SodaException("유효하지 않은 댓글입니다.")
|
||||
if (ownerId != member.id) throw SodaException("삭제 권한이 없습니다.")
|
||||
comment.isActive = false
|
||||
commentRepository.save(comment)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun reportComment(characterId: Long, commentId: Long, member: Member, content: String) {
|
||||
val comment = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
||||
if (comment.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.")
|
||||
if (content.isBlank()) throw SodaException("신고 내용을 입력해주세요.")
|
||||
|
||||
val report = CharacterCommentReport(content = content)
|
||||
report.comment = comment
|
||||
report.member = member
|
||||
reportRepository.save(report)
|
||||
}
|
||||
}
|
||||
|
@@ -22,6 +22,7 @@ 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
|
||||
|
||||
@RestController
|
||||
@@ -31,6 +32,7 @@ class ChatCharacterController(
|
||||
private val bannerService: ChatCharacterBannerService,
|
||||
private val chatRoomService: ChatRoomService,
|
||||
private val characterCommentService: CharacterCommentService,
|
||||
private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
@@ -56,16 +58,29 @@ class ChatCharacterController(
|
||||
chatRoomService.listMyChatRooms(member, 0, 10)
|
||||
.map { room ->
|
||||
RecentCharacter(
|
||||
roomId = room.chatRoomId,
|
||||
characterId = room.characterId,
|
||||
name = room.title,
|
||||
imageUrl = room.imageUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 인기 캐릭터 조회 (현재는 빈 리스트)
|
||||
// 인기 캐릭터 조회
|
||||
val popularCharacters = service.getPopularCharacters()
|
||||
.map {
|
||||
|
||||
// 최근 등록된 캐릭터 리스트 조회
|
||||
val newCharacters = service.getRecentCharactersPage(
|
||||
page = 0,
|
||||
size = 50
|
||||
).content
|
||||
|
||||
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
||||
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
||||
.map { agg ->
|
||||
CurationSection(
|
||||
characterCurationId = agg.curation.id!!,
|
||||
title = agg.curation.title,
|
||||
characters = agg.characters.map {
|
||||
Character(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
@@ -73,21 +88,9 @@ class ChatCharacterController(
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
|
||||
// 최신 캐릭터 조회 (최대 10개)
|
||||
val newCharacters = service.getNewCharacters(10)
|
||||
.map {
|
||||
Character(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
|
||||
// 큐레이션 섹션 (현재는 빈 리스트)
|
||||
val curationSections = emptyList<CurationSection>()
|
||||
|
||||
// 응답 생성
|
||||
ApiResponse.ok(
|
||||
CharacterMainResponse(
|
||||
@@ -160,6 +163,8 @@ class ChatCharacterController(
|
||||
name = character.name,
|
||||
description = character.description,
|
||||
mbti = character.mbti,
|
||||
gender = character.gender,
|
||||
age = character.age,
|
||||
imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}",
|
||||
personalities = personality,
|
||||
backgrounds = background,
|
||||
@@ -173,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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,47 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.curation
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.CascadeType
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
import javax.persistence.OneToMany
|
||||
|
||||
@Entity
|
||||
class CharacterCuration(
|
||||
@Column(nullable = false)
|
||||
var title: String,
|
||||
|
||||
// 19금 여부
|
||||
@Column(nullable = false)
|
||||
var isAdult: Boolean = false,
|
||||
|
||||
// 활성화 여부 (소프트 삭제)
|
||||
@Column(nullable = false)
|
||||
var isActive: Boolean = true,
|
||||
|
||||
// 정렬 순서 (낮을수록 먼저)
|
||||
@Column(nullable = false)
|
||||
var sortOrder: Int = 0
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "curation", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var characterMappings: MutableList<CharacterCurationMapping> = mutableListOf()
|
||||
}
|
||||
|
||||
@Entity
|
||||
class CharacterCurationMapping(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "curation_id")
|
||||
var curation: CharacterCuration,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "character_id")
|
||||
var chatCharacter: ChatCharacter,
|
||||
|
||||
// 정렬 순서 (낮을수록 먼저)
|
||||
@Column(nullable = false)
|
||||
var sortOrder: Int = 0
|
||||
) : BaseEntity()
|
@@ -0,0 +1,37 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.curation
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class CharacterCurationQueryService(
|
||||
private val curationRepository: CharacterCurationRepository,
|
||||
private val mappingRepository: CharacterCurationMappingRepository
|
||||
) {
|
||||
data class CurationAgg(
|
||||
val curation: CharacterCuration,
|
||||
val characters: List<ChatCharacter>
|
||||
)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getActiveCurationsWithCharacters(): List<CurationAgg> {
|
||||
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
|
||||
if (curations.isEmpty()) return emptyList()
|
||||
|
||||
// 매핑 + 캐릭터를 한 번에 조회(ch.isActive = true 필터 적용)하여 N+1 해소
|
||||
val mappings = mappingRepository
|
||||
.findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc(curations)
|
||||
|
||||
val charactersByCurationId: Map<Long, List<ChatCharacter>> = mappings
|
||||
.groupBy { it.curation.id!! }
|
||||
.mapValues { (_, list) -> list.map { it.chatCharacter } }
|
||||
|
||||
return curations.map { curation ->
|
||||
val characters = charactersByCurationId[curation.id!!] ?: emptyList()
|
||||
CurationAgg(curation, characters)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,48 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.curation.repository
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
|
||||
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
|
||||
|
||||
@Repository
|
||||
interface CharacterCurationMappingRepository : JpaRepository<CharacterCurationMapping, Long> {
|
||||
fun findByCuration(curation: CharacterCuration): List<CharacterCurationMapping>
|
||||
|
||||
@Query(
|
||||
"select m from CharacterCurationMapping m " +
|
||||
"join fetch m.chatCharacter ch " +
|
||||
"where m.curation in :curations and ch.isActive = true " +
|
||||
"order by m.curation.id asc, m.sortOrder asc"
|
||||
)
|
||||
fun findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc(
|
||||
@Param("curations") curations: List<CharacterCuration>
|
||||
): List<CharacterCurationMapping>
|
||||
|
||||
@Query(
|
||||
"select m from CharacterCurationMapping m " +
|
||||
"join fetch m.chatCharacter ch " +
|
||||
"where m.curation = :curation " +
|
||||
"order by m.sortOrder asc"
|
||||
)
|
||||
fun findByCurationWithCharacterOrderBySortOrderAsc(
|
||||
@Param("curation") curation: CharacterCuration
|
||||
): List<CharacterCurationMapping>
|
||||
|
||||
interface CharacterCountPerCuration {
|
||||
val curationId: Long
|
||||
val count: Long
|
||||
}
|
||||
|
||||
@Query(
|
||||
"select m.curation.id as curationId, count(m.id) as count " +
|
||||
"from CharacterCurationMapping m join m.chatCharacter ch " +
|
||||
"where m.curation in :curations and ch.isActive = true " +
|
||||
"group by m.curation.id"
|
||||
)
|
||||
fun countActiveCharactersByCurations(
|
||||
@Param("curations") curations: List<CharacterCuration>
|
||||
): List<CharacterCountPerCuration>
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.curation.repository
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface CharacterCurationRepository : JpaRepository<CharacterCuration, Long> {
|
||||
fun findByIsActiveTrueOrderBySortOrderAsc(): List<CharacterCuration>
|
||||
|
||||
@Query("SELECT MAX(c.sortOrder) FROM CharacterCuration c WHERE c.isActive = true")
|
||||
fun findMaxSortOrder(): Int?
|
||||
}
|
@@ -8,6 +8,8 @@ data class CharacterDetailResponse(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val mbti: String?,
|
||||
val gender: String?,
|
||||
val age: Int?,
|
||||
val imageUrl: String,
|
||||
val personalities: CharacterPersonalityResponse?,
|
||||
val backgrounds: CharacterBackgroundResponse?,
|
||||
|
@@ -1,5 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class CharacterMainResponse(
|
||||
val banners: List<CharacterBannerResponse>,
|
||||
val recentCharacters: List<RecentCharacter>,
|
||||
@@ -15,14 +17,14 @@ data class CurationSection(
|
||||
)
|
||||
|
||||
data class Character(
|
||||
val characterId: Long,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val imageUrl: String
|
||||
@JsonProperty("characterId") val characterId: Long,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("description") val description: String,
|
||||
@JsonProperty("imageUrl") val imageUrl: String
|
||||
)
|
||||
|
||||
data class RecentCharacter(
|
||||
val roomId: Long,
|
||||
val characterId: Long,
|
||||
val name: String,
|
||||
val imageUrl: String
|
||||
)
|
||||
|
@@ -0,0 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.dto
|
||||
|
||||
/**
|
||||
* 최근 등록된 캐릭터 전체보기 페이지 응답 DTO
|
||||
*/
|
||||
data class RecentCharactersResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<Character>
|
||||
)
|
@@ -0,0 +1,41 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.image
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.CascadeType
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
import javax.persistence.OneToMany
|
||||
|
||||
@Entity
|
||||
class CharacterImage(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "character_id")
|
||||
var chatCharacter: ChatCharacter,
|
||||
|
||||
// 원본 이미지 경로 (S3 key - content-bucket)
|
||||
var imagePath: String,
|
||||
|
||||
// 블러 이미지 경로 (S3 key - free/public bucket)
|
||||
var blurImagePath: String,
|
||||
|
||||
// 이미지 단독 구매 가격 (단위: can)
|
||||
var imagePriceCan: Long = 0L,
|
||||
|
||||
// 메시지를 통한 가격 (단위: can)
|
||||
var messagePriceCan: Long = 0L,
|
||||
|
||||
// 성인 이미지 여부 (본인인증 필요)
|
||||
var isAdult: Boolean = false,
|
||||
|
||||
// 갤러리/관리자 노출 순서 (낮을수록 먼저)
|
||||
var sortOrder: Int = 0,
|
||||
|
||||
// 활성화 여부 (소프트 삭제)
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "characterImage", cascade = [CascadeType.ALL], orphanRemoval = true)
|
||||
var triggerMappings: MutableList<CharacterImageTriggerMapping> = mutableListOf()
|
||||
}
|
@@ -0,0 +1,226 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.image
|
||||
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListItemResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImagePurchaseRequest
|
||||
import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImagePurchaseResponse
|
||||
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.data.domain.PageRequest
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
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.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/chat/character/image")
|
||||
class CharacterImageController(
|
||||
private val imageService: CharacterImageService,
|
||||
private val imageCloudFront: ImageContentCloudFront,
|
||||
private val canPaymentService: CanPaymentService,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
|
||||
@GetMapping("/list")
|
||||
fun list(
|
||||
@RequestParam characterId: Long,
|
||||
@RequestParam(required = false, defaultValue = "0") page: Int,
|
||||
@RequestParam(required = false, 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 pageSize = if (size <= 0) 20 else minOf(size, 20)
|
||||
|
||||
// 전체 활성 이미지 수(프로필 제외) 파악을 위해 최소 페이지 조회
|
||||
val totalActiveElements = imageService.pageActiveByCharacter(characterId, PageRequest.of(0, 1)).totalElements
|
||||
// 프로필 이미지는 무료로 볼 수 있으므로 보유 개수에도 +1 반영
|
||||
val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) + 1
|
||||
|
||||
val totalCount = totalActiveElements + 1 // 프로필 포함
|
||||
|
||||
val startIndex = page * pageSize
|
||||
if (startIndex >= totalCount) {
|
||||
return@run ApiResponse.ok(
|
||||
CharacterImageListResponse(
|
||||
totalCount = totalCount,
|
||||
ownedCount = ownedCount,
|
||||
items = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val endExclusive = kotlin.math.min(startIndex + pageSize, totalCount.toInt())
|
||||
val pageLength = endExclusive - startIndex
|
||||
|
||||
// 프로필 이미지 구성(맨 앞)
|
||||
val profilePath = imageService.getCharacterImagePath(characterId) ?: "profile/default-profile.png"
|
||||
val profileItem = CharacterImageListItemResponse(
|
||||
id = 0L,
|
||||
imageUrl = "$imageHost/$profilePath",
|
||||
isOwned = true,
|
||||
imagePriceCan = 0L,
|
||||
sortOrder = 0
|
||||
)
|
||||
|
||||
// 활성 이미지 offset/limit 계산 (결합 리스트 [프로필] + activeImages)
|
||||
val activeOffset = if (startIndex == 0) 0L else (startIndex - 1).toLong()
|
||||
val activeLimit = if (startIndex == 0) (pageLength - 1).toLong() else pageLength.toLong()
|
||||
|
||||
val expiration = 5L * 60L * 1000L // 5분
|
||||
val activeImages = if (activeLimit > 0) {
|
||||
imageService.pageActiveByCharacterOffset(
|
||||
characterId,
|
||||
activeOffset,
|
||||
activeLimit
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val items = buildList {
|
||||
if (startIndex == 0 && pageLength > 0) add(profileItem)
|
||||
activeImages.forEach { img ->
|
||||
val isOwned = (img.imagePriceCan == 0L) || imageService.isOwnedImageByMember(img.id!!, member.id!!)
|
||||
val url = if (isOwned) {
|
||||
imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||
} else {
|
||||
"$imageHost/${img.blurImagePath}"
|
||||
}
|
||||
add(
|
||||
CharacterImageListItemResponse(
|
||||
id = img.id!!,
|
||||
imageUrl = url,
|
||||
isOwned = isOwned,
|
||||
imagePriceCan = img.imagePriceCan,
|
||||
sortOrder = img.sortOrder
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ApiResponse.ok(
|
||||
CharacterImageListResponse(
|
||||
totalCount = totalCount,
|
||||
ownedCount = ownedCount,
|
||||
items = items
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/my-list")
|
||||
fun myList(
|
||||
@RequestParam characterId: Long,
|
||||
@RequestParam(required = false, defaultValue = "0") page: Int,
|
||||
@RequestParam(required = false, 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 pageSize = if (size <= 0) 20 else minOf(size, 20)
|
||||
val expiration = 5L * 60L * 1000L // 5분
|
||||
|
||||
val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!)
|
||||
val totalCount = ownedCount + 1 // 프로필 포함
|
||||
|
||||
// 빈 페이지 요청 처리
|
||||
val startIndex = page * pageSize
|
||||
if (startIndex >= totalCount) {
|
||||
return@run ApiResponse.ok(
|
||||
CharacterImageListResponse(
|
||||
totalCount = totalCount,
|
||||
ownedCount = ownedCount,
|
||||
items = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val endExclusive = kotlin.math.min(startIndex + pageSize, totalCount.toInt())
|
||||
val pageLength = endExclusive - startIndex
|
||||
|
||||
// 프로필 이미지 경로 및 아이템
|
||||
val profilePath = imageService.getCharacterImagePath(characterId) ?: "profile/default-profile.png"
|
||||
val profileItem = CharacterImageListItemResponse(
|
||||
id = 0L,
|
||||
imageUrl = "$imageHost/$profilePath",
|
||||
isOwned = true,
|
||||
imagePriceCan = 0L,
|
||||
sortOrder = 0
|
||||
)
|
||||
|
||||
// 보유 이미지의 오프셋/리밋 계산 (결합 리스트 [프로필] + ownedImages)
|
||||
val ownedOffset = if (startIndex == 0) 0L else (startIndex - 1).toLong()
|
||||
val ownedLimit = if (startIndex == 0) (pageLength - 1).toLong() else pageLength.toLong()
|
||||
|
||||
val ownedImagesPage = if (ownedLimit > 0) {
|
||||
imageService.pageOwnedActiveByCharacterForMember(characterId, member.id!!, ownedOffset, ownedLimit)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val items = buildList {
|
||||
if (startIndex == 0 && pageLength > 0) add(profileItem)
|
||||
ownedImagesPage.forEach { img ->
|
||||
val url = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||
add(
|
||||
CharacterImageListItemResponse(
|
||||
id = img.id!!,
|
||||
imageUrl = url,
|
||||
isOwned = true,
|
||||
imagePriceCan = img.imagePriceCan,
|
||||
sortOrder = img.sortOrder
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ApiResponse.ok(
|
||||
CharacterImageListResponse(
|
||||
totalCount = totalCount,
|
||||
ownedCount = ownedCount,
|
||||
items = items
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@PostMapping("/purchase")
|
||||
fun purchase(
|
||||
@RequestBody req: CharacterImagePurchaseRequest,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val image = imageService.getById(req.imageId)
|
||||
if (!image.isActive) throw SodaException("비활성화된 이미지입니다.")
|
||||
|
||||
val isOwned = (image.imagePriceCan == 0L) ||
|
||||
imageService.isOwnedImageByMember(image.id!!, member.id!!)
|
||||
|
||||
if (!isOwned) {
|
||||
val needCan = image.imagePriceCan.toInt()
|
||||
if (needCan <= 0) throw SodaException("구매 가격이 잘못되었습니다.")
|
||||
|
||||
canPaymentService.spendCanForCharacterImage(
|
||||
memberId = member.id!!,
|
||||
needCan = needCan,
|
||||
image = image,
|
||||
container = req.container
|
||||
)
|
||||
}
|
||||
|
||||
val expiration = 5L * 60L * 1000L // 5분
|
||||
val signedUrl = imageCloudFront.generateSignedURL(image.imagePath, expiration)
|
||||
ApiResponse.ok(CharacterImagePurchaseResponse(imageUrl = signedUrl))
|
||||
}
|
||||
}
|
@@ -0,0 +1,100 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.image
|
||||
|
||||
import com.querydsl.jpa.JPAExpressions
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
||||
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.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository {
|
||||
fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage>
|
||||
|
||||
fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(
|
||||
characterId: Long,
|
||||
pageable: Pageable
|
||||
): Page<CharacterImage>
|
||||
|
||||
fun countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId: Long, imagePriceCan: Long): Long
|
||||
|
||||
@Query(
|
||||
"SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " +
|
||||
"WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true"
|
||||
)
|
||||
fun findMaxSortOrderByCharacterId(characterId: Long): Int
|
||||
}
|
||||
|
||||
interface CharacterImageQueryRepository {
|
||||
fun findOwnedActiveImagesByCharacterPaged(
|
||||
characterId: Long,
|
||||
memberId: Long,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<CharacterImage>
|
||||
|
||||
fun findActiveImagesByCharacterPaged(
|
||||
characterId: Long,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<CharacterImage>
|
||||
}
|
||||
|
||||
class CharacterImageQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : CharacterImageQueryRepository {
|
||||
override fun findOwnedActiveImagesByCharacterPaged(
|
||||
characterId: Long,
|
||||
memberId: Long,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<CharacterImage> {
|
||||
val usages = listOf(CanUsage.CHAT_MESSAGE_PURCHASE, CanUsage.CHARACTER_IMAGE_PURCHASE)
|
||||
val ci = QCharacterImage.characterImage
|
||||
return queryFactory
|
||||
.selectFrom(ci)
|
||||
.where(
|
||||
ci.chatCharacter.id.eq(characterId)
|
||||
.and(ci.isActive.isTrue)
|
||||
.and(
|
||||
ci.imagePriceCan.eq(0L).or(
|
||||
JPAExpressions
|
||||
.selectOne()
|
||||
.from(useCan)
|
||||
.where(
|
||||
useCan.member.id.eq(memberId)
|
||||
.and(useCan.isRefund.isFalse)
|
||||
.and(useCan.characterImage.id.eq(ci.id))
|
||||
.and(useCan.canUsage.`in`(usages))
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(ci.sortOrder.asc(), ci.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun findActiveImagesByCharacterPaged(
|
||||
characterId: Long,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<CharacterImage> {
|
||||
val ci = QCharacterImage.characterImage
|
||||
return queryFactory
|
||||
.selectFrom(ci)
|
||||
.where(
|
||||
ci.chatCharacter.id.eq(characterId)
|
||||
.and(ci.isActive.isTrue)
|
||||
)
|
||||
.orderBy(ci.sortOrder.asc(), ci.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
}
|
@@ -0,0 +1,169 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.image
|
||||
|
||||
// ktlint-disable standard:max-line-length
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class CharacterImageService(
|
||||
private val characterRepository: ChatCharacterRepository,
|
||||
private val imageRepository: CharacterImageRepository,
|
||||
private val triggerTagRepository: CharacterImageTriggerRepository,
|
||||
private val useCanRepository: kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
) {
|
||||
|
||||
fun listActiveByCharacter(characterId: Long): List<CharacterImage> {
|
||||
return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId)
|
||||
}
|
||||
|
||||
// 페이징 조회(활성 이미지)
|
||||
fun pageActiveByCharacter(characterId: Long, pageable: Pageable): Page<CharacterImage> {
|
||||
return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId, pageable)
|
||||
}
|
||||
|
||||
// 오프셋/리밋 조회(활성 이미지)
|
||||
fun pageActiveByCharacterOffset(
|
||||
characterId: Long,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<CharacterImage> {
|
||||
if (limit <= 0L) return emptyList()
|
||||
return imageRepository.findActiveImagesByCharacterPaged(characterId, offset, limit)
|
||||
}
|
||||
|
||||
// 구매 이력 + 무료로 계산된 보유 수
|
||||
fun countOwnedActiveByCharacterForMember(characterId: Long, memberId: Long): Long {
|
||||
val freeCount = imageRepository.countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId, 0L)
|
||||
val purchasedCount = useCanRepository.countPurchasedActiveImagesByCharacter(
|
||||
memberId,
|
||||
characterId,
|
||||
listOf(
|
||||
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||
CanUsage.CHARACTER_IMAGE_PURCHASE
|
||||
)
|
||||
)
|
||||
return freeCount + purchasedCount
|
||||
}
|
||||
|
||||
fun isOwnedImageByMember(imageId: Long, memberId: Long): Boolean {
|
||||
// 무료이거나(컨트롤러에서 가격 확인) 구매 이력이 있으면 보유로 판단
|
||||
val purchased = useCanRepository.existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn(
|
||||
memberId,
|
||||
imageId,
|
||||
listOf(
|
||||
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||
CanUsage.CHARACTER_IMAGE_PURCHASE
|
||||
)
|
||||
)
|
||||
return purchased
|
||||
}
|
||||
|
||||
fun getById(id: Long): CharacterImage =
|
||||
imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") }
|
||||
|
||||
fun getCharacterImagePath(characterId: Long): String? {
|
||||
val character = characterRepository.findById(characterId)
|
||||
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") }
|
||||
return character.imagePath
|
||||
}
|
||||
|
||||
// 보유한(무료+구매) 활성 이미지 페이징 조회
|
||||
fun pageOwnedActiveByCharacterForMember(
|
||||
characterId: Long,
|
||||
memberId: Long,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<CharacterImage> {
|
||||
if (limit <= 0L) return emptyList()
|
||||
return imageRepository.findOwnedActiveImagesByCharacterPaged(characterId, memberId, offset, limit)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun registerImage(
|
||||
characterId: Long,
|
||||
imagePath: String,
|
||||
blurImagePath: String,
|
||||
imagePriceCan: Long,
|
||||
messagePriceCan: Long,
|
||||
isAdult: Boolean,
|
||||
triggers: List<String>
|
||||
): CharacterImage {
|
||||
val character = characterRepository.findById(characterId)
|
||||
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") }
|
||||
|
||||
if (imagePriceCan < 0 || messagePriceCan < 0) throw SodaException("가격은 0 can 이상이어야 합니다.")
|
||||
|
||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터에는 이미지를 등록할 수 없습니다: $characterId")
|
||||
|
||||
val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1
|
||||
val entity = CharacterImage(
|
||||
chatCharacter = character,
|
||||
imagePath = imagePath,
|
||||
blurImagePath = blurImagePath,
|
||||
imagePriceCan = imagePriceCan,
|
||||
messagePriceCan = messagePriceCan,
|
||||
isAdult = isAdult,
|
||||
sortOrder = nextOrder,
|
||||
isActive = true
|
||||
)
|
||||
val saved = imageRepository.save(entity)
|
||||
applyTriggers(saved, triggers)
|
||||
return saved
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정은 트리거만 가능
|
||||
*/
|
||||
@Transactional
|
||||
fun updateTriggers(imageId: Long, triggers: List<String>): CharacterImage {
|
||||
val image = getById(imageId)
|
||||
if (!image.isActive) throw SodaException("비활성화된 이미지는 수정할 수 없습니다: $imageId")
|
||||
applyTriggers(image, triggers)
|
||||
return image
|
||||
}
|
||||
|
||||
private fun applyTriggers(image: CharacterImage, triggers: List<String>) {
|
||||
// 입력 트리거 정규화
|
||||
val newWords = triggers.mapNotNull { it.trim().lowercase().takeIf { s -> s.isNotBlank() } }.distinct().toSet()
|
||||
|
||||
// 현재 매핑 단어 집합
|
||||
val currentMappings = image.triggerMappings
|
||||
val currentWords = currentMappings.map { it.tag.word }.toSet()
|
||||
|
||||
// 제거되어야 할 매핑(현재는 있지만 새 입력에는 없는 단어)
|
||||
val toRemove = currentMappings.filter { it.tag.word !in newWords }
|
||||
currentMappings.removeAll(toRemove)
|
||||
|
||||
// 추가되어야 할 단어(새 입력에는 있지만 현재는 없는 단어)
|
||||
val toAdd = newWords.minus(currentWords)
|
||||
toAdd.forEach { w ->
|
||||
val tag = triggerTagRepository.findByWord(w) ?: triggerTagRepository.save(CharacterImageTrigger(word = w))
|
||||
currentMappings.add(CharacterImageTriggerMapping(characterImage = image, tag = tag))
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteImage(imageId: Long) {
|
||||
val image = getById(imageId)
|
||||
image.isActive = false
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateOrders(characterId: Long, ids: List<Long>): List<CharacterImage> {
|
||||
// 동일 캐릭터 소속 검증 및 순서 재지정
|
||||
val updated = mutableListOf<CharacterImage>()
|
||||
ids.forEachIndexed { idx, id ->
|
||||
val img = getById(id)
|
||||
if (img.chatCharacter.id != characterId) throw SodaException("다른 캐릭터의 이미지가 포함되어 있습니다: $id")
|
||||
if (!img.isActive) throw SodaException("비활성화된 이미지는 순서를 변경할 수 없습니다: $id")
|
||||
img.sortOrder = idx + 1
|
||||
updated.add(img)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
}
|
@@ -0,0 +1,24 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.image
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.OneToMany
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
/**
|
||||
* 캐릭터 이미지 트리거 "태그" 엔티티
|
||||
* - word를 전역 고유로 관리하여 중복 단어 저장을 방지한다.
|
||||
* - 이미지와의 연결은 CharacterImageTriggerMapping을 사용한다.
|
||||
*/
|
||||
@Entity
|
||||
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["word"])])
|
||||
class CharacterImageTrigger(
|
||||
@Column(nullable = false)
|
||||
var word: String
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "tag", fetch = FetchType.LAZY)
|
||||
var mappings: MutableList<CharacterImageTriggerMapping> = mutableListOf()
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.image
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* CharacterImage 와 CharacterImageTrigger(태그) 사이의 매핑 엔티티
|
||||
*/
|
||||
@Entity
|
||||
class CharacterImageTriggerMapping(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "character_image_id")
|
||||
var characterImage: CharacterImage,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "tag_id")
|
||||
var tag: CharacterImageTrigger
|
||||
) : BaseEntity()
|
@@ -0,0 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.image
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface CharacterImageTriggerRepository : JpaRepository<CharacterImageTrigger, Long> {
|
||||
fun findByWord(word: String): CharacterImageTrigger?
|
||||
}
|
@@ -0,0 +1,17 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.image.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class CharacterImageListItemResponse(
|
||||
@JsonProperty("id") val id: Long,
|
||||
@JsonProperty("imageUrl") val imageUrl: String,
|
||||
@JsonProperty("isOwned") val isOwned: Boolean,
|
||||
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
|
||||
@JsonProperty("sortOrder") val sortOrder: Int
|
||||
)
|
||||
|
||||
data class CharacterImageListResponse(
|
||||
@JsonProperty("totalCount") val totalCount: Long,
|
||||
@JsonProperty("ownedCount") val ownedCount: Long,
|
||||
@JsonProperty("items") val items: List<CharacterImageListItemResponse>
|
||||
)
|
@@ -0,0 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.image.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class CharacterImagePurchaseRequest(
|
||||
@JsonProperty("imageId") val imageId: Long,
|
||||
@JsonProperty("container") val container: String
|
||||
)
|
||||
|
||||
data class CharacterImagePurchaseResponse(
|
||||
@JsonProperty("imageUrl") val imageUrl: String
|
||||
)
|
@@ -10,17 +10,29 @@ import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||
fun findByCharacterUUID(characterUUID: String): ChatCharacter?
|
||||
fun findByName(name: String): 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(
|
||||
"""
|
||||
@@ -61,4 +73,6 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||
@Param("characterId") characterId: Long,
|
||||
pageable: Pageable
|
||||
): List<ChatCharacter>
|
||||
|
||||
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
|
||||
}
|
||||
|
@@ -1,5 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.service
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
@@ -8,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.ChatCharacterTag
|
||||
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.ChatCharacterHobbyRepository
|
||||
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.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.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class ChatCharacterService(
|
||||
@@ -23,24 +32,99 @@ class ChatCharacterService(
|
||||
private val tagRepository: ChatCharacterTagRepository,
|
||||
private val valueRepository: ChatCharacterValueRepository,
|
||||
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)
|
||||
fun getPopularCharacters(): List<ChatCharacter> {
|
||||
// 채팅방 구현 전이므로 빈 리스트 반환
|
||||
return emptyList()
|
||||
@Cacheable(
|
||||
cacheNames = ["popularCharacters_24h"],
|
||||
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)
|
||||
fun getNewCharacters(limit: Int = 10): List<ChatCharacter> {
|
||||
return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit))
|
||||
fun getRecentCharactersPage(page: Int = 0, size: Int = 20): RecentCharactersResponse {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,6 +306,147 @@ class ChatCharacterService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기억(memories) 증분 업데이트
|
||||
*/
|
||||
@Transactional
|
||||
fun updateMemoriesForCharacter(chatCharacter: ChatCharacter, memories: List<ChatCharacterMemoryRequest>) {
|
||||
val desiredByTitle = memories
|
||||
.asSequence()
|
||||
.distinctBy { it.title }
|
||||
.associateBy { it.title }
|
||||
|
||||
val iterator = chatCharacter.memories.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val current = iterator.next()
|
||||
val desired = desiredByTitle[current.title]
|
||||
if (desired == null) {
|
||||
// 요청에 없는 항목은 제거
|
||||
iterator.remove()
|
||||
} else {
|
||||
// 값 필드만 in-place 업데이트
|
||||
if (current.content != desired.content) current.content = desired.content
|
||||
if (current.emotion != desired.emotion) current.emotion = desired.emotion
|
||||
}
|
||||
}
|
||||
|
||||
// 신규 추가
|
||||
val existingTitles = chatCharacter.memories.map { it.title }.toSet()
|
||||
desiredByTitle.values
|
||||
.filterNot { existingTitles.contains(it.title) }
|
||||
.forEach { chatCharacter.addMemory(it.title, it.content, it.emotion) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 성격(personalities) 증분 업데이트
|
||||
*/
|
||||
@Transactional
|
||||
fun updatePersonalitiesForCharacter(
|
||||
chatCharacter: ChatCharacter,
|
||||
personalities: List<ChatCharacterPersonalityRequest>
|
||||
) {
|
||||
val desiredByTrait = personalities
|
||||
.asSequence()
|
||||
.distinctBy { it.trait }
|
||||
.associateBy { it.trait }
|
||||
|
||||
val iterator = chatCharacter.personalities.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val current = iterator.next()
|
||||
val desired = desiredByTrait[current.trait]
|
||||
if (desired == null) {
|
||||
// 요청에 없는 항목은 제거
|
||||
iterator.remove()
|
||||
} else {
|
||||
// 값 필드만 in-place 업데이트
|
||||
if (current.description != desired.description) current.description = desired.description
|
||||
}
|
||||
}
|
||||
|
||||
// 신규 추가
|
||||
val existingTraits = chatCharacter.personalities.map { it.trait }.toSet()
|
||||
desiredByTrait.values
|
||||
.filterNot { existingTraits.contains(it.trait) }
|
||||
.forEach { chatCharacter.addPersonality(it.trait, it.description) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 배경(backgrounds) 증분 업데이트
|
||||
*/
|
||||
@Transactional
|
||||
fun updateBackgroundsForCharacter(chatCharacter: ChatCharacter, backgrounds: List<ChatCharacterBackgroundRequest>) {
|
||||
val desiredByTopic = backgrounds
|
||||
.asSequence()
|
||||
.distinctBy { it.topic }
|
||||
.associateBy { it.topic }
|
||||
|
||||
val iterator = chatCharacter.backgrounds.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val current = iterator.next()
|
||||
val desired = desiredByTopic[current.topic]
|
||||
if (desired == null) {
|
||||
// 요청에 없는 항목은 제거
|
||||
iterator.remove()
|
||||
} else {
|
||||
// 값 필드만 in-place 업데이트
|
||||
if (current.description != desired.description) current.description = desired.description
|
||||
}
|
||||
}
|
||||
|
||||
// 신규 추가
|
||||
val existingTopics = chatCharacter.backgrounds.map { it.topic }.toSet()
|
||||
desiredByTopic.values
|
||||
.filterNot { existingTopics.contains(it.topic) }
|
||||
.forEach { chatCharacter.addBackground(it.topic, it.description) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 관계(relationships) 증분 업데이트
|
||||
*/
|
||||
@Transactional
|
||||
fun updateRelationshipsForCharacter(
|
||||
chatCharacter: ChatCharacter,
|
||||
relationships: List<ChatCharacterRelationshipRequest>
|
||||
) {
|
||||
fun keyOf(p: String, r: String) = "$" + "{" + p + "}" + "::" + "{" + r + "}"
|
||||
|
||||
val desiredByKey = relationships
|
||||
.asSequence()
|
||||
.distinctBy { keyOf(it.personName, it.relationshipName) }
|
||||
.associateBy { keyOf(it.personName, it.relationshipName) }
|
||||
|
||||
val iterator = chatCharacter.relationships.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val current = iterator.next()
|
||||
val key = keyOf(current.personName, current.relationshipName)
|
||||
val desired = desiredByKey[key]
|
||||
if (desired == null) {
|
||||
iterator.remove()
|
||||
} else {
|
||||
if (current.description != desired.description) current.description = desired.description
|
||||
if (current.importance != desired.importance) current.importance = desired.importance
|
||||
if (current.relationshipType != desired.relationshipType) {
|
||||
current.relationshipType = desired.relationshipType
|
||||
}
|
||||
if (current.currentStatus != desired.currentStatus) current.currentStatus = desired.currentStatus
|
||||
}
|
||||
}
|
||||
|
||||
val existingKeys = chatCharacter.relationships.map { keyOf(it.personName, it.relationshipName) }.toSet()
|
||||
desiredByKey.values
|
||||
.filterNot { existingKeys.contains(keyOf(it.personName, it.relationshipName)) }
|
||||
.forEach { rr ->
|
||||
chatCharacter.addRelationship(
|
||||
rr.personName,
|
||||
rr.relationshipName,
|
||||
rr.description,
|
||||
rr.importance,
|
||||
rr.relationshipType,
|
||||
rr.currentStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 저장
|
||||
*/
|
||||
@@ -230,14 +455,6 @@ class ChatCharacterService(
|
||||
return chatCharacterRepository.save(chatCharacter)
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID로 캐릭터 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun findByCharacterUUID(characterUUID: String): ChatCharacter? {
|
||||
return chatCharacterRepository.findByCharacterUUID(characterUUID)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이름으로 캐릭터 조회
|
||||
*/
|
||||
@@ -246,14 +463,6 @@ class ChatCharacterService(
|
||||
return chatCharacterRepository.findByName(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 캐릭터 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun findAll(): List<ChatCharacter> {
|
||||
return chatCharacterRepository.findAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 캐릭터 조회
|
||||
*/
|
||||
@@ -331,57 +540,6 @@ class ChatCharacterService(
|
||||
return saveChatCharacter(chatCharacter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터에 기억 추가
|
||||
*/
|
||||
@Transactional
|
||||
fun addMemoryToChatCharacter(chatCharacter: ChatCharacter, title: String, content: String, emotion: String) {
|
||||
chatCharacter.addMemory(title, content, emotion)
|
||||
saveChatCharacter(chatCharacter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터에 성격 특성 추가
|
||||
*/
|
||||
@Transactional
|
||||
fun addPersonalityToChatCharacter(chatCharacter: ChatCharacter, trait: String, description: String) {
|
||||
chatCharacter.addPersonality(trait, description)
|
||||
saveChatCharacter(chatCharacter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터에 배경 정보 추가
|
||||
*/
|
||||
@Transactional
|
||||
fun addBackgroundToChatCharacter(chatCharacter: ChatCharacter, topic: String, description: String) {
|
||||
chatCharacter.addBackground(topic, description)
|
||||
saveChatCharacter(chatCharacter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터에 관계 추가
|
||||
*/
|
||||
@Transactional
|
||||
fun addRelationshipToChatCharacter(
|
||||
chatCharacter: ChatCharacter,
|
||||
personName: String,
|
||||
relationshipName: String,
|
||||
description: String,
|
||||
importance: Int,
|
||||
relationshipType: String,
|
||||
currentStatus: String
|
||||
) {
|
||||
chatCharacter.addRelationship(
|
||||
personName,
|
||||
relationshipName,
|
||||
description,
|
||||
importance,
|
||||
relationshipType,
|
||||
currentStatus
|
||||
)
|
||||
saveChatCharacter(chatCharacter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 생성 시 기본 정보와 함께 추가 정보도 설정
|
||||
*/
|
||||
@@ -464,7 +622,6 @@ class ChatCharacterService(
|
||||
* @param imagePath 이미지 경로 (null이면 이미지 변경 없음)
|
||||
* @param request 수정 요청 데이터 (id를 제외한 모든 필드는 null 가능)
|
||||
* @return 수정된 ChatCharacter 객체
|
||||
* @throws SodaException 캐릭터를 찾을 수 없는 경우
|
||||
*/
|
||||
@Transactional
|
||||
fun updateChatCharacterWithDetails(
|
||||
@@ -526,38 +683,19 @@ class ChatCharacterService(
|
||||
|
||||
// 추가 정보 설정 - 변경된 데이터만 업데이트
|
||||
if (request.memories != null) {
|
||||
chatCharacter.memories.clear()
|
||||
request.memories.forEach { memory ->
|
||||
chatCharacter.addMemory(memory.title, memory.content, memory.emotion)
|
||||
}
|
||||
updateMemoriesForCharacter(chatCharacter, request.memories)
|
||||
}
|
||||
|
||||
if (request.personalities != null) {
|
||||
chatCharacter.personalities.clear()
|
||||
request.personalities.forEach { personality ->
|
||||
chatCharacter.addPersonality(personality.trait, personality.description)
|
||||
}
|
||||
updatePersonalitiesForCharacter(chatCharacter, request.personalities)
|
||||
}
|
||||
|
||||
if (request.backgrounds != null) {
|
||||
chatCharacter.backgrounds.clear()
|
||||
request.backgrounds.forEach { background ->
|
||||
chatCharacter.addBackground(background.topic, background.description)
|
||||
}
|
||||
updateBackgroundsForCharacter(chatCharacter, request.backgrounds)
|
||||
}
|
||||
|
||||
if (request.relationships != null) {
|
||||
chatCharacter.relationships.clear()
|
||||
request.relationships.forEach { rr ->
|
||||
chatCharacter.addRelationship(
|
||||
rr.personName,
|
||||
rr.relationshipName,
|
||||
rr.description,
|
||||
rr.importance,
|
||||
rr.relationshipType,
|
||||
rr.currentStatus
|
||||
)
|
||||
}
|
||||
updateRelationshipsForCharacter(chatCharacter, request.relationships)
|
||||
}
|
||||
|
||||
return saveChatCharacter(chatCharacter)
|
||||
|
@@ -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,81 @@
|
||||
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.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)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Id
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.Version
|
||||
|
||||
@Entity
|
||||
@Table(name = "chat_quota")
|
||||
class ChatQuota(
|
||||
@Id
|
||||
val memberId: Long,
|
||||
var remainingFree: Int = 10,
|
||||
var remainingPaid: Int = 0,
|
||||
var nextRechargeAt: LocalDateTime? = null,
|
||||
@Version
|
||||
var version: Long? = null
|
||||
) {
|
||||
fun total(): Int = remainingFree + remainingPaid
|
||||
}
|
@@ -0,0 +1,63 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/chat/quota")
|
||||
class ChatQuotaController(
|
||||
private val chatQuotaService: ChatQuotaService,
|
||||
private val canPaymentService: CanPaymentService
|
||||
) {
|
||||
|
||||
data class ChatQuotaStatusResponse(
|
||||
val totalRemaining: Int,
|
||||
val nextRechargeAtEpoch: Long?
|
||||
)
|
||||
|
||||
data class ChatQuotaPurchaseRequest(
|
||||
val container: String
|
||||
)
|
||||
|
||||
@GetMapping("/me")
|
||||
fun getMyQuota(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
): ApiResponse<ChatQuotaStatusResponse> = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val s = chatQuotaService.getStatus(member.id!!)
|
||||
ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis))
|
||||
}
|
||||
|
||||
@PostMapping("/purchase")
|
||||
fun purchaseQuota(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@RequestBody request: ChatQuotaPurchaseRequest
|
||||
): ApiResponse<ChatQuotaStatusResponse> = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
if (request.container.isBlank()) throw SodaException("container를 확인해주세요.")
|
||||
|
||||
// 30캔 차감 처리 (결제 기록 남김)
|
||||
canPaymentService.spendCan(
|
||||
memberId = member.id!!,
|
||||
needCan = 30,
|
||||
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
|
||||
container = request.container
|
||||
)
|
||||
|
||||
// 글로벌 유료 개념 제거됨: 구매 성공 시에도 글로벌 쿼터 증액 없음
|
||||
val s = chatQuotaService.getStatus(member.id!!)
|
||||
ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis))
|
||||
}
|
||||
}
|
@@ -0,0 +1,15 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface ChatQuotaRepository : JpaRepository<ChatQuota, Long> {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select q from ChatQuota q where q.memberId = :memberId")
|
||||
fun findForUpdate(@Param("memberId") memberId: Long): ChatQuota?
|
||||
|
||||
fun findByMemberId(memberId: Long): ChatQuota?
|
||||
}
|
@@ -0,0 +1,62 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@Service
|
||||
class ChatQuotaService(
|
||||
private val repo: ChatQuotaRepository
|
||||
) {
|
||||
companion object {
|
||||
private const val FREE_BUCKET = 40
|
||||
}
|
||||
|
||||
data class QuotaStatus(
|
||||
val totalRemaining: Int,
|
||||
val nextRechargeAtEpochMillis: Long?
|
||||
)
|
||||
|
||||
private fun nextUtc20LocalDateTime(now: Instant = Instant.now()): LocalDateTime {
|
||||
val nowUtc = LocalDateTime.ofInstant(now, ZoneOffset.UTC)
|
||||
val today20 = nowUtc.withHour(20).withMinute(0).withSecond(0).withNano(0)
|
||||
val target = if (nowUtc.isBefore(today20)) today20 else today20.plusDays(1)
|
||||
// 저장은 시스템 기본 타임존의 LocalDateTime으로 보관
|
||||
return LocalDateTime.ofInstant(target.toInstant(ZoneOffset.UTC), ZoneId.systemDefault())
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus {
|
||||
val now = Instant.now()
|
||||
val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId))
|
||||
// Lazy refill: nextRechargeAt이 없거나 현재를 지났다면 무료 40 회복
|
||||
val nextRecharge = nextUtc20LocalDateTime(now)
|
||||
if (quota.nextRechargeAt == null || !LocalDateTime.now().isBefore(quota.nextRechargeAt)) {
|
||||
quota.remainingFree = FREE_BUCKET
|
||||
}
|
||||
// 다음 UTC20 기준 시간으로 항상 갱신
|
||||
quota.nextRechargeAt = nextRecharge
|
||||
|
||||
val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||
// 글로벌은 유료 개념 제거: totalRemaining은 remainingFree만 사용
|
||||
return QuotaStatus(totalRemaining = quota.remainingFree, nextRechargeAtEpochMillis = epoch)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun consumeOneFree(memberId: Long) {
|
||||
val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId))
|
||||
if (quota.remainingFree <= 0) {
|
||||
// 소비 불가: 호출자는 상태 조회로 남은 시간을 판단
|
||||
throw IllegalStateException("No global free quota")
|
||||
}
|
||||
quota.remainingFree -= 1
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun getStatus(memberId: Long): QuotaStatus {
|
||||
return applyRefillOnEnterAndGetStatus(memberId)
|
||||
}
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota.room
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.Version
|
||||
|
||||
@Entity
|
||||
@Table(name = "chat_room_quota")
|
||||
class ChatRoomQuota(
|
||||
val memberId: Long,
|
||||
val chatRoomId: Long,
|
||||
val characterId: Long,
|
||||
var remainingFree: Int = 10,
|
||||
var remainingPaid: Int = 0,
|
||||
var nextRechargeAt: Long? = null,
|
||||
@Version
|
||||
var version: Long? = null
|
||||
) : BaseEntity()
|
@@ -0,0 +1,139 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota.room
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.quota.ChatQuotaService
|
||||
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
|
||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
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.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/chat/rooms")
|
||||
class ChatRoomQuotaController(
|
||||
private val chatRoomRepository: ChatRoomRepository,
|
||||
private val participantRepository: ChatParticipantRepository,
|
||||
private val chatRoomQuotaService: ChatRoomQuotaService,
|
||||
private val chatQuotaService: ChatQuotaService
|
||||
) {
|
||||
|
||||
data class PurchaseRoomQuotaRequest(
|
||||
val container: String
|
||||
)
|
||||
|
||||
data class PurchaseRoomQuotaResponse(
|
||||
val totalRemaining: Int,
|
||||
val nextRechargeAtEpoch: Long?,
|
||||
val remainingFree: Int,
|
||||
val remainingPaid: Int
|
||||
)
|
||||
|
||||
data class RoomQuotaStatusResponse(
|
||||
val totalRemaining: Int,
|
||||
val nextRechargeAtEpoch: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
* 채팅방 유료 쿼터 구매 API
|
||||
* - 참여 여부 검증(내가 USER로 참여 중인 활성 방)
|
||||
* - 30캔 결제 (UseCan에 chatRoomId:characterId 기록)
|
||||
* - 방 유료 쿼터 40 충전
|
||||
*/
|
||||
@PostMapping("/{chatRoomId}/quota/purchase")
|
||||
fun purchaseRoomQuota(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable chatRoomId: Long,
|
||||
@RequestBody req: PurchaseRoomQuotaRequest
|
||||
): ApiResponse<PurchaseRoomQuotaResponse> = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
if (req.container.isBlank()) throw SodaException("container를 확인해주세요.")
|
||||
|
||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
|
||||
// 내 참여 여부 확인
|
||||
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
// 캐릭터 참여자 확인(유효한 AI 캐릭터 방인지 체크 및 characterId 기본값 보조)
|
||||
val characterParticipant = participantRepository
|
||||
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
|
||||
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
|
||||
|
||||
val character = characterParticipant.character
|
||||
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
|
||||
|
||||
val characterId = character.id
|
||||
?: throw SodaException("잘못된 요청입니다. 캐릭터 정보를 확인해주세요.")
|
||||
|
||||
// 서비스에서 결제 포함하여 처리
|
||||
val status = chatRoomQuotaService.purchase(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId,
|
||||
addPaid = 40,
|
||||
container = req.container
|
||||
)
|
||||
|
||||
ApiResponse.ok(
|
||||
PurchaseRoomQuotaResponse(
|
||||
totalRemaining = status.totalRemaining,
|
||||
nextRechargeAtEpoch = status.nextRechargeAtEpochMillis,
|
||||
remainingFree = status.remainingFree,
|
||||
remainingPaid = status.remainingPaid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/{chatRoomId}/quota/me")
|
||||
fun getMyRoomQuota(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable chatRoomId: Long
|
||||
): ApiResponse<RoomQuotaStatusResponse> = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
// 내 참여 여부 확인
|
||||
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
// 캐릭터 확인
|
||||
val characterParticipant = participantRepository
|
||||
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
|
||||
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
|
||||
val character = characterParticipant.character
|
||||
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
|
||||
|
||||
// 글로벌 Lazy refill
|
||||
val globalStatus = chatQuotaService.getStatus(member.id!!)
|
||||
|
||||
// 룸 Lazy refill 상태
|
||||
val roomStatus = chatRoomQuotaService.applyRefillOnEnterAndGetStatus(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = character.id!!,
|
||||
globalFree = globalStatus.totalRemaining
|
||||
)
|
||||
|
||||
val next: Long? = when {
|
||||
roomStatus.totalRemaining == 0 -> roomStatus.nextRechargeAtEpochMillis
|
||||
globalStatus.totalRemaining <= 0 -> globalStatus.nextRechargeAtEpochMillis
|
||||
else -> null
|
||||
}
|
||||
ApiResponse.ok(
|
||||
RoomQuotaStatusResponse(
|
||||
totalRemaining = roomStatus.totalRemaining,
|
||||
nextRechargeAtEpoch = next
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota.room
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface ChatRoomQuotaRepository : JpaRepository<ChatRoomQuota, Long> {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select q from ChatRoomQuota q where q.memberId = :memberId and q.chatRoomId = :chatRoomId")
|
||||
fun findForUpdate(
|
||||
@Param("memberId") memberId: Long,
|
||||
@Param("chatRoomId") chatRoomId: Long
|
||||
): ChatRoomQuota?
|
||||
}
|
@@ -0,0 +1,176 @@
|
||||
package kr.co.vividnext.sodalive.chat.quota.room
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@Service
|
||||
class ChatRoomQuotaService(
|
||||
private val repo: ChatRoomQuotaRepository,
|
||||
private val canPaymentService: CanPaymentService
|
||||
) {
|
||||
data class RoomQuotaStatus(
|
||||
val totalRemaining: Int,
|
||||
val nextRechargeAtEpochMillis: Long?,
|
||||
val remainingFree: Int,
|
||||
val remainingPaid: Int
|
||||
)
|
||||
|
||||
private fun calculateAvailableForRoom(globalFree: Int, roomFree: Int, roomPaid: Int): Int {
|
||||
// 유료가 있으면 글로벌 상관 없이 (유료 + 무료동시가능수)로 계산
|
||||
// 무료만 있는 경우에는 글로벌과 룸 Free의 교집합으로 사용 가능 횟수 계산
|
||||
val freeUsable = minOf(globalFree, roomFree)
|
||||
return roomPaid + freeUsable
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun applyRefillOnEnterAndGetStatus(
|
||||
memberId: Long,
|
||||
chatRoomId: Long,
|
||||
characterId: Long,
|
||||
globalFree: Int
|
||||
): RoomQuotaStatus {
|
||||
val now = Instant.now()
|
||||
val nowMillis = now.toEpochMilli()
|
||||
val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save(
|
||||
ChatRoomQuota(
|
||||
memberId = memberId,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId,
|
||||
remainingFree = 10,
|
||||
remainingPaid = 0,
|
||||
nextRechargeAt = null
|
||||
)
|
||||
)
|
||||
// Lazy refill: nextRechargeAt이 현재를 지났으면 무료 10으로 리셋하고 next=null
|
||||
if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) {
|
||||
quota.remainingFree = 10
|
||||
quota.nextRechargeAt = null
|
||||
}
|
||||
|
||||
val total = calculateAvailableForRoom(
|
||||
globalFree = globalFree,
|
||||
roomFree = quota.remainingFree,
|
||||
roomPaid = quota.remainingPaid
|
||||
)
|
||||
return RoomQuotaStatus(
|
||||
totalRemaining = total,
|
||||
nextRechargeAtEpochMillis = quota.nextRechargeAt,
|
||||
remainingFree = quota.remainingFree,
|
||||
remainingPaid = quota.remainingPaid
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun consumeOneForSend(
|
||||
memberId: Long,
|
||||
chatRoomId: Long,
|
||||
globalFreeProvider: () -> Int,
|
||||
consumeGlobalFree: () -> Unit
|
||||
): RoomQuotaStatus {
|
||||
val now = Instant.now()
|
||||
val nowMillis = now.toEpochMilli()
|
||||
val quota = repo.findForUpdate(memberId, chatRoomId)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
|
||||
// 충전 시간이 지났다면 무료 10으로 리셋하고 next=null
|
||||
if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) {
|
||||
quota.remainingFree = 10
|
||||
quota.nextRechargeAt = null
|
||||
}
|
||||
|
||||
// 1) 유료 우선 사용: 글로벌에 영향 없음
|
||||
if (quota.remainingPaid > 0) {
|
||||
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)
|
||||
return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid)
|
||||
}
|
||||
|
||||
// 2) 무료 사용: 글로벌과 룸 동시에 조건 충족 필요
|
||||
val globalFree = globalFreeProvider()
|
||||
if (globalFree <= 0) {
|
||||
// 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가
|
||||
throw SodaException("오늘의 무료 채팅이 모두 소진되었습니다. 내일 다시 이용해 주세요.")
|
||||
}
|
||||
if (quota.remainingFree <= 0) {
|
||||
// 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가
|
||||
val waitMillis = quota.nextRechargeAt
|
||||
if (waitMillis == null) {
|
||||
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
|
||||
}
|
||||
|
||||
throw SodaException("무료 채팅이 모두 소진되었습니다.")
|
||||
}
|
||||
|
||||
// 둘 다 가능 → 차감
|
||||
consumeGlobalFree()
|
||||
quota.remainingFree -= 1
|
||||
if (quota.remainingFree == 0) {
|
||||
// 무료가 0이 되는 순간 nextRechargeAt = 현재 + 6시간
|
||||
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
|
||||
}
|
||||
val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid)
|
||||
return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun purchase(
|
||||
memberId: Long,
|
||||
chatRoomId: Long,
|
||||
characterId: Long,
|
||||
addPaid: Int = 40,
|
||||
container: String
|
||||
): RoomQuotaStatus {
|
||||
// 요구사항: 30캔 결제 및 UseCan에 방/캐릭터 기록
|
||||
canPaymentService.spendCan(
|
||||
memberId = memberId,
|
||||
needCan = 30,
|
||||
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId,
|
||||
container = container
|
||||
)
|
||||
|
||||
val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save(
|
||||
ChatRoomQuota(
|
||||
memberId = memberId,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId
|
||||
)
|
||||
)
|
||||
quota.remainingPaid += addPaid
|
||||
quota.nextRechargeAt = null
|
||||
|
||||
val total = quota.remainingPaid + quota.remainingFree
|
||||
return RoomQuotaStatus(
|
||||
totalRemaining = total,
|
||||
nextRechargeAtEpochMillis = quota.nextRechargeAt,
|
||||
remainingFree = quota.remainingFree,
|
||||
remainingPaid = quota.remainingPaid
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun transferPaid(memberId: Long, fromChatRoomId: Long, toChatRoomId: Long, toCharacterId: Long) {
|
||||
val from = repo.findForUpdate(memberId, fromChatRoomId) ?: return
|
||||
if (from.remainingPaid <= 0) return
|
||||
val to = repo.findForUpdate(memberId, toChatRoomId) ?: repo.save(
|
||||
ChatRoomQuota(
|
||||
memberId = memberId,
|
||||
chatRoomId = toChatRoomId,
|
||||
characterId = toCharacterId
|
||||
)
|
||||
)
|
||||
to.remainingPaid += from.remainingPaid
|
||||
from.remainingPaid = 0
|
||||
// 유료 이관은 룸 무료 충전 시간에 영향을 주지 않음
|
||||
}
|
||||
}
|
@@ -1,14 +1,18 @@
|
||||
package kr.co.vividnext.sodalive.chat.room
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
@Entity
|
||||
class ChatMessage(
|
||||
// 텍스트 메시지 본문. 현재는 NOT NULL 유지. IMAGE 타입 등 비텍스트 메시지는 빈 문자열("") 저장 방침.
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
val message: String,
|
||||
|
||||
@@ -20,5 +24,23 @@ class ChatMessage(
|
||||
@JoinColumn(name = "participant_id", nullable = false)
|
||||
val participant: ChatParticipant,
|
||||
|
||||
val isActive: Boolean = true
|
||||
val isActive: Boolean = true,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "message_type", nullable = false)
|
||||
val messageType: ChatMessageType = ChatMessageType.TEXT,
|
||||
|
||||
// 미리 저장된 캐릭터 이미지 참조 (옵션)
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "character_image_id", nullable = true)
|
||||
val characterImage: CharacterImage? = null,
|
||||
|
||||
// 이미지 정적 경로 스냅샷 (옵션)
|
||||
@Column(name = "image_path", nullable = true, length = 1024)
|
||||
val imagePath: String? = null,
|
||||
|
||||
// 메시지 가격 (옵션). 제공되는 경우 1 이상이어야 함.
|
||||
// Bean Validation 사용 시 @field:Min(1) 추가 고려.
|
||||
@Column(name = "price", nullable = true)
|
||||
val price: Int? = null
|
||||
) : BaseEntity()
|
||||
|
@@ -0,0 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.chat.room
|
||||
|
||||
/**
|
||||
* 채팅 메시지 타입
|
||||
* - TEXT: 일반 텍스트 메시지
|
||||
* - IMAGE: 이미지 메시지(캐릭터 이미지 등)
|
||||
*
|
||||
* 유의: 유료 여부는 별도 price 필드로 표현합니다.
|
||||
*/
|
||||
enum class ChatMessageType {
|
||||
TEXT,
|
||||
IMAGE
|
||||
}
|
@@ -10,7 +10,7 @@ import javax.persistence.OneToMany
|
||||
class ChatRoom(
|
||||
val sessionId: String,
|
||||
val title: String,
|
||||
val isActive: Boolean = true
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
|
||||
val messages: MutableList<ChatMessage> = mutableListOf()
|
||||
|
@@ -1,5 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.chat.room.controller
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.ChatMessagePurchaseRequest
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomResetRequest
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest
|
||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||
@@ -90,12 +92,13 @@ class ChatRoomController(
|
||||
@GetMapping("/{chatRoomId}/enter")
|
||||
fun enterChatRoom(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable chatRoomId: Long
|
||||
@PathVariable chatRoomId: Long,
|
||||
@RequestParam(required = false) characterImageId: Long?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val response = chatRoomService.enterChatRoom(member, chatRoomId)
|
||||
val response = chatRoomService.enterChatRoom(member, chatRoomId, characterImageId)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
@@ -159,4 +162,43 @@ class ChatRoomController(
|
||||
ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 유료 메시지 구매 API
|
||||
* - 참여 여부 검증
|
||||
* - 이미지 메시지의 경우 이미 보유 시 결제 없이 true 반환
|
||||
* - 그 외 가격 검증 후 CanPaymentService 통해 결제 처리
|
||||
*/
|
||||
@PostMapping("/{chatRoomId}/messages/{messageId}/purchase")
|
||||
fun purchaseMessage(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable chatRoomId: Long,
|
||||
@PathVariable messageId: Long,
|
||||
@RequestBody request: ChatMessagePurchaseRequest
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container)
|
||||
ApiResponse.ok(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* 채팅방 초기화 API
|
||||
* - 로그인 및 본인인증 확인
|
||||
* - 내가 참여 중인 AI 캐릭터 채팅방인지 확인
|
||||
* - 30캔 결제 → 현재 채팅방 나가기 → 동일 캐릭터와 새 채팅방 생성 → 생성된 채팅방 데이터 반환
|
||||
*/
|
||||
@PostMapping("/{chatRoomId}/reset")
|
||||
fun resetChatRoom(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable chatRoomId: Long,
|
||||
@RequestBody request: ChatRoomResetRequest
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val response = chatRoomService.resetChatRoom(member, chatRoomId, request.container)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
}
|
||||
|
@@ -24,6 +24,7 @@ data class CreateChatRoomResponse(
|
||||
*/
|
||||
data class ChatRoomListItemDto(
|
||||
val chatRoomId: Long,
|
||||
val characterId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String,
|
||||
val opponentType: String,
|
||||
@@ -39,7 +40,11 @@ data class ChatMessageItemDto(
|
||||
val message: String,
|
||||
val profileImageUrl: String,
|
||||
val mine: Boolean,
|
||||
val createdAt: Long
|
||||
val createdAt: Long,
|
||||
val messageType: String,
|
||||
val imageUrl: String?,
|
||||
val price: Int?,
|
||||
val hasAccess: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -57,6 +62,7 @@ data class ChatMessagesPageResponse(
|
||||
|
||||
data class ChatRoomListQueryDto(
|
||||
val chatRoomId: Long,
|
||||
val characterId: Long,
|
||||
val title: String,
|
||||
val imagePath: String?,
|
||||
val characterType: CharacterType,
|
||||
@@ -126,6 +132,13 @@ data class SendChatMessageRequest(
|
||||
val message: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 유료 메시지 구매 요청 DTO
|
||||
*/
|
||||
data class ChatMessagePurchaseRequest(
|
||||
val container: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 외부 API 채팅 전송 응답 DTO
|
||||
*/
|
||||
@@ -169,5 +182,24 @@ data class ChatRoomEnterResponse(
|
||||
val roomId: Long,
|
||||
val character: ChatRoomEnterCharacterDto,
|
||||
val messages: List<ChatMessageItemDto>,
|
||||
val hasMoreMessages: Boolean
|
||||
val hasMoreMessages: Boolean,
|
||||
val totalRemaining: Int,
|
||||
val nextRechargeAtEpoch: Long?,
|
||||
val bgImageUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 채팅 메시지 전송 응답 DTO (메시지 + 쿼터 상태)
|
||||
*/
|
||||
data class SendChatMessageResponse(
|
||||
val messages: List<ChatMessageItemDto>,
|
||||
val totalRemaining: Int,
|
||||
val nextRechargeAtEpoch: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
* 채팅방 초기화 요청 DTO
|
||||
*/
|
||||
data class ChatRoomResetRequest(
|
||||
val container: String
|
||||
)
|
||||
|
@@ -39,6 +39,7 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
|
||||
value = """
|
||||
SELECT new kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto(
|
||||
r.id,
|
||||
pc.character.id,
|
||||
r.title,
|
||||
pc.character.imagePath,
|
||||
pc.character.characterType,
|
||||
@@ -54,7 +55,7 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
|
||||
AND pc.isActive = true
|
||||
AND r.isActive = true
|
||||
AND m.isActive = true
|
||||
GROUP BY r.id, r.title, r.createdAt, pc.character.imagePath, pc.character.characterType
|
||||
GROUP BY r.id, r.title, r.createdAt, pc.character.id, pc.character.imagePath, pc.character.characterType
|
||||
ORDER BY MAX(m.createdAt) DESC
|
||||
"""
|
||||
)
|
||||
@@ -62,4 +63,6 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
|
||||
@Param("member") member: Member,
|
||||
pageable: Pageable
|
||||
): List<ChatRoomListQueryDto>
|
||||
|
||||
fun findByIdAndIsActiveTrue(id: Long): ChatRoom?
|
||||
}
|
||||
|
@@ -1,8 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.chat.room.service
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaService
|
||||
import kr.co.vividnext.sodalive.chat.room.ChatMessage
|
||||
import kr.co.vividnext.sodalive.chat.room.ChatMessageType
|
||||
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
|
||||
import kr.co.vividnext.sodalive.chat.room.ChatRoom
|
||||
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
||||
@@ -16,6 +21,7 @@ import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
|
||||
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse
|
||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository
|
||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
|
||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
|
||||
@@ -34,6 +40,7 @@ import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
@@ -42,6 +49,11 @@ class ChatRoomService(
|
||||
private val participantRepository: ChatParticipantRepository,
|
||||
private val messageRepository: ChatMessageRepository,
|
||||
private val characterService: ChatCharacterService,
|
||||
private val characterImageService: CharacterImageService,
|
||||
private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService,
|
||||
private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront,
|
||||
private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService,
|
||||
private val chatRoomQuotaService: ChatRoomQuotaService,
|
||||
|
||||
@Value("\${weraser.api-key}")
|
||||
private val apiKey: String,
|
||||
@@ -57,6 +69,45 @@ class ChatRoomService(
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(ChatRoomService::class.java)
|
||||
|
||||
@Transactional
|
||||
fun purchaseMessage(member: Member, chatRoomId: Long, messageId: Long, container: String): ChatMessageItemDto {
|
||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
// 참여 여부 검증
|
||||
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
val message = messageRepository.findById(messageId).orElseThrow {
|
||||
SodaException("메시지를 찾을 수 없습니다.")
|
||||
}
|
||||
if (!message.isActive) throw SodaException("비활성화된 메시지입니다.")
|
||||
if (message.chatRoom.id != room.id) throw SodaException("잘못된 접근입니다")
|
||||
|
||||
val price = message.price ?: throw SodaException("구매할 수 없는 메시지입니다.")
|
||||
if (price <= 0) throw SodaException("구매 가격이 잘못되었습니다.")
|
||||
|
||||
// 이미지 메시지인 경우: 이미 소유했다면 결제 생략하고 DTO 반환
|
||||
if (message.messageType == ChatMessageType.IMAGE) {
|
||||
val image = message.characterImage
|
||||
if (image != null) {
|
||||
val alreadyOwned = characterImageService.isOwnedImageByMember(image.id!!, member.id!!)
|
||||
if (alreadyOwned) {
|
||||
return toChatMessageItemDto(message, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 결제 진행 및 UseCan 기록 (이미지 메시지면 chatMessage + characterImage 동시 기록됨)
|
||||
canPaymentService.spendCanForChatMessage(
|
||||
memberId = member.id!!,
|
||||
needCan = price,
|
||||
message = message,
|
||||
container = container
|
||||
)
|
||||
// 결제 완료 후 접근 가능 상태로 DTO 반환
|
||||
return toChatMessageItemDto(message, member, forceHasAccess = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 채팅방 생성 또는 조회
|
||||
*
|
||||
@@ -202,9 +253,17 @@ class ChatRoomService(
|
||||
).apply { id = q.chatRoomId }
|
||||
|
||||
val latest = messageRepository.findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(room)
|
||||
val preview = latest?.message?.let { msg ->
|
||||
val preview = if (latest?.message?.isNotBlank() == true) {
|
||||
latest.message.let { msg ->
|
||||
if (msg.length <= 30) msg else msg.take(30) + "..."
|
||||
}
|
||||
} else {
|
||||
if (latest?.message.isNullOrBlank() && latest?.characterImage != null) {
|
||||
"[이미지]"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
val imageUrl = "$imageHost/${q.imagePath ?: "profile/default-profile.png"}"
|
||||
val opponentType = q.characterType.name // Clone or Character
|
||||
@@ -213,6 +272,7 @@ class ChatRoomService(
|
||||
|
||||
ChatRoomListItemDto(
|
||||
chatRoomId = q.chatRoomId,
|
||||
characterId = q.characterId,
|
||||
title = q.title,
|
||||
imageUrl = imageUrl,
|
||||
opponentType = opponentType,
|
||||
@@ -238,9 +298,8 @@ class ChatRoomService(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean {
|
||||
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||
SodaException("채팅방을 찾을 수 없습니다.")
|
||||
}
|
||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||
if (participant == null) {
|
||||
throw SodaException("잘못된 접근입니다")
|
||||
@@ -248,20 +307,51 @@ class ChatRoomService(
|
||||
return fetchSessionActive(room.sessionId)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun enterChatRoom(member: Member, chatRoomId: Long): ChatRoomEnterResponse {
|
||||
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||
@Transactional
|
||||
fun enterChatRoom(member: Member, chatRoomId: Long, characterImageId: Long? = null): ChatRoomEnterResponse {
|
||||
// 1) 활성 여부 무관하게 방 조회
|
||||
val baseRoom = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||
SodaException("채팅방을 찾을 수 없습니다.")
|
||||
}
|
||||
// 참여 여부 검증
|
||||
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
// 캐릭터 참여자 조회
|
||||
val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
|
||||
room,
|
||||
// 2) 기본 방 기준 참여/활성 여부 확인
|
||||
val isActiveRoom = baseRoom.isActive
|
||||
val isMyActiveParticipation =
|
||||
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(baseRoom, member) != null
|
||||
|
||||
// 3) 기본 방의 캐릭터 식별 (활성 우선, 없으면 컬렉션에서 검색)
|
||||
val baseCharacterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
|
||||
baseRoom,
|
||||
ParticipantType.CHARACTER
|
||||
) ?: throw SodaException("잘못된 접근입니다")
|
||||
) ?: baseRoom.participants.firstOrNull {
|
||||
it.participantType == ParticipantType.CHARACTER
|
||||
} ?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
val baseCharacter = baseCharacterParticipant.character
|
||||
?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.")
|
||||
|
||||
// 4) 유효한 입장 대상 방 결정
|
||||
val effectiveRoom: ChatRoom = if (isActiveRoom && isMyActiveParticipation) {
|
||||
baseRoom
|
||||
} else {
|
||||
// 동일 캐릭터 + 내가 참여 중인 활성 방을 찾는다
|
||||
val alt = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, baseCharacter)
|
||||
alt ?: ( // 대체 방이 없으면 기존과 동일하게 예외 처리
|
||||
if (!isActiveRoom) {
|
||||
throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
} else {
|
||||
throw SodaException("잘못된 접근입니다")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 5) 응답 구성 시에는 effectiveRoom의 캐릭터(활성 우선) 사용
|
||||
val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
|
||||
effectiveRoom,
|
||||
ParticipantType.CHARACTER
|
||||
) ?: effectiveRoom.participants.firstOrNull {
|
||||
it.participantType == ParticipantType.CHARACTER
|
||||
} ?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
val character = characterParticipant.character
|
||||
?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.")
|
||||
@@ -274,41 +364,106 @@ class ChatRoomService(
|
||||
characterType = character.characterType.name
|
||||
)
|
||||
|
||||
// 메시지 최신 20개 조회 후 createdAt 오름차순으로 반환
|
||||
// 메시지 최신 20개 조회 후 createdAt 오름차순으로 반환 (effectiveRoom 기준)
|
||||
val pageable = PageRequest.of(0, 20)
|
||||
val fetched = messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable)
|
||||
val fetched = messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(effectiveRoom, pageable)
|
||||
|
||||
val nextCursor: Long? = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id
|
||||
val hasMore: Boolean = if (nextCursor != null) {
|
||||
messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, nextCursor)
|
||||
messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(effectiveRoom, nextCursor)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val messagesAsc = fetched.sortedBy { it.createdAt }
|
||||
val items = messagesAsc.map { msg ->
|
||||
val sender = msg.participant
|
||||
val profilePath = when (sender.participantType) {
|
||||
ParticipantType.USER -> sender.member?.profileImage
|
||||
ParticipantType.CHARACTER -> sender.character?.imagePath
|
||||
}
|
||||
val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
|
||||
val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||
?: 0L
|
||||
ChatMessageItemDto(
|
||||
messageId = msg.id!!,
|
||||
message = msg.message,
|
||||
profileImageUrl = senderImageUrl,
|
||||
mine = sender.member?.id == member.id,
|
||||
createdAt = createdAtMillis
|
||||
val items = messagesAsc.map { toChatMessageItemDto(it, member) }
|
||||
|
||||
// 5-1) 글로벌 쿼터 Lazy refill
|
||||
val globalStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!)
|
||||
// 5-2) 룸 쿼터 Lazy refill + 상태
|
||||
val roomStatus = chatRoomQuotaService.applyRefillOnEnterAndGetStatus(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = effectiveRoom.id!!,
|
||||
characterId = character.id!!,
|
||||
globalFree = globalStatus.totalRemaining
|
||||
)
|
||||
|
||||
// 선택적 캐릭터 이미지 서명 URL 생성 처리
|
||||
// 요구사항: baseRoom이 조건 불만족으로 동일 캐릭터의 내 활성 방으로 라우팅된 경우(bg 이미지 요청 무시)에는 null로 처리
|
||||
val signedUrl: String? =
|
||||
if (effectiveRoom.id != baseRoom.id) {
|
||||
null
|
||||
} else {
|
||||
try {
|
||||
if (characterImageId != null) {
|
||||
val img = characterImageService.getById(characterImageId)
|
||||
// 동일 캐릭터 소속 및 활성 검증
|
||||
if (img.chatCharacter.id == character.id && img.isActive) {
|
||||
val owned =
|
||||
(img.imagePriceCan == 0L) || characterImageService.isOwnedImageByMember(
|
||||
img.id!!,
|
||||
member.id!!
|
||||
)
|
||||
if (owned) {
|
||||
val expiration = 5L * 60L * 1000L // 5분
|
||||
imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 문제가 있어도 입장 자체는 가능해야 하므로 로그만 남기고 null 반환
|
||||
log.warn(
|
||||
"[chat] enter: signed url generation failed. roomId={}, imageId={}, reason={}",
|
||||
effectiveRoom.id,
|
||||
characterImageId,
|
||||
e.message
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// 권고안 + 이슈 보정: 채팅 가능(totalRemaining>0)인 경우 next=null
|
||||
val nextForEnter: Long? = when {
|
||||
// 채팅 가능: 유료>0 또는 무료 동시 사용 가능 → next는 표시하지 않음
|
||||
roomStatus.totalRemaining > 0 -> null
|
||||
// roomPaid==0 && roomFree>0 && global<=0 → 글로벌 next
|
||||
roomStatus.remainingPaid == 0 && roomStatus.remainingFree > 0 && globalStatus.totalRemaining <= 0 ->
|
||||
globalStatus.nextRechargeAtEpochMillis
|
||||
// roomPaid==0 && roomFree==0 → (global<=0) ? max(roomNext, globalNext) : roomNext
|
||||
roomStatus.remainingPaid == 0 && roomStatus.remainingFree == 0 -> {
|
||||
val roomNext = roomStatus.nextRechargeAtEpochMillis
|
||||
val globalNext = globalStatus.nextRechargeAtEpochMillis
|
||||
if (globalStatus.totalRemaining <= 0) {
|
||||
if (roomNext == null) {
|
||||
globalNext
|
||||
} else if (globalNext == null) {
|
||||
roomNext
|
||||
} else {
|
||||
maxOf(roomNext, globalNext)
|
||||
}
|
||||
} else {
|
||||
roomNext
|
||||
}
|
||||
}
|
||||
// 그 외 기존 규칙: room total==0 → room next, else if global<=0 → global next, else null
|
||||
roomStatus.totalRemaining == 0 -> roomStatus.nextRechargeAtEpochMillis
|
||||
globalStatus.totalRemaining <= 0 -> globalStatus.nextRechargeAtEpochMillis
|
||||
else -> null
|
||||
}
|
||||
return ChatRoomEnterResponse(
|
||||
roomId = room.id!!,
|
||||
roomId = effectiveRoom.id!!,
|
||||
character = characterDto,
|
||||
messages = items,
|
||||
hasMoreMessages = hasMore
|
||||
hasMoreMessages = hasMore,
|
||||
totalRemaining = roomStatus.totalRemaining,
|
||||
nextRechargeAtEpoch = nextForEnter,
|
||||
bgImageUrl = signedUrl
|
||||
)
|
||||
}
|
||||
|
||||
@@ -352,10 +507,9 @@ class ChatRoomService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun leaveChatRoom(member: Member, chatRoomId: Long) {
|
||||
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||
SodaException("채팅방을 찾을 수 없습니다.")
|
||||
}
|
||||
fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) {
|
||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
@@ -371,12 +525,13 @@ class ChatRoomService(
|
||||
|
||||
// 3) 내가 마지막 USER였다면 외부 세션 종료
|
||||
if (userCount == 0L) {
|
||||
endExternalSession(room.sessionId)
|
||||
endExternalSession(room.sessionId, throwOnFailure = throwOnSessionEndFailure)
|
||||
room.isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun endExternalSession(sessionId: String) {
|
||||
// 사용자 흐름을 방해하지 않기 위해 실패 시 예외를 던지지 않고 내부 재시도 후 로그만 남깁니다.
|
||||
private fun endExternalSession(sessionId: String, throwOnFailure: Boolean = false) {
|
||||
// 기본 동작: 내부 재시도. throwOnFailure=true일 때는 최종 실패 시 예외 전파.
|
||||
val maxAttempts = 3
|
||||
var attempt = 0
|
||||
while (attempt < maxAttempts) {
|
||||
@@ -390,7 +545,6 @@ class ChatRoomService(
|
||||
|
||||
val headers = HttpHeaders()
|
||||
headers.set("x-api-key", apiKey)
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
|
||||
val httpEntity = HttpEntity(null, headers)
|
||||
|
||||
@@ -419,15 +573,20 @@ class ChatRoomService(
|
||||
log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message)
|
||||
}
|
||||
}
|
||||
// 최종 실패 로그 (예외 미전파)
|
||||
// 최종 실패 처리
|
||||
val message = "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요."
|
||||
if (throwOnFailure) {
|
||||
log.error("[chat] 외부 세션 종료 최종 실패(예외 전파): sessionId={}, attempts={}", sessionId, maxAttempts)
|
||||
throw SodaException(message)
|
||||
} else {
|
||||
log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse {
|
||||
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||
SodaException("채팅방을 찾을 수 없습니다.")
|
||||
}
|
||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
@@ -449,23 +608,7 @@ class ChatRoomService(
|
||||
// createdAt 오름차순으로 정렬하여 반환
|
||||
val messagesAsc = fetched.sortedBy { it.createdAt }
|
||||
|
||||
val items = messagesAsc.map { msg ->
|
||||
val sender = msg.participant
|
||||
val profilePath = when (sender.participantType) {
|
||||
ParticipantType.USER -> sender.member?.profileImage
|
||||
ParticipantType.CHARACTER -> sender.character?.imagePath
|
||||
}
|
||||
val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
|
||||
val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
|
||||
?: 0L
|
||||
ChatMessageItemDto(
|
||||
messageId = msg.id!!,
|
||||
message = msg.message,
|
||||
profileImageUrl = imageUrl,
|
||||
mine = sender.member?.id == member.id,
|
||||
createdAt = createdAtMillis
|
||||
)
|
||||
}
|
||||
val items = messagesAsc.map { toChatMessageItemDto(it, member) }
|
||||
|
||||
return ChatMessagesPageResponse(
|
||||
messages = items,
|
||||
@@ -475,11 +618,10 @@ class ChatRoomService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun sendMessage(member: Member, chatRoomId: Long, message: String): List<ChatMessageItemDto> {
|
||||
fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse {
|
||||
// 1) 방 존재 확인
|
||||
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||
SodaException("채팅방을 찾을 수 없습니다.")
|
||||
}
|
||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
// 2) 참여 여부 확인 (USER)
|
||||
val myParticipant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
@@ -497,7 +639,15 @@ class ChatRoomService(
|
||||
val sessionId = room.sessionId
|
||||
val characterUUID = character.characterUUID
|
||||
|
||||
// 5) 외부 API 호출 (최대 3회 재시도)
|
||||
// 5) 쿼터 확인 및 차감 (유료 우선, 무료 사용 시 글로벌과 룸 동시 차감)
|
||||
val roomQuotaAfterConsume = chatRoomQuotaService.consumeOneForSend(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = room.id!!,
|
||||
globalFreeProvider = { chatQuotaService.getStatus(member.id!!).totalRemaining },
|
||||
consumeGlobalFree = { chatQuotaService.consumeOneFree(member.id!!) }
|
||||
)
|
||||
|
||||
// 6) 외부 API 호출 (최대 3회 재시도)
|
||||
val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId)
|
||||
|
||||
// 6) 내 메시지 저장
|
||||
@@ -509,30 +659,146 @@ class ChatRoomService(
|
||||
)
|
||||
messageRepository.save(myMsgEntity)
|
||||
|
||||
// 7) 캐릭터 메시지 저장
|
||||
val characterMsgEntity = ChatMessage(
|
||||
// 7) 캐릭터 텍스트 메시지 항상 저장
|
||||
val characterTextMsg = messageRepository.save(
|
||||
ChatMessage(
|
||||
message = characterReply,
|
||||
chatRoom = room,
|
||||
participant = characterParticipant,
|
||||
isActive = true
|
||||
)
|
||||
val savedCharacterMsg = messageRepository.save(characterMsgEntity)
|
||||
|
||||
// 8) 응답 DTO 구성 (캐릭터 메시지 리스트 반환 - 단일 요소)
|
||||
val profilePath = characterParticipant.character?.imagePath
|
||||
val defaultPath = profilePath ?: "profile/default-profile.png"
|
||||
val imageUrl = "$imageHost/$defaultPath"
|
||||
val dto = ChatMessageItemDto(
|
||||
messageId = savedCharacterMsg.id!!,
|
||||
message = savedCharacterMsg.message,
|
||||
profileImageUrl = imageUrl,
|
||||
mine = false,
|
||||
createdAt = savedCharacterMsg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()
|
||||
?.toEpochMilli()
|
||||
?: 0L
|
||||
)
|
||||
|
||||
return listOf(dto)
|
||||
// 응답 프로필 이미지 URL 공통 구성
|
||||
val profilePath = characterParticipant.character?.imagePath
|
||||
val defaultPath = profilePath ?: "profile/default-profile.png"
|
||||
val senderImageUrl = "$imageHost/$defaultPath"
|
||||
|
||||
val textDto = ChatMessageItemDto(
|
||||
messageId = characterTextMsg.id!!,
|
||||
message = characterTextMsg.message,
|
||||
profileImageUrl = senderImageUrl,
|
||||
mine = false,
|
||||
createdAt = characterTextMsg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()
|
||||
?.toEpochMilli()
|
||||
?: 0L,
|
||||
messageType = ChatMessageType.TEXT.name,
|
||||
imageUrl = null,
|
||||
price = null,
|
||||
hasAccess = true
|
||||
)
|
||||
|
||||
// 발송 후 최신 잔여 수량 및 next 계산 규칙 적용
|
||||
val statusTotalRemaining = roomQuotaAfterConsume.totalRemaining
|
||||
val globalAfter = chatQuotaService.getStatus(member.id!!)
|
||||
val statusNextRechargeAt: Long? = when {
|
||||
// 채팅 가능: totalRemaining>0 → next 표시하지 않음
|
||||
statusTotalRemaining > 0 -> null
|
||||
// totalRemaining==0이고 (global<=0) → max(roomNext, globalNext)
|
||||
statusTotalRemaining == 0 && globalAfter.totalRemaining <= 0 -> {
|
||||
val roomNext = roomQuotaAfterConsume.nextRechargeAtEpochMillis
|
||||
val globalNext = globalAfter.nextRechargeAtEpochMillis
|
||||
if (roomNext == null) globalNext else if (globalNext == null) roomNext else maxOf(roomNext, globalNext)
|
||||
}
|
||||
|
||||
statusTotalRemaining == 0 -> roomQuotaAfterConsume.nextRechargeAtEpochMillis
|
||||
globalAfter.totalRemaining <= 0 -> globalAfter.nextRechargeAtEpochMillis
|
||||
else -> null
|
||||
}
|
||||
|
||||
// 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우)
|
||||
val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply)
|
||||
if (matchedImage != null) {
|
||||
val owned = characterImageService.isOwnedImageByMember(matchedImage.id!!, member.id!!)
|
||||
val priceInt: Int? = if (owned) {
|
||||
null
|
||||
} else {
|
||||
val p = matchedImage.messagePriceCan
|
||||
if (p <= 0L) null else if (p > Int.MAX_VALUE) Int.MAX_VALUE else p.toInt()
|
||||
}
|
||||
// 보유하지 않은 경우 블러 이미지로 전송
|
||||
val snapshotPath = if (owned) matchedImage.imagePath else matchedImage.blurImagePath
|
||||
val imageMsg = messageRepository.save(
|
||||
ChatMessage(
|
||||
message = "",
|
||||
chatRoom = room,
|
||||
participant = characterParticipant,
|
||||
isActive = true,
|
||||
messageType = ChatMessageType.IMAGE,
|
||||
characterImage = matchedImage,
|
||||
imagePath = snapshotPath,
|
||||
price = priceInt
|
||||
)
|
||||
)
|
||||
|
||||
val imageDto = toChatMessageItemDto(imageMsg, member)
|
||||
return SendChatMessageResponse(
|
||||
messages = listOf(textDto, imageDto),
|
||||
totalRemaining = statusTotalRemaining,
|
||||
nextRechargeAtEpoch = statusNextRechargeAt
|
||||
)
|
||||
}
|
||||
|
||||
return SendChatMessageResponse(
|
||||
messages = listOf(textDto),
|
||||
totalRemaining = statusTotalRemaining,
|
||||
nextRechargeAtEpoch = statusNextRechargeAt
|
||||
)
|
||||
}
|
||||
|
||||
private fun toChatMessageItemDto(
|
||||
msg: ChatMessage,
|
||||
member: Member,
|
||||
forceHasAccess: Boolean = false
|
||||
): ChatMessageItemDto {
|
||||
val sender = msg.participant
|
||||
val profilePath = when (sender.participantType) {
|
||||
ParticipantType.USER -> sender.member?.profileImage
|
||||
ParticipantType.CHARACTER -> sender.character?.imagePath
|
||||
}
|
||||
val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}"
|
||||
val createdAtMillis = msg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L
|
||||
val hasAccess = if (forceHasAccess) {
|
||||
true
|
||||
} else if (msg.messageType == ChatMessageType.IMAGE) {
|
||||
if (msg.price == null) {
|
||||
true
|
||||
} else {
|
||||
msg.characterImage?.id?.let {
|
||||
characterImageService.isOwnedImageByMember(it, member.id!!)
|
||||
} ?: true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
val expirationMs = 5L * 60L * 1000L
|
||||
val resolvedImageUrl: String? = if (msg.messageType == ChatMessageType.IMAGE) {
|
||||
val path = if (hasAccess) {
|
||||
msg.characterImage?.imagePath ?: msg.imagePath
|
||||
} else {
|
||||
msg.imagePath
|
||||
}
|
||||
path?.let { p ->
|
||||
if (hasAccess) {
|
||||
imageCloudFront.generateSignedURL(p, expirationMs)
|
||||
} else {
|
||||
"$imageHost/$p"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return ChatMessageItemDto(
|
||||
messageId = msg.id!!,
|
||||
message = msg.message,
|
||||
profileImageUrl = senderImageUrl,
|
||||
mine = sender.member?.id == member.id,
|
||||
createdAt = createdAtMillis,
|
||||
messageType = msg.messageType.name,
|
||||
imageUrl = resolvedImageUrl,
|
||||
price = msg.price,
|
||||
hasAccess = hasAccess
|
||||
)
|
||||
}
|
||||
|
||||
private fun callExternalApiForChatSendWithRetry(
|
||||
@@ -603,4 +869,60 @@ class ChatRoomService(
|
||||
}
|
||||
return characterContent
|
||||
}
|
||||
|
||||
private fun findTriggeredCharacterImage(characterId: Long, replyText: String): CharacterImage? {
|
||||
val text = replyText.lowercase()
|
||||
val images: List<CharacterImage> = characterImageService.listActiveByCharacter(characterId)
|
||||
for (img in images) {
|
||||
val triggers = img.triggerMappings
|
||||
.map { it.tag.word.trim().lowercase() }
|
||||
.filter { it.isNotBlank() }
|
||||
if (triggers.isEmpty()) continue
|
||||
val allIncluded = triggers.all { t -> text.contains(t) }
|
||||
if (allIncluded) return img
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetChatRoom(member: Member, chatRoomId: Long, container: String): CreateChatRoomResponse {
|
||||
// 0) 방 존재 및 내 참여 여부 확인
|
||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
// 1) AI 캐릭터 채팅방인지 확인 (CHARACTER 타입의 활성 참여자 존재 확인)
|
||||
val characterParticipant = participantRepository
|
||||
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
|
||||
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
|
||||
val character = characterParticipant.character
|
||||
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.")
|
||||
|
||||
// 2) 30캔 결제 (채팅방 초기화 전용 CanUsage 사용)
|
||||
canPaymentService.spendCan(
|
||||
memberId = member.id!!,
|
||||
needCan = 30,
|
||||
canUsage = CanUsage.CHAT_ROOM_RESET,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = character.id!!,
|
||||
container = container
|
||||
)
|
||||
|
||||
// 3) 현재 채팅방 나가기 (세션 종료 실패 시 롤백되도록 설정)
|
||||
leaveChatRoom(member, chatRoomId, true)
|
||||
|
||||
// 4) 동일한 캐릭터와 새로운 채팅방 생성
|
||||
val created = createOrGetChatRoom(member, character.id!!)
|
||||
|
||||
// 5) 신규 채팅방 생성 성공 시: 기존 방의 유료 쿼터를 새 방으로 이관
|
||||
chatRoomQuotaService.transferPaid(
|
||||
memberId = member.id!!,
|
||||
fromChatRoomId = chatRoomId,
|
||||
toChatRoomId = created.chatRoomId,
|
||||
toCharacterId = character.id!!
|
||||
)
|
||||
// 글로벌 무료 쿼터는 UTC 20:00 기준 lazy 충전이므로 별도의 초기화 불필요
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
.cacheDefaults(defaultCacheConfig)
|
||||
.withInitialCacheConfigurations(cacheConfigMap)
|
||||
|
@@ -95,6 +95,7 @@ class SecurityConfig(
|
||||
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.build()
|
||||
|
@@ -53,7 +53,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -119,7 +122,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -196,7 +202,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
audioContent.member.id.eq(memberId)
|
||||
.and(order.isActive.isTrue)
|
||||
@@ -318,7 +327,10 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
|
173
src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt
Normal file
173
src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt
Normal file
@@ -0,0 +1,173 @@
|
||||
package kr.co.vividnext.sodalive.utils
|
||||
|
||||
import java.awt.image.BufferedImage
|
||||
import java.awt.image.DataBufferInt
|
||||
import java.util.stream.IntStream
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* 가우시안 커널 기반 블러 유틸리티
|
||||
* - 반경(radius)에 따라 커널 크기(2*radius+1) 생성
|
||||
* - 시그마는 관례적으로 radius/3.0 적용
|
||||
* - 수평/수직 분리 합성곱으로 품질과 성능 확보
|
||||
*/
|
||||
/**
|
||||
* 고속 가우시안 블러 유틸
|
||||
*
|
||||
* - 원본 비율/해상도 그대로 두고 "큰 반경 블러"만 빠르게 적용하고 싶을 때 사용합니다.
|
||||
* - 강한 익명화를 원하면(식별 불가 수준) 이 함수 대신
|
||||
* "다운스케일 → 큰 반경 블러 → 원본 해상도로 업스케일"을 조합하세요.
|
||||
* (예: ImageUtils.anonymizeStrongFast 처럼)
|
||||
*/
|
||||
object ImageBlurUtil {
|
||||
/**
|
||||
* 분리형(1D) 가우시안 블러(수평 → 수직 2패스), 배열 접근 기반 고속 구현.
|
||||
*
|
||||
* @param src 원본 이미지
|
||||
* @param radius 가우시안 반경(>=1). 클수록 강하게 흐려짐. (권장 5~64)
|
||||
* @param parallel true면 행/열 패스를 병렬 실행(ForkJoinPool). 멀티코어에서만 유효.
|
||||
* @return 블러된 새 이미지 (TYPE_INT_ARGB)
|
||||
*/
|
||||
fun blurFast(src: BufferedImage, radius: Int = 240, parallel: Boolean = true): BufferedImage {
|
||||
require(radius > 0) { "radius must be > 0" }
|
||||
|
||||
// 1) 프리멀티 알파로 변환 (경계 품질↑)
|
||||
val s = toPremultiplied(src) // TYPE_INT_ARGB_PRE
|
||||
val w = s.width
|
||||
val h = s.height
|
||||
|
||||
// 2) 중간/최종 버퍼(프리멀티 유지)
|
||||
val tmp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE)
|
||||
val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE)
|
||||
|
||||
val srcArr = (s.raster.dataBuffer as DataBufferInt).data
|
||||
val tmpArr = (tmp.raster.dataBuffer as DataBufferInt).data
|
||||
val dstArr = (dst.raster.dataBuffer as DataBufferInt).data
|
||||
|
||||
// 3) 1D 가우시안 커널(정규화)
|
||||
// sigma는 일반적으로 radius/3.0이 자연스러운 값
|
||||
val sigma = radius / 3.0
|
||||
val kernel = buildGaussian1D(radius, sigma)
|
||||
|
||||
// 4) 수평 패스 (y 라인별)
|
||||
if (parallel) {
|
||||
IntStream.range(0, h).parallel().forEach { y ->
|
||||
convolveRow(srcArr, tmpArr, w, h, y, kernel, radius)
|
||||
}
|
||||
} else {
|
||||
for (y in 0 until h) convolveRow(srcArr, tmpArr, w, h, y, kernel, radius)
|
||||
}
|
||||
|
||||
// 5) 수직 패스 (x 컬럼별)
|
||||
if (parallel) {
|
||||
IntStream.range(0, w).parallel().forEach { x ->
|
||||
convolveCol(tmpArr, dstArr, w, h, x, kernel, radius)
|
||||
}
|
||||
} else {
|
||||
for (x in 0 until w) convolveCol(tmpArr, dstArr, w, h, x, kernel, radius)
|
||||
}
|
||||
|
||||
// 6) 비프리멀티(일반 ARGB)로 변환해서 반환 (파일 저장/그리기 호환성↑)
|
||||
return toNonPremultiplied(dst)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 내부 구현
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 수평 합성곱: 경계는 replicate(클램프)
|
||||
private fun convolveRow(src: IntArray, dst: IntArray, w: Int, h: Int, y: Int, k: DoubleArray, r: Int) {
|
||||
val base = y * w
|
||||
for (x in 0 until w) {
|
||||
var aAcc = 0.0
|
||||
var rAcc = 0.0
|
||||
var gAcc = 0.0
|
||||
var bAcc = 0.0
|
||||
var i = -r
|
||||
while (i <= r) {
|
||||
val xx = clamp(x + i, 0, w - 1)
|
||||
val argb = src[base + xx]
|
||||
val a = (argb ushr 24) and 0xFF
|
||||
val rr = (argb ushr 16) and 0xFF
|
||||
val gg = (argb ushr 8) and 0xFF
|
||||
val bb = argb and 0xFF
|
||||
val wgt = k[i + r]
|
||||
aAcc += a * wgt; rAcc += rr * wgt; gAcc += gg * wgt; bAcc += bb * wgt
|
||||
i++
|
||||
}
|
||||
val a = aAcc.roundToInt().coerceIn(0, 255)
|
||||
val rr = rAcc.roundToInt().coerceIn(0, 255)
|
||||
val gg = gAcc.roundToInt().coerceIn(0, 255)
|
||||
val bb = bAcc.roundToInt().coerceIn(0, 255)
|
||||
dst[base + x] = (a shl 24) or (rr shl 16) or (gg shl 8) or bb
|
||||
}
|
||||
}
|
||||
|
||||
// 수직 합성곱: 경계 replicate(클램프)
|
||||
private fun convolveCol(src: IntArray, dst: IntArray, w: Int, h: Int, x: Int, k: DoubleArray, r: Int) {
|
||||
var idx = x
|
||||
for (y in 0 until h) {
|
||||
var aAcc = 0.0
|
||||
var rAcc = 0.0
|
||||
var gAcc = 0.0
|
||||
var bAcc = 0.0
|
||||
var i = -r
|
||||
while (i <= r) {
|
||||
val yy = clamp(y + i, 0, h - 1)
|
||||
val argb = src[yy * w + x]
|
||||
val a = (argb ushr 24) and 0xFF
|
||||
val rr = (argb ushr 16) and 0xFF
|
||||
val gg = (argb ushr 8) and 0xFF
|
||||
val bb = argb and 0xFF
|
||||
val wgt = k[i + r]
|
||||
aAcc += a * wgt; rAcc += rr * wgt; gAcc += gg * wgt; bAcc += bb * wgt
|
||||
i++
|
||||
}
|
||||
val a = aAcc.roundToInt().coerceIn(0, 255)
|
||||
val rr = rAcc.roundToInt().coerceIn(0, 255)
|
||||
val gg = gAcc.roundToInt().coerceIn(0, 255)
|
||||
val bb = bAcc.roundToInt().coerceIn(0, 255)
|
||||
dst[idx] = (a shl 24) or (rr shl 16) or (gg shl 8) or bb
|
||||
idx += w
|
||||
}
|
||||
}
|
||||
|
||||
// 1D 가우시안 커널 (정규화)
|
||||
private fun buildGaussian1D(radius: Int, sigma: Double): DoubleArray {
|
||||
val size = radius * 2 + 1
|
||||
val kernel = DoubleArray(size)
|
||||
val sigma2 = 2.0 * sigma * sigma
|
||||
var sum = 0.0
|
||||
for (i in -radius..radius) {
|
||||
val v = exp(-(i * i) / sigma2)
|
||||
kernel[i + radius] = v
|
||||
sum += v
|
||||
}
|
||||
for (i in 0 until size) kernel[i] /= sum
|
||||
return kernel
|
||||
}
|
||||
|
||||
private fun clamp(v: Int, lo: Int, hi: Int): Int = max(lo, min(hi, v))
|
||||
|
||||
// 프리멀티/비프리멀티 변환(빠른 방법: Graphics로 그리기)
|
||||
private fun toPremultiplied(src: BufferedImage): BufferedImage {
|
||||
if (src.type == BufferedImage.TYPE_INT_ARGB_PRE) return src
|
||||
val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB_PRE)
|
||||
val g = out.createGraphics()
|
||||
g.drawImage(src, 0, 0, null)
|
||||
g.dispose()
|
||||
return out
|
||||
}
|
||||
|
||||
private fun toNonPremultiplied(src: BufferedImage): BufferedImage {
|
||||
if (src.type == BufferedImage.TYPE_INT_ARGB) return src
|
||||
val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB)
|
||||
val g = out.createGraphics()
|
||||
g.drawImage(src, 0, 0, null)
|
||||
g.dispose()
|
||||
return out
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user