Compare commits
336 Commits
test
...
c7925c1706
Author | SHA1 | Date | |
---|---|---|---|
c7925c1706 | |||
be59bd7e89 | |||
51ce143fc2 | |||
89eb11f808 | |||
30d89987a4 | |||
7959d3e5ed | |||
1e29573ef7 | |||
cc2f533dc6 | |||
32b0c19f9d | |||
9af2d768e8 | |||
5677824cde | |||
e8f1bc09f9 | |||
d1a936d55b | |||
dc97eaa835 | |||
dcbe57806c | |||
b14438cc15 | |||
b27d3bd5c6 | |||
03ebc9cfe9 | |||
24841b9850 | |||
d35a3d1a8c | |||
60c4e0b528 | |||
84f33d1bc2 | |||
c4e1709b99 | |||
e7a5fd5819 | |||
4bde03643c | |||
1bc52b56af | |||
9c33fd93f7 | |||
3c087bc275 | |||
8ad13c289e | |||
7577f48a09 | |||
0251906964 | |||
2723a5f134 | |||
c3c60605fd | |||
238f704b22 | |||
5639d8ac8e | |||
9aac591591 | |||
ffa8e5aebb | |||
cbbfe014cc | |||
83028f7817 | |||
70d1795557 | |||
8c6c681424 | |||
50bc9f4ff3 | |||
f00ea03fad | |||
f22e7b9ad1 | |||
c7ec95f4bb | |||
229e7a8ccc | |||
3c616474ff | |||
56eb6b3ce3 | |||
545836d43c | |||
219f83dec0 | |||
a76a841238 | |||
c26680de84 | |||
8fffad9d3a | |||
f4f0f203a2 | |||
b7196f5a0c | |||
5d33a18890 | |||
96186a1a50 | |||
bc8bc479d1 | |||
47595b1291 | |||
01a88964df | |||
3a2b77379f | |||
dc4e5f75cd | |||
d0178d551c | |||
827333108d | |||
587b90bd27 | |||
4dc20c5e90 | |||
ac25782f2b | |||
20437d56e7 | |||
f0b412828a | |||
367faac5c3 | |||
84deaaa970 | |||
a2b39466c2 | |||
03586c4005 | |||
6ea69e1510 | |||
553c6dc539 | |||
6cc22f5b6d | |||
9103d67cc1 | |||
25083fb0e4 | |||
d2dc045255 | |||
b8621dfbb0 | |||
93633940dd | |||
b6f5325351 | |||
7c32c08f1f | |||
1d268da08d | |||
797666ae0d | |||
dcf470997e | |||
0974d1dbf8 | |||
12a35db6cd | |||
9abbb05ad8 | |||
1ecaf69b0b | |||
e334d1e5d9 | |||
b735e861d0 | |||
4eb433d372 | |||
2416ae61f3 | |||
01fb336985 | |||
b6af88a732 | |||
58a2a17d6d | |||
79f5a0f520 | |||
7f6c0f7f04 | |||
f658df4dca | |||
9d43b8e23a | |||
4270aef79b | |||
1c0dc82d44 | |||
c1e325aadf | |||
cec87da69d | |||
f68f24cb2c | |||
ed094347fc | |||
b8afdffbe1 | |||
f6ba79f31c | |||
5f3b1663d2 | |||
66e786b4bb | |||
f671114574 | |||
ce37060d94 | |||
7d19a4d184 | |||
22f28a2f8a | |||
ceef9ca979 | |||
efe8f4f939 | |||
ba692a1195 | |||
d732bad042 | |||
4c935c3bee | |||
c160dd791f | |||
23cd1b4601 | |||
031fc8ba1b | |||
c6853289ad | |||
2497bb69bc | |||
a58a67e0a2 | |||
4315fe12a5 | |||
42f10a8899 | |||
1e4b47f989 | |||
ff255dbfae | |||
dbe9b72feb | |||
95a714b391 | |||
28f58c7f56 | |||
8bd46d8f21 | |||
e1bb8e54ed | |||
1de705b063 | |||
f6926ad356 | |||
2cdbbb1b37 | |||
4dce8c8f03 | |||
97a5bace6f | |||
d4d51ec48f | |||
fb91398462 | |||
105dadd798 | |||
2abf2837d3 | |||
422aa67af6 | |||
7fffab6985 | |||
5a4be3d2c1 | |||
f39a7681db | |||
c60a7580ba | |||
97edb56edc | |||
6ebca8d22b | |||
95371ad934 | |||
2c176825fd | |||
fae7de48d3 | |||
b8230646a2 | |||
43279541dd | |||
b4791977c1 | |||
ef917ecc25 | |||
a93faad951 | |||
fd001d24d3 | |||
7aa5884797 | |||
5b237a1547 | |||
2e37990d87 | |||
dd07d724a8 | |||
03ce8618e7 | |||
db1a7a7fd6 | |||
36a82d7f53 | |||
3a34401113 | |||
9927268330 | |||
c45c97e29d | |||
c64a315226 | |||
a4cafca6ab | |||
46284a0660 | |||
05df86e15a | |||
8b433027e2 | |||
5bd4ff7610 | |||
d693c397ea | |||
1d8d1ec9a5 | |||
5e491f11ee | |||
7cedea06ac | |||
2e5f750e50 | |||
20289cad10 | |||
e0d64c31c7 | |||
8c1b95dc97 | |||
fb5641343e | |||
87765941eb | |||
1809862c16 | |||
300f784f7d | |||
67a045eae6 | |||
2a79903a28 | |||
d3222ce083 | |||
406a421742 | |||
10bf728faf | |||
607617747c | |||
f0a69eb1a2 | |||
6b307a6e17 | |||
08d08a934a | |||
c500c12668 | |||
62060adeba | |||
b2fc75edb8 | |||
a999dd2085 | |||
49f95ab100 | |||
1a84d5b30c | |||
3b65050632 | |||
d0df31674c | |||
1fe88402e2 | |||
67097696e6 | |||
8e7e77067a | |||
9899390b61 | |||
80c476a908 | |||
59da1d6e49 | |||
5aef7dac33 | |||
faf7aa06b6 | |||
38ef6e5583 | |||
c0b15b5d94 | |||
2cfc067ea1 | |||
a91db4f956 | |||
8a09780a02 | |||
45e8ec6505 | |||
4554b85914 | |||
8aa79c4a9c | |||
c8d3210b57 | |||
2282a49563 | |||
b82fdfb2c8 | |||
2d17eac199 | |||
e482bc3aad | |||
ec022b74d1 | |||
dc42c09ce3 | |||
046a34d2a4 | |||
9ff6ec1888 | |||
d2950106ec | |||
962f800d2e | |||
962107e507 | |||
039bd11963 | |||
5c250ea4ae | |||
e3405bcec6 | |||
0fd1c2235f | |||
b20c29b022 | |||
12d5dcd298 | |||
2c305dc6c6 | |||
62f76f7433 | |||
858ce524f9 | |||
3795fb4a40 | |||
0c01aeec50 | |||
892206744d | |||
9e2c1474db | |||
16328f73d9 | |||
e0d4f53cf4 | |||
e09a59c5b4 | |||
049e654535 | |||
c927dc4ecd | |||
fe4ecd0ad8 | |||
78d476fe80 | |||
a11c8465d5 | |||
366304a9b7 | |||
4356663688 | |||
26b55e6fcf | |||
0d743f7204 | |||
6cbe113b3e | |||
6409b69d6c | |||
c5164c76fc | |||
baade8e138 | |||
b848d6b4e0 | |||
d8139d2ab0 | |||
e96d8f7469 | |||
2acffd8afc | |||
3c8e72073c | |||
724d7a9d9b | |||
2da3b0db78 | |||
685ad7afaf | |||
264cf75964 | |||
c773dbc7b5 | |||
37cbc64f52 | |||
cb1dde17bb | |||
c29988acf4 | |||
eadbf56dae | |||
4b3b455135 | |||
e6ac177396 | |||
3d0e29003f | |||
78b9b00f77 | |||
0ee7faa551 | |||
e5fdced681 | |||
afb99fef64 | |||
7dfaa36024 | |||
0496f665aa | |||
0d19e1be74 | |||
4aff0111aa | |||
63b3ba2bb2 | |||
7444b41f60 | |||
8e90dbc8b6 | |||
9f70722521 | |||
52fae596fa | |||
ccb67957bc | |||
fb82538d0d | |||
72ee39612e | |||
51fd5408dc | |||
3fae40fbef | |||
0745890af0 | |||
4abe1730a7 | |||
626f0e6989 | |||
9f42d9d173 | |||
f90a93c4bc | |||
8000ad6c6a | |||
1f1f1bea1a | |||
d95460c7cd | |||
a3d93d4b08 | |||
07a92af982 | |||
f4618877d4 | |||
2b914fd222 | |||
109e42a5a3 | |||
fa515ad39c | |||
f09673a795 | |||
f71536c614 | |||
7bdddc7ae8 | |||
aa8926a624 | |||
be71e59be2 | |||
4d7753378f | |||
60257c4ef4 | |||
1e0b79bf62 | |||
6883434d0d | |||
eda2193e64 | |||
99bf829c88 | |||
5feafe1b48 | |||
c9292b7d04 | |||
ae7e1a91c1 | |||
3e1887e0d1 | |||
474646db47 | |||
56f7b6c449 | |||
76b2b5f7e3 | |||
e918d809eb | |||
7af059e543 | |||
897726e1ec | |||
8b98a2dd07 | |||
cca75420f0 | |||
86c627ed1d | |||
d55514e3a7 |
@@ -7,5 +7,5 @@ indent_size = 4
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 130
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -323,7 +323,4 @@ gradle-app.setting
|
||||
### Gradle Patch ###
|
||||
**/build/
|
||||
|
||||
.kiro/
|
||||
.junie
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
||||
|
@@ -39,10 +39,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -78,10 +75,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -148,10 +142,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(order.isActive.isTrue)
|
||||
.groupBy(
|
||||
member.id,
|
||||
@@ -239,10 +230,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
@@ -263,10 +251,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -296,10 +281,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -319,10 +301,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -352,10 +331,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -375,10 +351,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
@@ -409,10 +382,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
|
@@ -2,7 +2,6 @@ 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
|
||||
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
|
||||
|
||||
@Entity
|
||||
data class CreatorSettlementRatio(
|
||||
var subsidy: Int,
|
||||
var liveSettlementRatio: Int,
|
||||
var contentSettlementRatio: Int,
|
||||
var communitySettlementRatio: Int
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
val contentSettlementRatio: Int,
|
||||
val 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,7 +4,6 @@ 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
|
||||
@@ -28,14 +27,4 @@ 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,9 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CreatorSettlementRatioRepository :
|
||||
JpaRepository<CreatorSettlementRatio, Long>,
|
||||
CreatorSettlementRatioQueryRepository {
|
||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
||||
}
|
||||
CreatorSettlementRatioQueryRepository
|
||||
|
||||
interface CreatorSettlementRatioQueryRepository {
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCreatorSettlementRatioItem(
|
||||
member.id,
|
||||
member.nickname,
|
||||
creatorSettlementRatio.subsidy,
|
||||
creatorSettlementRatio.liveSettlementRatio,
|
||||
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
)
|
||||
.from(creatorSettlementRatio)
|
||||
.innerJoin(creatorSettlementRatio.member, member)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.orderBy(creatorSettlementRatio.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(creatorSettlementRatio.id)
|
||||
.from(creatorSettlementRatio)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
@@ -14,6 +14,8 @@ class CreatorSettlementRatioService(
|
||||
) {
|
||||
@Transactional
|
||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||
val creatorSettlementRatio = request.toEntity()
|
||||
|
||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||
|
||||
@@ -21,52 +23,10 @@ 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,7 +8,6 @@ data class GetCreatorSettlementRatioResponse(
|
||||
)
|
||||
|
||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
|
@@ -1,229 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.dto.UpdateBannerOrdersRequest
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||
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
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/banner")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminChatBannerController(
|
||||
private val bannerService: ChatCharacterBannerService,
|
||||
private val adminCharacterService: AdminChatCharacterService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val s3Bucket: String,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
/**
|
||||
* 활성화된 배너 목록 조회 API
|
||||
*
|
||||
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||
* @param size 페이지 크기 (기본값 20)
|
||||
* @return 페이징된 배너 목록
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
fun getBannerList(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
||||
val banners = bannerService.getActiveBanners(pageable)
|
||||
val response = ChatCharacterBannerListPageResponse(
|
||||
totalCount = banners.totalElements,
|
||||
content = banners.content.map { ChatCharacterBannerResponse.from(it, imageHost) }
|
||||
)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 상세 조회 API
|
||||
*
|
||||
* @param bannerId 배너 ID
|
||||
* @return 배너 상세 정보
|
||||
*/
|
||||
@GetMapping("/{bannerId}")
|
||||
fun getBannerDetail(@PathVariable bannerId: Long) = run {
|
||||
val banner = bannerService.getBannerById(bannerId)
|
||||
val response = ChatCharacterBannerResponse.from(banner, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 검색 API (배너 등록을 위한)
|
||||
*
|
||||
* @param searchTerm 검색어 (이름, 설명, MBTI, 태그)
|
||||
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||
* @param size 페이지 크기 (기본값 20)
|
||||
* @return 검색된 캐릭터 목록
|
||||
*/
|
||||
@GetMapping("/search-character")
|
||||
fun searchCharacters(
|
||||
@RequestParam searchTerm: String,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
||||
val pageResult = adminCharacterService.searchCharacters(searchTerm, pageable, imageHost)
|
||||
val response = ChatCharacterSearchListPageResponse(
|
||||
totalCount = pageResult.totalElements,
|
||||
content = pageResult.content
|
||||
)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 등록 API
|
||||
*
|
||||
* @param image 배너 이미지
|
||||
* @param requestString 배너 등록 요청 정보 (캐릭터 ID와 선택적으로 정렬 순서 포함)
|
||||
* @return 등록된 배너 정보
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
fun registerBanner(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(
|
||||
requestString,
|
||||
ChatCharacterBannerRegisterRequest::class.java
|
||||
)
|
||||
|
||||
// 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함)
|
||||
val banner = bannerService.registerBanner(
|
||||
characterId = request.characterId,
|
||||
imagePath = ""
|
||||
)
|
||||
|
||||
// 2. 배너 ID를 사용하여 이미지 업로드
|
||||
val imagePath = saveImage(banner.id!!, image)
|
||||
|
||||
// 3. 이미지 경로로 배너 업데이트
|
||||
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
|
||||
|
||||
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지를 S3에 업로드하고 경로를 반환
|
||||
*
|
||||
* @param bannerId 배너 ID (이미지 경로에 사용)
|
||||
* @param image 업로드할 이미지 파일
|
||||
* @return 업로드된 이미지 경로
|
||||
*/
|
||||
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
|
||||
val fileName = generateFileName("character-banner")
|
||||
|
||||
// S3에 이미지 업로드 (배너 ID를 경로에 사용)
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = s3Bucket,
|
||||
filePath = "characters/banners/$bannerId/$fileName",
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 수정 API
|
||||
*
|
||||
* @param image 배너 이미지
|
||||
* @param requestString 배너 수정 요청 정보 (배너 ID와 선택적으로 캐릭터 ID 포함)
|
||||
* @return 수정된 배너 정보
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
fun updateBanner(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(
|
||||
requestString,
|
||||
ChatCharacterBannerUpdateRequest::class.java
|
||||
)
|
||||
// 배너 정보 조회
|
||||
bannerService.getBannerById(request.bannerId)
|
||||
|
||||
// 배너 ID를 사용하여 이미지 업로드
|
||||
val imagePath = saveImage(request.bannerId, image)
|
||||
|
||||
// 배너 수정 (이미지와 캐릭터 모두 수정 가능)
|
||||
val updatedBanner = bannerService.updateBanner(
|
||||
bannerId = request.bannerId,
|
||||
imagePath = imagePath,
|
||||
characterId = request.characterId
|
||||
)
|
||||
|
||||
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 삭제 API (소프트 삭제)
|
||||
*
|
||||
* @param bannerId 배너 ID
|
||||
* @return 성공 여부
|
||||
*/
|
||||
@DeleteMapping("/{bannerId}")
|
||||
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
||||
bannerService.deleteBanner(bannerId)
|
||||
|
||||
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 정렬 순서 일괄 변경 API
|
||||
* ID 목록의 순서대로 정렬 순서를 1부터 순차적으로 설정합니다.
|
||||
*
|
||||
* @param request 정렬 순서 일괄 변경 요청 정보 (배너 ID 목록)
|
||||
* @return 성공 메시지
|
||||
*/
|
||||
@PutMapping("/orders")
|
||||
fun updateBannerOrders(
|
||||
@RequestBody request: UpdateBannerOrdersRequest
|
||||
) = run {
|
||||
bannerService.updateBannerOrders(request.ids)
|
||||
|
||||
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
|
||||
}
|
||||
}
|
@@ -1,32 +0,0 @@
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
@@ -1,139 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
@@ -1,62 +0,0 @@
|
||||
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()
|
||||
)
|
||||
}
|
@@ -1,423 +0,0 @@
|
||||
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
|
||||
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.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory
|
||||
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.PutMapping
|
||||
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.client.RestTemplate
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/character")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
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,
|
||||
|
||||
@Value("\${weraser.api-url}")
|
||||
private val apiUrl: String,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val s3Bucket: String,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
/**
|
||||
* 활성화된 캐릭터 목록 조회 API
|
||||
*
|
||||
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||
* @param size 페이지 크기 (기본값 20)
|
||||
* @return 페이징된 캐릭터 목록
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
fun getCharacterList(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = adminService.createDefaultPageRequest(page, size)
|
||||
val response = adminService.getActiveChatCharacters(pageable, imageHost)
|
||||
|
||||
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
|
||||
*
|
||||
* @param characterId 캐릭터 ID
|
||||
* @return 캐릭터 상세 정보
|
||||
*/
|
||||
@GetMapping("/{characterId}")
|
||||
fun getCharacterDetail(
|
||||
@PathVariable characterId: Long
|
||||
) = run {
|
||||
val response = adminService.getChatCharacterDetail(characterId, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
fun registerCharacter(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
// JSON 문자열을 ChatCharacterRegisterRequest 객체로 변환
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, ChatCharacterRegisterRequest::class.java)
|
||||
|
||||
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
|
||||
val existingCharacter = service.findByName(request.name)
|
||||
if (existingCharacter != null) {
|
||||
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
||||
}
|
||||
|
||||
// 1. 외부 API 호출
|
||||
val characterUUID = callExternalApi(request)
|
||||
|
||||
// 2. ChatCharacter 저장
|
||||
val chatCharacter = service.createChatCharacterWithDetails(
|
||||
characterUUID = characterUUID,
|
||||
name = request.name,
|
||||
description = request.description,
|
||||
systemPrompt = request.systemPrompt,
|
||||
age = request.age?.toIntOrNull(),
|
||||
gender = request.gender,
|
||||
mbti = request.mbti,
|
||||
speechPattern = request.speechPattern,
|
||||
speechStyle = request.speechStyle,
|
||||
appearance = request.appearance,
|
||||
originalTitle = request.originalTitle,
|
||||
originalLink = request.originalLink,
|
||||
characterType = request.characterType?.let {
|
||||
runCatching { CharacterType.valueOf(it) }
|
||||
.getOrDefault(CharacterType.Character)
|
||||
} ?: CharacterType.Character,
|
||||
tags = request.tags,
|
||||
values = request.values,
|
||||
hobbies = request.hobbies,
|
||||
goals = request.goals,
|
||||
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
|
||||
personalities = request.personalities.map { Pair(it.trait, it.description) },
|
||||
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
|
||||
relationships = request.relationships
|
||||
)
|
||||
|
||||
// 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정
|
||||
val imagePath = saveImage(
|
||||
characterId = chatCharacter.id!!,
|
||||
image = image
|
||||
)
|
||||
chatCharacter.imagePath = imagePath
|
||||
service.saveChatCharacter(chatCharacter)
|
||||
|
||||
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||
if (request.originalWorkId != null) {
|
||||
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
private fun callExternalApi(request: ChatCharacterRegisterRequest): String {
|
||||
try {
|
||||
val factory = SimpleClientHttpRequestFactory()
|
||||
factory.setConnectTimeout(20000) // 20초
|
||||
factory.setReadTimeout(20000) // 20초
|
||||
|
||||
val restTemplate = RestTemplate(factory)
|
||||
|
||||
val headers = HttpHeaders()
|
||||
headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
|
||||
// 외부 API에 전달하지 않을 필드(originalTitle, originalLink, characterType)를 제외하고 바디 구성
|
||||
val body = mutableMapOf<String, Any>()
|
||||
body["name"] = request.name
|
||||
body["systemPrompt"] = request.systemPrompt
|
||||
body["description"] = request.description
|
||||
request.age?.let { body["age"] = it }
|
||||
request.gender?.let { body["gender"] = it }
|
||||
request.mbti?.let { body["mbti"] = it }
|
||||
request.speechPattern?.let { body["speechPattern"] = it }
|
||||
request.speechStyle?.let { body["speechStyle"] = it }
|
||||
request.appearance?.let { body["appearance"] = it }
|
||||
if (request.tags.isNotEmpty()) body["tags"] = request.tags
|
||||
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
|
||||
if (request.values.isNotEmpty()) body["values"] = request.values
|
||||
if (request.goals.isNotEmpty()) body["goals"] = request.goals
|
||||
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
|
||||
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
|
||||
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
|
||||
if (request.memories.isNotEmpty()) body["memories"] = request.memories
|
||||
|
||||
val httpEntity = HttpEntity(body, headers)
|
||||
|
||||
val response = restTemplate.exchange(
|
||||
"$apiUrl/api/characters",
|
||||
HttpMethod.POST,
|
||||
httpEntity,
|
||||
String::class.java
|
||||
)
|
||||
|
||||
// 응답 파싱
|
||||
val objectMapper = ObjectMapper()
|
||||
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
||||
|
||||
// success가 false이면 throw
|
||||
if (!apiResponse.success) {
|
||||
throw SodaException(apiResponse.message ?: "등록에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
|
||||
// success가 true이면 data.id 반환
|
||||
return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.")
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveImage(characterId: Long, image: MultipartFile): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
|
||||
// S3에 이미지 업로드
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = s3Bucket,
|
||||
filePath = "characters/$characterId/${generateFileName(prefix = "character")}",
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 수정 API
|
||||
* 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
||||
* 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
||||
* 3. 이미지 있는지 확인
|
||||
* 4. 2, 3번 중 하나라도 해당 하면 계속 진행
|
||||
* 5. 2, 3번에 데이터 없으면 throw SodaException('변경된 데이터가 없습니다.')
|
||||
*
|
||||
* @param image 캐릭터 이미지 (선택적)
|
||||
* @param requestString ChatCharacterUpdateRequest 객체를 JSON 문자열로 변환한 값
|
||||
* @return ApiResponse 객체
|
||||
* @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
fun updateCharacter(
|
||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
// 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java)
|
||||
|
||||
// 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
||||
val hasChangedData = hasChanges(request) // 외부 API 대상으로의 변경 여부(3가지 필드 제외)
|
||||
|
||||
// 3. 이미지 있는지 확인
|
||||
val hasImage = image != null && !image.isEmpty
|
||||
|
||||
// 3가지만 변경된 경우(외부 API 변경은 없지만 DB 변경은 있는 경우)를 허용하기 위해 별도 플래그 계산
|
||||
val hasDbOnlyChanges =
|
||||
request.originalTitle != null ||
|
||||
request.originalLink != null ||
|
||||
request.characterType != null ||
|
||||
request.originalWorkId != null
|
||||
|
||||
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
||||
throw SodaException("변경된 데이터가 없습니다.")
|
||||
}
|
||||
|
||||
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
|
||||
if (hasChangedData) {
|
||||
val chatCharacter = service.findById(request.id)
|
||||
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")
|
||||
|
||||
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
|
||||
if (request.name != null && request.name != chatCharacter.name) {
|
||||
val existingCharacter = service.findByName(request.name)
|
||||
if (existingCharacter != null) {
|
||||
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
||||
}
|
||||
}
|
||||
|
||||
callExternalApiForUpdate(chatCharacter.characterUUID, request)
|
||||
}
|
||||
|
||||
// 이미지 경로 변수 초기화
|
||||
// 이미지가 있으면 이미지 저장
|
||||
val imagePath = if (hasImage) {
|
||||
saveImage(
|
||||
characterId = request.id,
|
||||
image = image!!
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 엔티티 수정
|
||||
service.updateChatCharacterWithDetails(
|
||||
imagePath = imagePath,
|
||||
request = request
|
||||
)
|
||||
|
||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||
if (request.originalWorkId != null) {
|
||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청에 변경된 데이터가 있는지 확인
|
||||
* id를 제외한 모든 필드가 null이면 변경된 데이터가 없는 것으로 판단
|
||||
*
|
||||
* @param request 수정 요청 데이터
|
||||
* @return 변경된 데이터가 있으면 true, 없으면 false
|
||||
*/
|
||||
private fun hasChanges(request: ChatCharacterUpdateRequest): Boolean {
|
||||
return request.systemPrompt != null ||
|
||||
request.description != null ||
|
||||
request.age != null ||
|
||||
request.gender != null ||
|
||||
request.mbti != null ||
|
||||
request.speechPattern != null ||
|
||||
request.speechStyle != null ||
|
||||
request.appearance != null ||
|
||||
request.isActive != null ||
|
||||
request.tags != null ||
|
||||
request.hobbies != null ||
|
||||
request.values != null ||
|
||||
request.goals != null ||
|
||||
request.relationships != null ||
|
||||
request.personalities != null ||
|
||||
request.backgrounds != null ||
|
||||
request.memories != null ||
|
||||
request.name != null
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 호출 - 수정 API
|
||||
* 변경된 데이터만 요청에 포함
|
||||
*
|
||||
* @param characterUUID 캐릭터 UUID
|
||||
* @param request 수정 요청 데이터
|
||||
*/
|
||||
private fun callExternalApiForUpdate(characterUUID: String, request: ChatCharacterUpdateRequest) {
|
||||
try {
|
||||
val factory = SimpleClientHttpRequestFactory()
|
||||
factory.setConnectTimeout(20000) // 20초
|
||||
factory.setReadTimeout(20000) // 20초
|
||||
|
||||
val restTemplate = RestTemplate(factory)
|
||||
|
||||
val headers = HttpHeaders()
|
||||
headers.set("x-api-key", apiKey)
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
|
||||
// 변경된 데이터만 포함하는 맵 생성
|
||||
val updateData = mutableMapOf<String, Any>()
|
||||
|
||||
// isActive = false인 경우 처리
|
||||
if (request.isActive != null && !request.isActive) {
|
||||
val inactiveName = "inactive_${request.name}"
|
||||
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
|
||||
updateData["name"] = inactiveName + randomSuffix
|
||||
} else {
|
||||
request.name?.let { updateData["name"] = it }
|
||||
request.systemPrompt?.let { updateData["systemPrompt"] = it }
|
||||
request.description?.let { updateData["description"] = it }
|
||||
request.age?.let { updateData["age"] = it }
|
||||
request.gender?.let { updateData["gender"] = it }
|
||||
request.mbti?.let { updateData["mbti"] = it }
|
||||
request.speechPattern?.let { updateData["speechPattern"] = it }
|
||||
request.speechStyle?.let { updateData["speechStyle"] = it }
|
||||
request.appearance?.let { updateData["appearance"] = it }
|
||||
request.tags?.let { updateData["tags"] = it }
|
||||
request.hobbies?.let { updateData["hobbies"] = it }
|
||||
request.values?.let { updateData["values"] = it }
|
||||
request.goals?.let { updateData["goals"] = it }
|
||||
request.relationships?.let { updateData["relationships"] = it }
|
||||
request.personalities?.let { updateData["personalities"] = it }
|
||||
request.backgrounds?.let { updateData["backgrounds"] = it }
|
||||
request.memories?.let { updateData["memories"] = it }
|
||||
}
|
||||
|
||||
val httpEntity = HttpEntity(updateData, headers)
|
||||
val response = restTemplate.exchange(
|
||||
"$apiUrl/api/characters/$characterUUID",
|
||||
HttpMethod.PUT,
|
||||
httpEntity,
|
||||
String::class.java
|
||||
)
|
||||
|
||||
// 응답 파싱
|
||||
val objectMapper = ObjectMapper()
|
||||
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
||||
|
||||
// success가 false이면 throw
|
||||
if (!apiResponse.success) {
|
||||
throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,82 +0,0 @@
|
||||
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))
|
||||
}
|
@@ -1,45 +0,0 @@
|
||||
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
|
||||
)
|
@@ -1,153 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
}
|
@@ -1,132 +0,0 @@
|
||||
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,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val description: String,
|
||||
val systemPrompt: String,
|
||||
val characterType: String,
|
||||
val age: Int?,
|
||||
val gender: String?,
|
||||
val mbti: String?,
|
||||
val speechPattern: String?,
|
||||
val speechStyle: String?,
|
||||
val appearance: String?,
|
||||
val isActive: Boolean,
|
||||
val tags: List<String>,
|
||||
val hobbies: List<String>,
|
||||
val values: List<String>,
|
||||
val goals: List<String>,
|
||||
val relationships: List<RelationshipResponse>,
|
||||
val personalities: List<PersonalityResponse>,
|
||||
val backgrounds: List<BackgroundResponse>,
|
||||
val memories: List<MemoryResponse>,
|
||||
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
|
||||
) {
|
||||
companion object {
|
||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${chatCharacter.imagePath}"
|
||||
} else {
|
||||
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,
|
||||
name = chatCharacter.name,
|
||||
imageUrl = fullImagePath,
|
||||
description = chatCharacter.description,
|
||||
systemPrompt = chatCharacter.systemPrompt,
|
||||
characterType = chatCharacter.characterType.name,
|
||||
age = chatCharacter.age,
|
||||
gender = chatCharacter.gender,
|
||||
mbti = chatCharacter.mbti,
|
||||
speechPattern = chatCharacter.speechPattern,
|
||||
speechStyle = chatCharacter.speechStyle,
|
||||
appearance = chatCharacter.appearance,
|
||||
isActive = chatCharacter.isActive,
|
||||
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
||||
hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby },
|
||||
values = chatCharacter.valueMappings.map { it.value.value },
|
||||
goals = chatCharacter.goalMappings.map { it.goal.goal },
|
||||
relationships = chatCharacter.relationships.map {
|
||||
RelationshipResponse(
|
||||
personName = it.personName,
|
||||
relationshipName = it.relationshipName,
|
||||
description = it.description,
|
||||
importance = it.importance,
|
||||
relationshipType = it.relationshipType,
|
||||
currentStatus = it.currentStatus
|
||||
)
|
||||
},
|
||||
personalities = chatCharacter.personalities.map {
|
||||
PersonalityResponse(it.trait, it.description)
|
||||
},
|
||||
backgrounds = chatCharacter.backgrounds.map {
|
||||
BackgroundResponse(it.topic, it.description)
|
||||
},
|
||||
memories = chatCharacter.memories.map {
|
||||
MemoryResponse(it.title, it.content, it.emotion)
|
||||
},
|
||||
originalWork = originalWorkBrief
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PersonalityResponse(
|
||||
val trait: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class BackgroundResponse(
|
||||
val topic: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class MemoryResponse(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val emotion: String
|
||||
)
|
||||
|
||||
data class RelationshipResponse(
|
||||
val personName: String,
|
||||
val relationshipName: String,
|
||||
val description: String,
|
||||
val importance: Int,
|
||||
val relationshipType: String,
|
||||
val currentStatus: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
|
||||
*/
|
||||
data class OriginalWorkBriefResponse(
|
||||
val id: Long,
|
||||
val imageUrl: String?,
|
||||
val title: String
|
||||
)
|
@@ -1,90 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class ChatCharacterPersonalityRequest(
|
||||
@JsonProperty("trait") val trait: String,
|
||||
@JsonProperty("description") val description: String
|
||||
)
|
||||
|
||||
data class ChatCharacterBackgroundRequest(
|
||||
@JsonProperty("topic") val topic: String,
|
||||
@JsonProperty("description") val description: String
|
||||
)
|
||||
|
||||
data class ChatCharacterMemoryRequest(
|
||||
@JsonProperty("title") val title: String,
|
||||
@JsonProperty("content") val content: String,
|
||||
@JsonProperty("emotion") val emotion: String
|
||||
)
|
||||
|
||||
data class ChatCharacterRelationshipRequest(
|
||||
@JsonProperty("personName") val personName: String,
|
||||
@JsonProperty("relationshipName") val relationshipName: String,
|
||||
@JsonProperty("description") val description: String,
|
||||
@JsonProperty("importance") val importance: Int,
|
||||
@JsonProperty("relationshipType") val relationshipType: String,
|
||||
@JsonProperty("currentStatus") val currentStatus: String
|
||||
)
|
||||
|
||||
data class ChatCharacterRegisterRequest(
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("systemPrompt") val systemPrompt: String,
|
||||
@JsonProperty("description") val description: String,
|
||||
@JsonProperty("age") val age: String?,
|
||||
@JsonProperty("gender") val gender: String?,
|
||||
@JsonProperty("mbti") val mbti: String?,
|
||||
@JsonProperty("speechPattern") val speechPattern: String?,
|
||||
@JsonProperty("speechStyle") val speechStyle: String?,
|
||||
@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(),
|
||||
@JsonProperty("values") val values: List<String> = emptyList(),
|
||||
@JsonProperty("goals") val goals: List<String> = emptyList(),
|
||||
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest> = emptyList(),
|
||||
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest> = emptyList(),
|
||||
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest> = emptyList(),
|
||||
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest> = emptyList()
|
||||
)
|
||||
|
||||
data class ExternalApiResponse(
|
||||
@JsonProperty("success") val success: Boolean,
|
||||
@JsonProperty("data") val data: ExternalApiData? = null,
|
||||
@JsonProperty("message") val message: String? = null
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ExternalApiData(
|
||||
@JsonProperty("id") val id: String
|
||||
)
|
||||
|
||||
data class ChatCharacterUpdateRequest(
|
||||
@JsonProperty("id") val id: Long,
|
||||
@JsonProperty("name") val name: String? = null,
|
||||
@JsonProperty("systemPrompt") val systemPrompt: String? = null,
|
||||
@JsonProperty("description") val description: String? = null,
|
||||
@JsonProperty("age") val age: String? = null,
|
||||
@JsonProperty("gender") val gender: String? = null,
|
||||
@JsonProperty("mbti") val mbti: String? = null,
|
||||
@JsonProperty("speechPattern") val speechPattern: String? = null,
|
||||
@JsonProperty("speechStyle") val speechStyle: String? = null,
|
||||
@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,
|
||||
@JsonProperty("hobbies") val hobbies: List<String>? = null,
|
||||
@JsonProperty("values") val values: List<String>? = null,
|
||||
@JsonProperty("goals") val goals: List<String>? = null,
|
||||
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest>? = null,
|
||||
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest>? = null,
|
||||
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
|
||||
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest>? = null
|
||||
)
|
@@ -1,62 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
data class ChatCharacterListResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val description: String,
|
||||
val gender: String?,
|
||||
val age: Int?,
|
||||
val mbti: String?,
|
||||
val speechStyle: String?,
|
||||
val speechPattern: String?,
|
||||
val tags: List<String>,
|
||||
val createdAt: String?,
|
||||
val updatedAt: String?
|
||||
) {
|
||||
companion object {
|
||||
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
private val seoulZoneId = ZoneId.of("Asia/Seoul")
|
||||
|
||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterListResponse {
|
||||
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${chatCharacter.imagePath}"
|
||||
} else {
|
||||
chatCharacter.imagePath
|
||||
}
|
||||
|
||||
// UTC에서 Asia/Seoul로 시간대 변환 및 문자열 포맷팅
|
||||
val createdAtStr = chatCharacter.createdAt?.atZone(ZoneId.of("UTC"))
|
||||
?.withZoneSameInstant(seoulZoneId)
|
||||
?.format(formatter)
|
||||
|
||||
val updatedAtStr = chatCharacter.updatedAt?.atZone(ZoneId.of("UTC"))
|
||||
?.withZoneSameInstant(seoulZoneId)
|
||||
?.format(formatter)
|
||||
|
||||
return ChatCharacterListResponse(
|
||||
id = chatCharacter.id!!,
|
||||
name = chatCharacter.name,
|
||||
imageUrl = fullImagePath,
|
||||
description = chatCharacter.description,
|
||||
gender = chatCharacter.gender,
|
||||
age = chatCharacter.age,
|
||||
mbti = chatCharacter.mbti,
|
||||
speechStyle = chatCharacter.speechStyle,
|
||||
speechPattern = chatCharacter.speechPattern,
|
||||
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
||||
createdAt = createdAtStr,
|
||||
updatedAt = updatedAtStr
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChatCharacterListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<ChatCharacterListResponse>
|
||||
)
|
@@ -1,9 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
/**
|
||||
* 캐릭터 검색 결과 페이지 응답 DTO
|
||||
*/
|
||||
data class ChatCharacterSearchListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<ChatCharacterListResponse>
|
||||
)
|
@@ -1,30 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
|
||||
/**
|
||||
* 원작 연결된 캐릭터 결과 응답 DTO
|
||||
*/
|
||||
data class OriginalWorkChatCharacterResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val imagePath: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
|
||||
return OriginalWorkChatCharacterResponse(
|
||||
id = character.id!!,
|
||||
name = character.name,
|
||||
imagePath = character.imagePath?.let { "$imageHost/$it" }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 연결된 캐릭터 결과 페이지 응답 DTO
|
||||
*/
|
||||
data class OriginalWorkChatCharacterListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<OriginalWorkChatCharacterResponse>
|
||||
)
|
@@ -1,170 +0,0 @@
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,53 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,78 +0,0 @@
|
||||
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.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AdminChatCharacterService(
|
||||
private val chatCharacterRepository: ChatCharacterRepository
|
||||
) {
|
||||
/**
|
||||
* 활성화된 캐릭터 목록을 페이징하여 조회
|
||||
*
|
||||
* @param pageable 페이징 정보
|
||||
* @return 페이징된 캐릭터 목록
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse {
|
||||
// isActive가 true인 캐릭터만 조회
|
||||
val page = chatCharacterRepository.findByIsActiveTrue(pageable)
|
||||
|
||||
// 페이지 정보 생성
|
||||
val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) }
|
||||
|
||||
return ChatCharacterListPageResponse(
|
||||
totalCount = page.totalElements,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 페이지 요청 생성
|
||||
*
|
||||
* @param page 페이지 번호 (0부터 시작)
|
||||
* @param size 페이지 크기
|
||||
* @return 페이지 요청 객체
|
||||
*/
|
||||
fun createDefaultPageRequest(page: Int = 0, size: Int = 20): PageRequest {
|
||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 상세 정보 조회
|
||||
*
|
||||
* @param characterId 캐릭터 ID
|
||||
* @param imageHost 이미지 호스트 URL
|
||||
* @return 캐릭터 상세 정보
|
||||
* @throws SodaException 캐릭터를 찾을 수 없는 경우
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||
val chatCharacter = chatCharacterRepository.findById(characterId)
|
||||
.orElseThrow { SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") }
|
||||
|
||||
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun searchCharacters(
|
||||
searchTerm: String,
|
||||
pageable: Pageable,
|
||||
imageHost: String = ""
|
||||
): Page<ChatCharacterListResponse> {
|
||||
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
|
||||
return characters.map { ChatCharacterListResponse.from(it, imageHost) }
|
||||
}
|
||||
}
|
@@ -1,30 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 등록 요청 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerRegisterRequest(
|
||||
// 캐릭터 ID
|
||||
@JsonProperty("characterId") val characterId: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 수정 요청 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerUpdateRequest(
|
||||
// 배너 ID
|
||||
@JsonProperty("bannerId") val bannerId: Long,
|
||||
|
||||
// 캐릭터 ID (변경할 캐릭터)
|
||||
@JsonProperty("characterId") val characterId: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 정렬 순서 일괄 변경 요청 DTO
|
||||
*/
|
||||
data class UpdateBannerOrdersRequest(
|
||||
// 배너 ID 목록 (순서대로 정렬됨)
|
||||
@JsonProperty("ids") val ids: List<Long>
|
||||
)
|
@@ -1,32 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 응답 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerResponse(
|
||||
val id: Long,
|
||||
val imagePath: String,
|
||||
val characterId: Long,
|
||||
val characterName: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse {
|
||||
return ChatCharacterBannerResponse(
|
||||
id = banner.id!!,
|
||||
imagePath = "$imageHost/${banner.imagePath}",
|
||||
characterId = banner.chatCharacter.id!!,
|
||||
characterName = banner.chatCharacter.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 목록 페이지 응답 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<ChatCharacterBannerResponse>
|
||||
)
|
@@ -1,199 +0,0 @@
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,95 +0,0 @@
|
||||
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>
|
||||
)
|
@@ -1,213 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.original.service
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 원작(오리지널 작품) 관련 관리자 서비스
|
||||
* - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다.
|
||||
*/
|
||||
@Service
|
||||
class AdminOriginalWorkService(
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val originalWorkTagRepository: OriginalWorkTagRepository
|
||||
) {
|
||||
|
||||
/** 원작 등록 (중복 제목 방지 포함) */
|
||||
@Transactional
|
||||
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
|
||||
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
|
||||
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
|
||||
}
|
||||
val entity = OriginalWork(
|
||||
title = request.title,
|
||||
contentType = request.contentType,
|
||||
category = request.category,
|
||||
isAdult = request.isAdult,
|
||||
description = request.description,
|
||||
originalWork = request.originalWork,
|
||||
originalLink = request.originalLink,
|
||||
writer = request.writer,
|
||||
studio = request.studio
|
||||
)
|
||||
// 링크 리스트 생성
|
||||
request.originalLinks?.filter { it.isNotBlank() }?.forEach { link ->
|
||||
entity.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = entity))
|
||||
}
|
||||
// 태그 매핑 생성 (기존 태그 재사용)
|
||||
request.tags?.let { tags ->
|
||||
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
||||
normalized.forEach { t ->
|
||||
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
||||
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
|
||||
}
|
||||
}
|
||||
return originalWorkRepository.save(entity)
|
||||
}
|
||||
|
||||
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
||||
@Transactional
|
||||
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
|
||||
request.title?.let { ow.title = it }
|
||||
request.contentType?.let { ow.contentType = it }
|
||||
request.category?.let { ow.category = it }
|
||||
request.isAdult?.let { ow.isAdult = it }
|
||||
request.description?.let { ow.description = it }
|
||||
request.originalWork?.let { ow.originalWork = it }
|
||||
request.originalLink?.let { ow.originalLink = it }
|
||||
request.writer?.let { ow.writer = it }
|
||||
request.studio?.let { ow.studio = it }
|
||||
// 링크 리스트가 전달되면 기존 것을 교체
|
||||
request.originalLinks?.let { links ->
|
||||
ow.originalLinks.clear()
|
||||
links.filter { it.isNotBlank() }.forEach { link ->
|
||||
ow.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = ow))
|
||||
}
|
||||
}
|
||||
// 태그 변경사항만 반영 (요청이 null이면 변경 없음)
|
||||
request.tags?.let { tags ->
|
||||
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
||||
val current = ow.tagMappings.map { it.tag.tag }.toSet()
|
||||
val toAdd = normalized.minus(current)
|
||||
val toRemove = current.minus(normalized)
|
||||
|
||||
if (toRemove.isNotEmpty()) {
|
||||
val itr = ow.tagMappings.iterator()
|
||||
while (itr.hasNext()) {
|
||||
val m = itr.next()
|
||||
if (toRemove.contains(m.tag.tag)) {
|
||||
itr.remove() // orphanRemoval=true로 매핑 삭제
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toAdd.isNotEmpty()) {
|
||||
toAdd.forEach { t ->
|
||||
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
||||
ow.tagMappings.add(OriginalWorkTagMapping(originalWork = ow, tag = tagEntity))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imagePath != null) {
|
||||
ow.imagePath = imagePath
|
||||
}
|
||||
return originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 이미지 경로만 별도 갱신 */
|
||||
@Transactional
|
||||
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
ow.imagePath = imagePath
|
||||
return originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 삭제 (소프트 삭제) */
|
||||
@Transactional
|
||||
fun deleteOriginalWork(id: Long) {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
|
||||
ow.isDeleted = true
|
||||
originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 상세 조회 (소프트 삭제 제외) */
|
||||
@Transactional(readOnly = true)
|
||||
fun getOriginalWork(id: Long): OriginalWork {
|
||||
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
}
|
||||
|
||||
/** 원작 페이징 조회 */
|
||||
@Transactional(readOnly = true)
|
||||
fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> {
|
||||
val safePage = if (page < 0) 0 else page
|
||||
val safeSize = when {
|
||||
size <= 0 -> 20
|
||||
size > 100 -> 100
|
||||
else -> size
|
||||
}
|
||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||
return originalWorkRepository.findByIsDeletedFalse(pageable)
|
||||
}
|
||||
|
||||
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
|
||||
@Transactional(readOnly = true)
|
||||
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
|
||||
// 원작 존재 및 소프트 삭제 여부 확인
|
||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
|
||||
val safePage = if (page < 0) 0 else page
|
||||
val safeSize = when {
|
||||
size <= 0 -> 20
|
||||
size > 100 -> 100
|
||||
else -> size
|
||||
}
|
||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
|
||||
}
|
||||
|
||||
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
|
||||
@Transactional(readOnly = true)
|
||||
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
|
||||
return originalWorkRepository.searchNoPaging(searchTerm)
|
||||
}
|
||||
|
||||
/** 원작에 기존 캐릭터들을 배정 */
|
||||
@Transactional
|
||||
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
if (characterIds.isEmpty()) return
|
||||
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||
characters.forEach { it.originalWork = ow }
|
||||
chatCharacterRepository.saveAll(characters)
|
||||
}
|
||||
|
||||
/** 원작에서 캐릭터들 해제 */
|
||||
@Transactional
|
||||
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||
// 원작 존재 확인 (소프트 삭제 제외)
|
||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
if (characterIds.isEmpty()) return
|
||||
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||
characters.forEach { it.originalWork = null }
|
||||
chatCharacterRepository.saveAll(characters)
|
||||
}
|
||||
|
||||
/** 단일 캐릭터를 지정 원작에 배정 */
|
||||
@Transactional
|
||||
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
|
||||
val character = chatCharacterRepository.findById(characterId)
|
||||
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
|
||||
|
||||
if (originalWorkId == 0L) {
|
||||
character.originalWork = null
|
||||
} else {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
character.originalWork = ow
|
||||
}
|
||||
|
||||
chatCharacterRepository.save(character)
|
||||
}
|
||||
}
|
@@ -1,48 +0,0 @@
|
||||
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,10 +72,6 @@ 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,7 +13,6 @@ 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
|
||||
@@ -38,8 +37,6 @@ class CanPaymentService(
|
||||
memberId: Long,
|
||||
needCan: Int,
|
||||
canUsage: CanUsage,
|
||||
chatRoomId: Long? = null,
|
||||
characterId: Long? = null,
|
||||
isSecret: Boolean = false,
|
||||
liveRoom: LiveRoom? = null,
|
||||
order: Order? = null,
|
||||
@@ -112,14 +109,6 @@ 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("잘못된 요청입니다.")
|
||||
}
|
||||
@@ -338,98 +327,4 @@ 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,9 +9,5 @@ enum class CanUsage {
|
||||
SPIN_ROULETTE,
|
||||
PAID_COMMUNITY_POST,
|
||||
ALARM_SLOT,
|
||||
AUDITION_VOTE,
|
||||
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
||||
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
|
||||
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
|
||||
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
|
||||
AUDITION_VOTE
|
||||
}
|
||||
|
@@ -1,8 +1,6 @@
|
||||
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
|
||||
@@ -30,11 +28,7 @@ data class UseCan(
|
||||
|
||||
var isRefund: Boolean = false,
|
||||
|
||||
val isSecret: Boolean = false,
|
||||
|
||||
// 채팅 연동을 위한 식별자 (옵션)
|
||||
var chatRoomId: Long? = null,
|
||||
var characterId: Long? = null
|
||||
val isSecret: Boolean = false
|
||||
) : BaseEntity() {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
@@ -64,16 +58,6 @@ 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,22 +6,10 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository {
|
||||
// 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외)
|
||||
fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn(
|
||||
memberId: Long,
|
||||
imageId: Long,
|
||||
usages: Collection<CanUsage>
|
||||
): Boolean
|
||||
}
|
||||
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository
|
||||
|
||||
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 {
|
||||
@@ -38,24 +26,4 @@ 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,163 +0,0 @@
|
||||
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
|
||||
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
|
||||
class ChatCharacter(
|
||||
val characterUUID: String,
|
||||
|
||||
// 캐릭터 이름 (API 키 내에서 유일해야 함)
|
||||
var name: String,
|
||||
|
||||
// 캐릭터 한 줄 소개
|
||||
var description: String,
|
||||
|
||||
// AI 시스템 프롬프트
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var systemPrompt: String,
|
||||
|
||||
// 나이
|
||||
var age: Int? = null,
|
||||
|
||||
// 성별
|
||||
var gender: String? = null,
|
||||
|
||||
// mbti
|
||||
var mbti: String? = null,
|
||||
|
||||
// 말투 패턴 설명
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var speechPattern: String? = null,
|
||||
|
||||
// 대화 스타일
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var speechStyle: String? = null,
|
||||
|
||||
// 외모 설명
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var appearance: String? = null,
|
||||
|
||||
// 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
||||
@Column(nullable = true)
|
||||
var originalTitle: String? = null,
|
||||
|
||||
// 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
||||
@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)
|
||||
var characterType: CharacterType = CharacterType.Character,
|
||||
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
var imagePath: String? = null
|
||||
|
||||
@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, orphanRemoval = true)
|
||||
var personalities: MutableList<ChatCharacterPersonality> = mutableListOf()
|
||||
|
||||
@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, orphanRemoval = true)
|
||||
var relationships: MutableList<ChatCharacterRelationship> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var tagMappings: MutableList<ChatCharacterTagMapping> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var valueMappings: MutableList<ChatCharacterValueMapping> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var goalMappings: MutableList<ChatCharacterGoalMapping> = mutableListOf()
|
||||
|
||||
// 태그 추가 헬퍼 메소드
|
||||
fun addTag(tag: ChatCharacterTag) {
|
||||
val mapping = ChatCharacterTagMapping(this, tag)
|
||||
tagMappings.add(mapping)
|
||||
}
|
||||
|
||||
// 가치관 추가 헬퍼 메소드
|
||||
fun addValue(value: ChatCharacterValue) {
|
||||
val mapping = ChatCharacterValueMapping(this, value)
|
||||
valueMappings.add(mapping)
|
||||
}
|
||||
|
||||
// 취미 추가 헬퍼 메소드
|
||||
fun addHobby(hobby: ChatCharacterHobby) {
|
||||
val mapping = ChatCharacterHobbyMapping(this, hobby)
|
||||
hobbyMappings.add(mapping)
|
||||
}
|
||||
|
||||
// 목표 추가 헬퍼 메소드
|
||||
fun addGoal(goal: ChatCharacterGoal) {
|
||||
val mapping = ChatCharacterGoalMapping(this, goal)
|
||||
goalMappings.add(mapping)
|
||||
}
|
||||
|
||||
// 기억 추가 헬퍼 메소드
|
||||
fun addMemory(title: String, content: String, emotion: String) {
|
||||
val memory = ChatCharacterMemory(title, content, emotion, this)
|
||||
memories.add(memory)
|
||||
}
|
||||
|
||||
// 성격 추가 헬퍼 메소드
|
||||
fun addPersonality(trait: String, description: String) {
|
||||
val personality = ChatCharacterPersonality(trait, description, this)
|
||||
personalities.add(personality)
|
||||
}
|
||||
|
||||
// 배경 추가 헬퍼 메소드
|
||||
fun addBackground(topic: String, description: String) {
|
||||
val background = ChatCharacterBackground(topic, description, this)
|
||||
backgrounds.add(background)
|
||||
}
|
||||
|
||||
// 관계 추가 헬퍼 메소드
|
||||
fun addRelationship(
|
||||
personName: String,
|
||||
relationshipName: String,
|
||||
description: String,
|
||||
importance: Int,
|
||||
relationshipType: String,
|
||||
currentStatus: String
|
||||
) {
|
||||
val relationship = ChatCharacterRelationship(
|
||||
personName,
|
||||
relationshipName,
|
||||
description,
|
||||
importance,
|
||||
relationshipType,
|
||||
currentStatus,
|
||||
this
|
||||
)
|
||||
relationships.add(relationship)
|
||||
}
|
||||
}
|
||||
|
||||
enum class CharacterType {
|
||||
Clone,
|
||||
Character
|
||||
}
|
@@ -1,26 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 캐릭터 배경 정보
|
||||
*/
|
||||
|
||||
@Entity
|
||||
class ChatCharacterBackground(
|
||||
// 배경 주제
|
||||
val topic: String,
|
||||
|
||||
// 배경 설명
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var description: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter
|
||||
) : BaseEntity()
|
@@ -1,29 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 엔티티
|
||||
* 이미지와 캐릭터 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다.
|
||||
* 정렬 순서(sortOrder)를 통해 배너의 표시 순서를 결정합니다.
|
||||
*/
|
||||
@Entity
|
||||
class ChatCharacterBanner(
|
||||
// 배너 이미지 경로
|
||||
var imagePath: String? = null,
|
||||
|
||||
// 연관된 캐릭터
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "character_id")
|
||||
var chatCharacter: ChatCharacter,
|
||||
|
||||
// 정렬 순서 (낮을수록 먼저 표시)
|
||||
var sortOrder: Int = 0,
|
||||
|
||||
// 활성화 여부 (소프트 삭제용)
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity()
|
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
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 = ["goal"])])
|
||||
class ChatCharacterGoal(
|
||||
@Column(nullable = false)
|
||||
val goal: String
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "goal")
|
||||
var goalMappings: MutableList<ChatCharacterGoalMapping> = mutableListOf()
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* ChatCharacter와 ChatCharacterGoal 간의 매핑 엔티티
|
||||
* ChatCharacterGoal의 중복을 방지하기 위한 매핑 테이블
|
||||
*/
|
||||
@Entity
|
||||
class ChatCharacterGoalMapping(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "goal_id")
|
||||
val goal: ChatCharacterGoal
|
||||
) : BaseEntity()
|
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
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 = ["hobby"])])
|
||||
class ChatCharacterHobby(
|
||||
@Column(nullable = false)
|
||||
val hobby: String
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "hobby")
|
||||
var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf()
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* ChatCharacter와 ChatCharacterHobby 간의 매핑 엔티티
|
||||
* ChatCharacterHobby의 중복을 방지하기 위한 매핑 테이블
|
||||
*/
|
||||
@Entity
|
||||
class ChatCharacterHobbyMapping(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "hobby_id")
|
||||
val hobby: ChatCharacterHobby
|
||||
) : BaseEntity()
|
@@ -1,29 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 캐릭터 기억
|
||||
*/
|
||||
|
||||
@Entity
|
||||
class ChatCharacterMemory(
|
||||
// 기억 제목
|
||||
val title: String,
|
||||
|
||||
// 기억 내용
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var content: String,
|
||||
|
||||
// 감정
|
||||
var emotion: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter
|
||||
) : BaseEntity()
|
@@ -1,26 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 캐릭터 성격 특성
|
||||
*/
|
||||
|
||||
@Entity
|
||||
class ChatCharacterPersonality(
|
||||
// 성격 특성
|
||||
val trait: String,
|
||||
|
||||
// 성격 특성 설명
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var description: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter
|
||||
) : BaseEntity()
|
@@ -1,33 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 캐릭터 관계
|
||||
*/
|
||||
|
||||
@Entity
|
||||
class ChatCharacterRelationship(
|
||||
// 상대 인물 이름
|
||||
var personName: String,
|
||||
// 관계명 (예: 친구, 동료 등)
|
||||
var relationshipName: String,
|
||||
// 관계 설명
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var description: String,
|
||||
// 중요도
|
||||
var importance: Int,
|
||||
// 관계 타입 (분류용)
|
||||
var relationshipType: String,
|
||||
// 현재 상태
|
||||
var currentStatus: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter
|
||||
) : BaseEntity()
|
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
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 ChatCharacterTag(
|
||||
@Column(nullable = false)
|
||||
val tag: String
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "tag")
|
||||
var tagMappings: MutableList<ChatCharacterTagMapping> = mutableListOf()
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* ChatCharacter와 ChatCharacterTag 간의 매핑 엔티티
|
||||
* ChatCharacterTag의 중복을 방지하기 위한 매핑 테이블
|
||||
*/
|
||||
@Entity
|
||||
class ChatCharacterTagMapping(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "tag_id")
|
||||
val tag: ChatCharacterTag
|
||||
) : BaseEntity()
|
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
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 = ["value"])])
|
||||
class ChatCharacterValue(
|
||||
@Column(nullable = false)
|
||||
val value: String
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "value")
|
||||
var valueMappings: MutableList<ChatCharacterValueMapping> = mutableListOf()
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* ChatCharacter와 ChatCharacterValue 간의 매핑 엔티티
|
||||
* ChatCharacterValue의 중복을 방지하기 위한 매핑 테이블
|
||||
*/
|
||||
@Entity
|
||||
class ChatCharacterValueMapping(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "value_id")
|
||||
val value: ChatCharacterValue
|
||||
) : BaseEntity()
|
@@ -1,118 +0,0 @@
|
||||
# ChatCharacter 엔티티 관계 구현
|
||||
|
||||
## 개요
|
||||
|
||||
이 구현은 ChatCharacter와 다른 엔티티들 간의 1:N 관계를 설정합니다. ChatCharacter가 저장될 때 관련 엔티티들도 함께 저장되며, Tag, Value, Hobby, Goal은 중복을 허용하지 않고 기존 내용과 동일한 내용이 들어오면 관계만 맺습니다.
|
||||
|
||||
## 엔티티 구조
|
||||
|
||||
### 주요 엔티티
|
||||
|
||||
- **ChatCharacter**: 메인 엔티티로, 캐릭터의 기본 정보를 저장합니다.
|
||||
- **ChatCharacterMemory**: 캐릭터의 기억 정보를 저장합니다.
|
||||
- **ChatCharacterPersonality**: 캐릭터의 성격 특성을 저장합니다.
|
||||
- **ChatCharacterBackground**: 캐릭터의 배경 정보를 저장합니다.
|
||||
- **ChatCharacterRelationship**: 캐릭터의 관계 정보를 저장합니다.
|
||||
|
||||
### 중복 방지를 위한 엔티티
|
||||
|
||||
- **ChatCharacterTag**: 캐릭터 태그 정보를 저장합니다. 태그 이름은 유일합니다.
|
||||
- **ChatCharacterValue**: 캐릭터 가치관 정보를 저장합니다. 가치관 이름은 유일합니다.
|
||||
- **ChatCharacterHobby**: 캐릭터 취미 정보를 저장합니다. 취미 이름은 유일합니다.
|
||||
- **ChatCharacterGoal**: 캐릭터 목표 정보를 저장합니다. 목표 이름은 유일합니다.
|
||||
|
||||
### 매핑 엔티티
|
||||
|
||||
- **ChatCharacterTagMapping**: ChatCharacter와 ChatCharacterTag 간의 관계를 맺습니다.
|
||||
- **ChatCharacterValueMapping**: ChatCharacter와 ChatCharacterValue 간의 관계를 맺습니다.
|
||||
- **ChatCharacterHobbyMapping**: ChatCharacter와 ChatCharacterHobby 간의 관계를 맺습니다.
|
||||
- **ChatCharacterGoalMapping**: ChatCharacter와 ChatCharacterGoal 간의 관계를 맺습니다.
|
||||
|
||||
## 관계 설정
|
||||
|
||||
- ChatCharacter와 Memory, Personality, Background, Relationship은 1:N 관계입니다.
|
||||
- ChatCharacter와 Tag, Value, Hobby, Goal은 매핑 엔티티를 통한 N:M 관계입니다.
|
||||
- 모든 관계는 ChatCharacter가 저장될 때 함께 저장됩니다(CascadeType.ALL).
|
||||
|
||||
## 서비스 기능
|
||||
|
||||
ChatCharacterService는 다음과 같은 기능을 제공합니다:
|
||||
|
||||
1. 캐릭터 생성 및 저장
|
||||
2. 캐릭터 조회 (UUID, 이름, 전체 목록)
|
||||
3. 캐릭터에 태그, 가치관, 취미, 목표 추가
|
||||
4. 캐릭터에 기억, 성격 특성, 배경 정보, 관계 추가
|
||||
5. 모든 정보를 포함한 캐릭터 생성
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```kotlin
|
||||
// 캐릭터 생성
|
||||
val chatCharacter = chatCharacterService.createChatCharacter(
|
||||
characterUUID = "uuid-123",
|
||||
name = "AI 어시스턴트",
|
||||
description = "친절한 AI 어시스턴트",
|
||||
systemPrompt = "당신은 친절한 AI 어시스턴트입니다."
|
||||
)
|
||||
|
||||
// 태그 추가
|
||||
chatCharacterService.addTagToCharacter(chatCharacter, "친절함")
|
||||
chatCharacterService.addTagToCharacter(chatCharacter, "도움")
|
||||
|
||||
// 가치관 추가
|
||||
chatCharacterService.addValueToCharacter(chatCharacter, "정직")
|
||||
|
||||
// 취미 추가
|
||||
chatCharacterService.addHobbyToCharacter(chatCharacter, "독서")
|
||||
|
||||
// 목표 추가
|
||||
chatCharacterService.addGoalToCharacter(chatCharacter, "사용자 만족")
|
||||
|
||||
// 기억 추가
|
||||
chatCharacterService.addMemoryToChatCharacter(
|
||||
chatCharacter,
|
||||
"첫 만남",
|
||||
"사용자와의 첫 대화",
|
||||
"기쁨"
|
||||
)
|
||||
|
||||
// 성격 특성 추가
|
||||
chatCharacterService.addPersonalityToChatCharacter(
|
||||
chatCharacter,
|
||||
"친절함",
|
||||
"항상 친절하게 대응합니다."
|
||||
)
|
||||
|
||||
// 배경 정보 추가
|
||||
chatCharacterService.addBackgroundToChatCharacter(
|
||||
chatCharacter,
|
||||
"생성 배경",
|
||||
"사용자를 돕기 위해 만들어졌습니다."
|
||||
)
|
||||
|
||||
// 관계 추가
|
||||
chatCharacterService.addRelationshipToChatCharacter(
|
||||
chatCharacter,
|
||||
"사용자와의 관계: 도우미"
|
||||
)
|
||||
|
||||
// 모든 정보를 포함한 캐릭터 생성
|
||||
val completeCharacter = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "uuid-456",
|
||||
name = "종합 AI",
|
||||
description = "모든 정보를 가진 AI",
|
||||
systemPrompt = "당신은 모든 정보를 가진 AI입니다.",
|
||||
tags = listOf("종합", "지식"),
|
||||
values = listOf("정확성", "유용성"),
|
||||
hobbies = listOf("학습", "정보 수집"),
|
||||
goals = listOf("정확한 정보 제공"),
|
||||
memories = listOf(Triple("학습 시작", "처음 학습을 시작했습니다.", "호기심")),
|
||||
personalities = listOf(Pair("분석적", "정보를 분석적으로 처리합니다.")),
|
||||
backgrounds = listOf(Pair("개발 목적", "정보 제공을 위해 개발되었습니다.")),
|
||||
relationships = listOf("사용자와의 관계: 정보 제공자")
|
||||
)
|
||||
```
|
||||
|
||||
## 중복 방지 메커니즘
|
||||
|
||||
Tag, Value, Hobby, Goal 엔티티는 각각 고유한 필드(tag, value, hobby, goal)에 대해 유니크 제약 조건을 가지고 있습니다. 서비스 레이어에서는 이미 존재하는 엔티티를 찾아 재사용하거나, 존재하지 않는 경우 새로 생성합니다. 이를 통해 중복을 방지하고 관계만 맺을 수 있습니다.
|
@@ -1,39 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
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.OneToMany
|
||||
import javax.persistence.Table
|
||||
|
||||
@Entity
|
||||
@Table(name = "character_comment")
|
||||
data class CharacterComment(
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var comment: String,
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "parent_id", nullable = true)
|
||||
var parent: CharacterComment? = null
|
||||
set(value) {
|
||||
value?.children?.add(this)
|
||||
field = value
|
||||
}
|
||||
|
||||
@OneToMany(mappedBy = "parent")
|
||||
var children: MutableList<CharacterComment> = mutableListOf()
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
var member: Member? = null
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "character_id", nullable = false)
|
||||
var chatCharacter: ChatCharacter? = null
|
||||
}
|
@@ -1,108 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
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.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.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")
|
||||
class CharacterCommentController(
|
||||
private val service: CharacterCommentService,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
|
||||
@PostMapping("/{characterId}/comments")
|
||||
fun createComment(
|
||||
@PathVariable characterId: Long,
|
||||
@RequestBody request: CreateCharacterCommentRequest,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val id = service.addComment(characterId, member, request.comment)
|
||||
ApiResponse.ok(id)
|
||||
}
|
||||
|
||||
@PostMapping("/{characterId}/comments/{commentId}/replies")
|
||||
fun createReply(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@RequestBody request: CreateCharacterCommentRequest,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val id = service.addReply(characterId, commentId, member, request.comment)
|
||||
ApiResponse.ok(id)
|
||||
}
|
||||
|
||||
@GetMapping("/{characterId}/comments")
|
||||
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, cursor, limit)
|
||||
ApiResponse.ok(data)
|
||||
}
|
||||
|
||||
@GetMapping("/{characterId}/comments/{commentId}/replies")
|
||||
fun listReplies(
|
||||
@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, 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, "신고가 접수되었습니다.")
|
||||
}
|
||||
}
|
@@ -1,64 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
// Request DTOs
|
||||
data class CreateCharacterCommentRequest(
|
||||
val comment: String
|
||||
)
|
||||
|
||||
// Response DTOs
|
||||
// 댓글 Response
|
||||
// - 댓글 ID
|
||||
// - 댓글 쓴 Member 프로필 이미지
|
||||
// - 댓글 쓴 Member 닉네임
|
||||
// - 댓글 쓴 시간 timestamp(long)
|
||||
// - 답글 수
|
||||
|
||||
data class CharacterCommentResponse(
|
||||
val commentId: Long,
|
||||
val memberId: Long,
|
||||
val memberProfileImage: String,
|
||||
val memberNickname: String,
|
||||
val createdAt: Long,
|
||||
val replyCount: Int,
|
||||
val comment: String
|
||||
)
|
||||
|
||||
// 답글 Response 단건(목록 원소)
|
||||
// - 답글 ID
|
||||
// - 답글 쓴 Member 프로필 이미지
|
||||
// - 답글 쓴 Member 닉네임
|
||||
// - 답글 쓴 시간 timestamp(long)
|
||||
|
||||
data class CharacterReplyResponse(
|
||||
val replyId: Long,
|
||||
val memberId: Long,
|
||||
val memberProfileImage: String,
|
||||
val memberNickname: String,
|
||||
val createdAt: Long,
|
||||
val comment: String
|
||||
)
|
||||
|
||||
// 댓글의 답글 조회 Response 컨테이너
|
||||
// - 원본 댓글 Response
|
||||
// - 답글 목록(위 사양의 필드 포함)
|
||||
|
||||
data class CharacterCommentRepliesResponse(
|
||||
val original: CharacterCommentResponse,
|
||||
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
|
||||
)
|
@@ -1,25 +0,0 @@
|
||||
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
|
||||
}
|
@@ -1,5 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CharacterCommentReportRepository : JpaRepository<CharacterCommentReport, Long>
|
@@ -1,38 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CharacterCommentRepository : JpaRepository<CharacterComment, Long> {
|
||||
fun findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(
|
||||
chatCharacterId: Long,
|
||||
pageable: Pageable
|
||||
): List<CharacterComment>
|
||||
|
||||
fun findByChatCharacter_IdAndIsActiveTrueAndParentIsNullAndIdLessThanOrderByCreatedAtDesc(
|
||||
chatCharacterId: Long,
|
||||
id: Long,
|
||||
pageable: Pageable
|
||||
): List<CharacterComment>
|
||||
|
||||
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
|
||||
}
|
@@ -1,194 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.ZoneId
|
||||
|
||||
@Service
|
||||
class CharacterCommentService(
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val commentRepository: CharacterCommentRepository,
|
||||
private val reportRepository: CharacterCommentReportRepository
|
||||
) {
|
||||
|
||||
private fun profileUrl(imageHost: String, profileImage: String?): String {
|
||||
return if (profileImage.isNullOrBlank()) {
|
||||
"$imageHost/profile/default-profile.png"
|
||||
} else {
|
||||
"$imageHost/$profileImage"
|
||||
}
|
||||
}
|
||||
|
||||
private fun toEpochMilli(created: java.time.LocalDateTime?): Long {
|
||||
return created?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L
|
||||
}
|
||||
|
||||
private fun toCommentResponse(
|
||||
imageHost: String,
|
||||
entity: CharacterComment,
|
||||
replyCountOverride: Int? = null
|
||||
): CharacterCommentResponse {
|
||||
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),
|
||||
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
|
||||
comment = entity.comment
|
||||
)
|
||||
}
|
||||
|
||||
private fun toReplyResponse(imageHost: String, entity: CharacterComment): CharacterReplyResponse {
|
||||
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),
|
||||
comment = entity.comment
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addComment(characterId: Long, member: Member, text: String): Long {
|
||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val entity = CharacterComment(comment = text)
|
||||
entity.chatCharacter = character
|
||||
entity.member = member
|
||||
commentRepository.save(entity)
|
||||
return entity.id!!
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): Long {
|
||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
||||
if (parent.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.")
|
||||
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
|
||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val entity = CharacterComment(comment = text)
|
||||
entity.chatCharacter = character
|
||||
entity.member = member
|
||||
entity.parent = parent
|
||||
commentRepository.save(entity)
|
||||
return entity.id!!
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listComments(
|
||||
imageHost: String,
|
||||
characterId: Long,
|
||||
cursor: Long?,
|
||||
limit: Int = 20
|
||||
): CharacterCommentListResponse {
|
||||
val pageable = PageRequest.of(0, limit)
|
||||
val comments = if (cursor == null) {
|
||||
commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(
|
||||
characterId,
|
||||
pageable
|
||||
)
|
||||
} 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,
|
||||
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 = 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 = items,
|
||||
cursor = nextCursor
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getLatestComment(imageHost: String, characterId: Long): CharacterCommentResponse? {
|
||||
return commentRepository
|
||||
.findFirstByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(characterId)
|
||||
?.let { toCommentResponse(imageHost, it) }
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getTotalCommentCount(characterId: Long): Int {
|
||||
// 활성 원댓글 수 + 활성 부모를 가진 활성 답글 수
|
||||
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)
|
||||
}
|
||||
}
|
@@ -1,196 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.controller
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterDetailResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterMainResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||
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.PathVariable
|
||||
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")
|
||||
class ChatCharacterController(
|
||||
private val service: ChatCharacterService,
|
||||
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
|
||||
) {
|
||||
@GetMapping("/main")
|
||||
fun getCharacterMain(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
): ApiResponse<CharacterMainResponse> = run {
|
||||
// 배너 조회 (최대 10개)
|
||||
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
|
||||
.content
|
||||
.map {
|
||||
CharacterBannerResponse(
|
||||
characterId = it.chatCharacter.id!!,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
|
||||
// 최근 대화한 캐릭터(채팅방) 조회 (회원별 최근 순으로 최대 10개)
|
||||
val recentCharacters = if (member == null || member.auth == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
chatRoomService.listMyChatRooms(member, 0, 10)
|
||||
.map { room ->
|
||||
RecentCharacter(
|
||||
characterId = room.characterId,
|
||||
name = room.title,
|
||||
imageUrl = room.imageUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 인기 캐릭터 조회
|
||||
val popularCharacters = service.getPopularCharacters()
|
||||
|
||||
// 최근 등록된 캐릭터 리스트 조회
|
||||
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,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 응답 생성
|
||||
ApiResponse.ok(
|
||||
CharacterMainResponse(
|
||||
banners = banners,
|
||||
recentCharacters = recentCharacters,
|
||||
popularCharacters = popularCharacters,
|
||||
newCharacters = newCharacters,
|
||||
curationSections = curationSections
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 상세 정보 조회 API
|
||||
* 캐릭터 ID를 받아 해당 캐릭터의 상세 정보를 반환합니다.
|
||||
*/
|
||||
@GetMapping("/{characterId}")
|
||||
fun getCharacterDetail(
|
||||
@PathVariable characterId: Long,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
// 캐릭터 상세 정보 조회
|
||||
val character = service.getCharacterDetail(characterId)
|
||||
?: throw SodaException("캐릭터를 찾을 수 없습니다.")
|
||||
|
||||
// 태그 가공: # prefix 규칙 적용 후 공백으로 연결
|
||||
val tags = character.tagMappings
|
||||
.map { it.tag.tag }
|
||||
.joinToString(" ") { if (it.startsWith("#")) it else "#$it" }
|
||||
|
||||
// 성격, 배경: 각각 첫 번째 항목만 선택
|
||||
val personality: CharacterPersonalityResponse? = character.personalities.firstOrNull()?.let {
|
||||
CharacterPersonalityResponse(
|
||||
trait = it.trait,
|
||||
description = it.description
|
||||
)
|
||||
}
|
||||
|
||||
val background: CharacterBackgroundResponse? = character.backgrounds.firstOrNull()?.let {
|
||||
CharacterBackgroundResponse(
|
||||
topic = it.topic,
|
||||
description = it.description
|
||||
)
|
||||
}
|
||||
|
||||
// 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
|
||||
val others = service.getOtherCharactersBySharedTags(characterId, 10)
|
||||
.map { other ->
|
||||
val otherTags = other.tagMappings
|
||||
.map { it.tag.tag }
|
||||
.joinToString(" ") { if (it.startsWith("#")) it else "#$it" }
|
||||
OtherCharacter(
|
||||
characterId = other.id!!,
|
||||
name = other.name,
|
||||
imageUrl = "$imageHost/${other.imagePath ?: "profile/default-profile.png"}",
|
||||
tags = otherTags
|
||||
)
|
||||
}
|
||||
|
||||
// 최신 댓글 1개 조회
|
||||
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
|
||||
|
||||
// 응답 생성
|
||||
ApiResponse.ok(
|
||||
CharacterDetailResponse(
|
||||
characterId = character.id!!,
|
||||
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,
|
||||
tags = tags,
|
||||
originalTitle = character.originalTitle,
|
||||
originalLink = character.originalLink,
|
||||
characterType = character.characterType,
|
||||
others = others,
|
||||
latestComment = latestComment,
|
||||
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 등록된 캐릭터 전체보기
|
||||
* - 기준: 2주 이내 등록된 캐릭터만 페이징 조회
|
||||
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
|
||||
*/
|
||||
@GetMapping("/recent")
|
||||
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
|
||||
ApiResponse.ok(
|
||||
service.getRecentCharactersPage(
|
||||
page = page ?: 0,
|
||||
size = 20
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
@@ -1,47 +0,0 @@
|
||||
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()
|
@@ -1,37 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
}
|
@@ -1,14 +0,0 @@
|
||||
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?
|
||||
}
|
@@ -1,40 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
|
||||
|
||||
data class CharacterDetailResponse(
|
||||
val characterId: Long,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val mbti: String?,
|
||||
val gender: String?,
|
||||
val age: Int?,
|
||||
val imageUrl: String,
|
||||
val personalities: CharacterPersonalityResponse?,
|
||||
val backgrounds: CharacterBackgroundResponse?,
|
||||
val tags: String,
|
||||
val originalTitle: String?,
|
||||
val originalLink: String?,
|
||||
val characterType: CharacterType,
|
||||
val others: List<OtherCharacter>,
|
||||
val latestComment: CharacterCommentResponse?,
|
||||
val totalComments: Int
|
||||
)
|
||||
|
||||
data class OtherCharacter(
|
||||
val characterId: Long,
|
||||
val name: String,
|
||||
val imageUrl: String,
|
||||
val tags: String
|
||||
)
|
||||
|
||||
data class CharacterPersonalityResponse(
|
||||
val trait: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class CharacterBackgroundResponse(
|
||||
val topic: String,
|
||||
val description: String
|
||||
)
|
@@ -1,35 +0,0 @@
|
||||
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>,
|
||||
val popularCharacters: List<Character>,
|
||||
val newCharacters: List<Character>,
|
||||
val curationSections: List<CurationSection>
|
||||
)
|
||||
|
||||
data class CurationSection(
|
||||
val characterCurationId: Long,
|
||||
val title: String,
|
||||
val characters: List<Character>
|
||||
)
|
||||
|
||||
data class Character(
|
||||
@JsonProperty("characterId") val characterId: Long,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("description") val description: String,
|
||||
@JsonProperty("imageUrl") val imageUrl: String
|
||||
)
|
||||
|
||||
data class RecentCharacter(
|
||||
val characterId: Long,
|
||||
val name: String,
|
||||
val imageUrl: String
|
||||
)
|
||||
|
||||
data class CharacterBannerResponse(
|
||||
val characterId: Long,
|
||||
val imageUrl: String
|
||||
)
|
@@ -1,9 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.dto
|
||||
|
||||
/**
|
||||
* 최근 등록된 캐릭터 전체보기 페이지 응답 DTO
|
||||
*/
|
||||
data class RecentCharactersResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<Character>
|
||||
)
|
@@ -1,41 +0,0 @@
|
||||
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()
|
||||
}
|
@@ -1,226 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
@@ -1,100 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
@@ -1,169 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
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()
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
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()
|
@@ -1,9 +0,0 @@
|
||||
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?
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
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>
|
||||
)
|
@@ -1,12 +0,0 @@
|
||||
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
|
||||
)
|
@@ -1,18 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.repository
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
|
||||
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 ChatCharacterBannerRepository : JpaRepository<ChatCharacterBanner, Long> {
|
||||
// 활성화된 배너 목록 조회 (정렬 순서대로)
|
||||
fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<ChatCharacterBanner>
|
||||
|
||||
// 활성화된 배너 중 최대 정렬 순서 값 조회
|
||||
@Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true")
|
||||
fun findMaxSortOrder(): Int?
|
||||
}
|
@@ -1,10 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.repository
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface ChatCharacterGoalRepository : JpaRepository<ChatCharacterGoal, Long> {
|
||||
fun findByGoal(goal: String): ChatCharacterGoal?
|
||||
}
|
@@ -1,10 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.repository
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface ChatCharacterHobbyRepository : JpaRepository<ChatCharacterHobby, Long> {
|
||||
fun findByHobby(hobby: String): ChatCharacterHobby?
|
||||
}
|
@@ -1,78 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.repository
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
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
|
||||
|
||||
@Repository
|
||||
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||
fun findByName(name: String): ChatCharacter?
|
||||
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
||||
fun findByOriginalWorkIdAndIsActiveTrue(originalWorkId: Long, pageable: Pageable): Page<ChatCharacter>
|
||||
|
||||
/**
|
||||
* 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회
|
||||
*/
|
||||
@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>
|
||||
|
||||
/**
|
||||
* 2주 이내(파라미터 since 이상) 활성 캐릭터 개수
|
||||
*/
|
||||
fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long
|
||||
|
||||
/**
|
||||
* 이름, 설명, MBTI, 태그로 캐릭터 검색 - 페이징
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT c FROM ChatCharacter c
|
||||
LEFT JOIN c.tagMappings tm
|
||||
LEFT JOIN tm.tag t
|
||||
WHERE c.isActive = true AND
|
||||
(
|
||||
LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
|
||||
LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
|
||||
(c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR
|
||||
(t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%')))
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun searchCharacters(
|
||||
@Param("searchTerm") searchTerm: String,
|
||||
pageable: Pageable
|
||||
): Page<ChatCharacter>
|
||||
|
||||
/**
|
||||
* 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외)
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT c FROM ChatCharacter c
|
||||
JOIN c.tagMappings tm
|
||||
JOIN tm.tag t
|
||||
WHERE c.isActive = true
|
||||
AND c.id <> :characterId
|
||||
AND t.id IN (
|
||||
SELECT t2.id FROM ChatCharacterTagMapping tm2 JOIN tm2.tag t2 WHERE tm2.chatCharacter.id = :characterId
|
||||
)
|
||||
ORDER BY function('RAND')
|
||||
"""
|
||||
)
|
||||
fun findRandomBySharedTags(
|
||||
@Param("characterId") characterId: Long,
|
||||
pageable: Pageable
|
||||
): List<ChatCharacter>
|
||||
|
||||
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
|
||||
}
|
@@ -1,10 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.repository
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface ChatCharacterTagRepository : JpaRepository<ChatCharacterTag, Long> {
|
||||
fun findByTag(tag: String): ChatCharacterTag?
|
||||
}
|
@@ -1,10 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.repository
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface ChatCharacterValueRepository : JpaRepository<ChatCharacterValue, Long> {
|
||||
fun findByValue(value: String): ChatCharacterValue?
|
||||
}
|
@@ -1,134 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.service
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository
|
||||
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 ChatCharacterBannerService(
|
||||
private val bannerRepository: ChatCharacterBannerRepository,
|
||||
private val characterRepository: ChatCharacterRepository
|
||||
) {
|
||||
/**
|
||||
* 활성화된 모든 배너 조회 (정렬 순서대로)
|
||||
*/
|
||||
fun getActiveBanners(pageable: Pageable): Page<ChatCharacterBanner> {
|
||||
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 상세 조회
|
||||
*/
|
||||
fun getBannerById(bannerId: Long): ChatCharacterBanner {
|
||||
return bannerRepository.findById(bannerId)
|
||||
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") }
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 등록
|
||||
*
|
||||
* @param characterId 캐릭터 ID
|
||||
* @param imagePath 이미지 경로
|
||||
* @return 등록된 배너
|
||||
*/
|
||||
@Transactional
|
||||
fun registerBanner(characterId: Long, imagePath: String): ChatCharacterBanner {
|
||||
val character = characterRepository.findById(characterId)
|
||||
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") }
|
||||
|
||||
if (!character.isActive) {
|
||||
throw SodaException("비활성화된 캐릭터에는 배너를 등록할 수 없습니다: $characterId")
|
||||
}
|
||||
|
||||
// 정렬 순서가 지정되지 않은 경우 가장 마지막 순서로 설정
|
||||
val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1
|
||||
|
||||
val banner = ChatCharacterBanner(
|
||||
imagePath = imagePath,
|
||||
chatCharacter = character,
|
||||
sortOrder = finalSortOrder
|
||||
)
|
||||
|
||||
return bannerRepository.save(banner)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 수정
|
||||
*
|
||||
* @param bannerId 배너 ID
|
||||
* @param imagePath 이미지 경로 (변경할 경우)
|
||||
* @param characterId 캐릭터 ID (변경할 경우)
|
||||
* @return 수정된 배너
|
||||
*/
|
||||
@Transactional
|
||||
fun updateBanner(bannerId: Long, imagePath: String? = null, characterId: Long? = null): ChatCharacterBanner {
|
||||
val banner = bannerRepository.findById(bannerId)
|
||||
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") }
|
||||
|
||||
if (!banner.isActive) {
|
||||
throw SodaException("비활성화된 배너는 수정할 수 없습니다: $bannerId")
|
||||
}
|
||||
|
||||
// 이미지 경로 변경
|
||||
if (imagePath != null) {
|
||||
banner.imagePath = imagePath
|
||||
}
|
||||
|
||||
// 캐릭터 변경
|
||||
if (characterId != null) {
|
||||
val character = characterRepository.findById(characterId)
|
||||
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") }
|
||||
|
||||
if (!character.isActive) {
|
||||
throw SodaException("비활성화된 캐릭터로는 변경할 수 없습니다: $characterId")
|
||||
}
|
||||
|
||||
banner.chatCharacter = character
|
||||
}
|
||||
|
||||
return bannerRepository.save(banner)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 삭제 (소프트 삭제)
|
||||
*/
|
||||
@Transactional
|
||||
fun deleteBanner(bannerId: Long) {
|
||||
val banner = bannerRepository.findById(bannerId)
|
||||
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") }
|
||||
|
||||
banner.isActive = false
|
||||
bannerRepository.save(banner)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 정렬 순서 일괄 변경
|
||||
* ID 목록의 순서대로 정렬 순서를 1부터 순차적으로 설정합니다.
|
||||
*
|
||||
* @param ids 배너 ID 목록 (순서대로 정렬됨)
|
||||
* @return 수정된 배너 목록
|
||||
*/
|
||||
@Transactional
|
||||
fun updateBannerOrders(ids: List<Long>): List<ChatCharacterBanner> {
|
||||
val updatedBanners = mutableListOf<ChatCharacterBanner>()
|
||||
|
||||
for (index in ids.indices) {
|
||||
val banner = bannerRepository.findById(ids[index])
|
||||
.orElseThrow { SodaException("배너를 찾을 수 없습니다: ${ids[index]}") }
|
||||
|
||||
if (!banner.isActive) {
|
||||
throw SodaException("비활성화된 배너는 수정할 수 없습니다: ${ids[index]}")
|
||||
}
|
||||
|
||||
banner.sortOrder = index + 1
|
||||
updatedBanners.add(bannerRepository.save(banner))
|
||||
}
|
||||
|
||||
return updatedBanners
|
||||
}
|
||||
}
|
@@ -1,703 +0,0 @@
|
||||
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
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
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(
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val tagRepository: ChatCharacterTagRepository,
|
||||
private val valueRepository: ChatCharacterValueRepository,
|
||||
private val hobbyRepository: ChatCharacterHobbyRepository,
|
||||
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)
|
||||
@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] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 등록된 캐릭터 전체보기 (페이징) - 전체 개수 포함
|
||||
* - 기준: 현재 시각 기준 2주 이내 생성된 활성 캐릭터
|
||||
* - 2주 이내 캐릭터가 0개라면: totalCount=20, 첫 페이지는 최근 등록 활성 캐릭터 20개, 그 외 페이지는 빈 리스트
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getOtherCharactersBySharedTags(characterId: Long, limit: Int = 10): List<ChatCharacter> {
|
||||
val others = chatCharacterRepository.findRandomBySharedTags(
|
||||
characterId,
|
||||
PageRequest.of(0, limit)
|
||||
)
|
||||
// 태그 초기화 (지연 로딩 문제 방지)
|
||||
others.forEach { it.tagMappings.size }
|
||||
return others
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그를 찾거나 생성하여 캐릭터에 연결
|
||||
*/
|
||||
@Transactional
|
||||
fun addTagToCharacter(chatCharacter: ChatCharacter, tagName: String) {
|
||||
val tag = tagRepository.findByTag(tagName) ?: ChatCharacterTag(tagName)
|
||||
if (tag.id == null) {
|
||||
tagRepository.save(tag)
|
||||
}
|
||||
chatCharacter.addTag(tag)
|
||||
}
|
||||
|
||||
/**
|
||||
* 가치관을 찾거나 생성하여 캐릭터에 연결
|
||||
*/
|
||||
@Transactional
|
||||
fun addValueToCharacter(chatCharacter: ChatCharacter, valueName: String) {
|
||||
val value = valueRepository.findByValue(valueName) ?: ChatCharacterValue(valueName)
|
||||
if (value.id == null) {
|
||||
valueRepository.save(value)
|
||||
}
|
||||
chatCharacter.addValue(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 취미를 찾거나 생성하여 캐릭터에 연결
|
||||
*/
|
||||
@Transactional
|
||||
fun addHobbyToCharacter(chatCharacter: ChatCharacter, hobbyName: String) {
|
||||
val hobby = hobbyRepository.findByHobby(hobbyName) ?: ChatCharacterHobby(hobbyName)
|
||||
if (hobby.id == null) {
|
||||
hobbyRepository.save(hobby)
|
||||
}
|
||||
chatCharacter.addHobby(hobby)
|
||||
}
|
||||
|
||||
/**
|
||||
* 목표를 찾거나 생성하여 캐릭터에 연결
|
||||
*/
|
||||
@Transactional
|
||||
fun addGoalToCharacter(chatCharacter: ChatCharacter, goalName: String) {
|
||||
val goal = goalRepository.findByGoal(goalName) ?: ChatCharacterGoal(goalName)
|
||||
if (goal.id == null) {
|
||||
goalRepository.save(goal)
|
||||
}
|
||||
chatCharacter.addGoal(goal)
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 태그를 한번에 캐릭터에 연결
|
||||
*/
|
||||
@Transactional
|
||||
fun addTagsToCharacter(chatCharacter: ChatCharacter, tags: List<String>) {
|
||||
tags.distinct().forEach { addTagToCharacter(chatCharacter, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 매핑을 증분 업데이트(추가/삭제만)하여 불필요한 매핑 레코드 재생성을 방지
|
||||
*/
|
||||
@Transactional
|
||||
fun updateTagsForCharacter(chatCharacter: ChatCharacter, tags: List<String>) {
|
||||
val desired = tags.distinct()
|
||||
// 현재 매핑된 태그 문자열 목록
|
||||
val current = chatCharacter.tagMappings.map { it.tag.tag }
|
||||
|
||||
// 추가가 필요한 태그
|
||||
val toAdd = desired.filterNot { current.contains(it) }
|
||||
toAdd.forEach { addTagToCharacter(chatCharacter, it) }
|
||||
|
||||
// 제거가 필요한 태그 매핑
|
||||
if (chatCharacter.tagMappings.isNotEmpty()) {
|
||||
val iterator = chatCharacter.tagMappings.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val mapping = iterator.next()
|
||||
if (!desired.contains(mapping.tag.tag)) {
|
||||
iterator.remove() // orphanRemoval=true 이므로 매핑 엔티티 삭제 처리
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 가치관을 한번에 캐릭터에 연결
|
||||
*/
|
||||
@Transactional
|
||||
fun addValuesToCharacter(chatCharacter: ChatCharacter, values: List<String>) {
|
||||
values.forEach { addValueToCharacter(chatCharacter, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 취미를 한번에 캐릭터에 연결
|
||||
*/
|
||||
@Transactional
|
||||
fun addHobbiesToCharacter(chatCharacter: ChatCharacter, hobbies: List<String>) {
|
||||
hobbies.forEach { addHobbyToCharacter(chatCharacter, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 목표를 한번에 캐릭터에 연결
|
||||
*/
|
||||
@Transactional
|
||||
fun addGoalsToCharacter(chatCharacter: ChatCharacter, goals: List<String>) {
|
||||
goals.forEach { addGoalToCharacter(chatCharacter, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 가치관 매핑 증분 업데이트
|
||||
*/
|
||||
@Transactional
|
||||
fun updateValuesForCharacter(chatCharacter: ChatCharacter, values: List<String>) {
|
||||
val desired = values.distinct()
|
||||
val current = chatCharacter.valueMappings.map { it.value.value }
|
||||
val toAdd = desired.filterNot { current.contains(it) }
|
||||
toAdd.forEach { addValueToCharacter(chatCharacter, it) }
|
||||
if (chatCharacter.valueMappings.isNotEmpty()) {
|
||||
val iterator = chatCharacter.valueMappings.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val mapping = iterator.next()
|
||||
if (!desired.contains(mapping.value.value)) {
|
||||
iterator.remove() // orphanRemoval=true 이므로 매핑 엔티티 삭제 처리
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 취미 매핑 증분 업데이트
|
||||
*/
|
||||
@Transactional
|
||||
fun updateHobbiesForCharacter(chatCharacter: ChatCharacter, hobbies: List<String>) {
|
||||
val desired = hobbies.distinct()
|
||||
val current = chatCharacter.hobbyMappings.map { it.hobby.hobby }
|
||||
val toAdd = desired.filterNot { current.contains(it) }
|
||||
toAdd.forEach { addHobbyToCharacter(chatCharacter, it) }
|
||||
if (chatCharacter.hobbyMappings.isNotEmpty()) {
|
||||
val iterator = chatCharacter.hobbyMappings.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val mapping = iterator.next()
|
||||
if (!desired.contains(mapping.hobby.hobby)) {
|
||||
iterator.remove() // orphanRemoval=true 이므로 매핑 엔티티 삭제 처리
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 목표 매핑 증분 업데이트
|
||||
*/
|
||||
@Transactional
|
||||
fun updateGoalsForCharacter(chatCharacter: ChatCharacter, goals: List<String>) {
|
||||
val desired = goals.distinct()
|
||||
val current = chatCharacter.goalMappings.map { it.goal.goal }
|
||||
val toAdd = desired.filterNot { current.contains(it) }
|
||||
toAdd.forEach { addGoalToCharacter(chatCharacter, it) }
|
||||
if (chatCharacter.goalMappings.isNotEmpty()) {
|
||||
val iterator = chatCharacter.goalMappings.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val mapping = iterator.next()
|
||||
if (!desired.contains(mapping.goal.goal)) {
|
||||
iterator.remove() // orphanRemoval=true 이므로 매핑 엔티티 삭제 처리
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기억(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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 저장
|
||||
*/
|
||||
@Transactional
|
||||
fun saveChatCharacter(chatCharacter: ChatCharacter): ChatCharacter {
|
||||
return chatCharacterRepository.save(chatCharacter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이름으로 캐릭터 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun findByName(name: String): ChatCharacter? {
|
||||
return chatCharacterRepository.findByName(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 캐릭터 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun findById(id: Long): ChatCharacter? {
|
||||
return chatCharacterRepository.findById(id).orElse(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 ID로 상세 정보를 조회합니다.
|
||||
* 태그, 가치관, 취미, 목표 등의 관계 정보도 함께 조회합니다.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getCharacterDetail(id: Long): ChatCharacter? {
|
||||
val character = findById(id) ?: return null
|
||||
|
||||
// 지연 로딩된 관계 데이터 초기화
|
||||
character.tagMappings.size
|
||||
character.valueMappings.size
|
||||
character.hobbyMappings.size
|
||||
character.goalMappings.size
|
||||
character.memories.size
|
||||
character.personalities.size
|
||||
character.backgrounds.size
|
||||
character.relationships.size
|
||||
|
||||
return character
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 생성 및 관련 엔티티 연결
|
||||
*/
|
||||
@Transactional
|
||||
fun createChatCharacter(
|
||||
characterUUID: String,
|
||||
name: String,
|
||||
description: String,
|
||||
systemPrompt: String,
|
||||
age: Int? = null,
|
||||
gender: String? = null,
|
||||
mbti: String? = null,
|
||||
speechPattern: String? = null,
|
||||
speechStyle: String? = null,
|
||||
appearance: String? = null,
|
||||
originalTitle: String? = null,
|
||||
originalLink: String? = null,
|
||||
characterType: CharacterType = CharacterType.Character,
|
||||
tags: List<String> = emptyList(),
|
||||
values: List<String> = emptyList(),
|
||||
hobbies: List<String> = emptyList(),
|
||||
goals: List<String> = emptyList()
|
||||
): ChatCharacter {
|
||||
val chatCharacter = ChatCharacter(
|
||||
characterUUID = characterUUID,
|
||||
name = name,
|
||||
description = description,
|
||||
systemPrompt = systemPrompt,
|
||||
age = age,
|
||||
gender = gender,
|
||||
mbti = mbti,
|
||||
speechPattern = speechPattern,
|
||||
speechStyle = speechStyle,
|
||||
appearance = appearance,
|
||||
originalTitle = originalTitle,
|
||||
originalLink = originalLink,
|
||||
characterType = characterType
|
||||
)
|
||||
|
||||
// 관련 엔티티 연결
|
||||
addTagsToCharacter(chatCharacter, tags)
|
||||
addValuesToCharacter(chatCharacter, values)
|
||||
addHobbiesToCharacter(chatCharacter, hobbies)
|
||||
addGoalsToCharacter(chatCharacter, goals)
|
||||
|
||||
return saveChatCharacter(chatCharacter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 생성 시 기본 정보와 함께 추가 정보도 설정
|
||||
*/
|
||||
@Transactional
|
||||
fun createChatCharacterWithDetails(
|
||||
characterUUID: String,
|
||||
name: String,
|
||||
description: String,
|
||||
systemPrompt: String,
|
||||
age: Int? = null,
|
||||
gender: String? = null,
|
||||
mbti: String? = null,
|
||||
speechPattern: String? = null,
|
||||
speechStyle: String? = null,
|
||||
appearance: String? = null,
|
||||
originalTitle: String? = null,
|
||||
originalLink: String? = null,
|
||||
characterType: CharacterType = CharacterType.Character,
|
||||
tags: List<String> = emptyList(),
|
||||
values: List<String> = emptyList(),
|
||||
hobbies: List<String> = emptyList(),
|
||||
goals: List<String> = emptyList(),
|
||||
memories: List<Triple<String, String, String>> = emptyList(),
|
||||
personalities: List<Pair<String, String>> = emptyList(),
|
||||
backgrounds: List<Pair<String, String>> = emptyList(),
|
||||
relationships: List<ChatCharacterRelationshipRequest> = emptyList()
|
||||
): ChatCharacter {
|
||||
val chatCharacter = createChatCharacter(
|
||||
characterUUID = characterUUID,
|
||||
name = name,
|
||||
description = description,
|
||||
systemPrompt = systemPrompt,
|
||||
age = age,
|
||||
gender = gender,
|
||||
mbti = mbti,
|
||||
speechPattern = speechPattern,
|
||||
speechStyle = speechStyle,
|
||||
appearance = appearance,
|
||||
originalTitle = originalTitle,
|
||||
originalLink = originalLink,
|
||||
characterType = characterType,
|
||||
tags = tags,
|
||||
values = values,
|
||||
hobbies = hobbies,
|
||||
goals = goals
|
||||
)
|
||||
|
||||
// 추가 정보 설정
|
||||
memories.forEach { (title, content, emotion) ->
|
||||
chatCharacter.addMemory(title, content, emotion)
|
||||
}
|
||||
|
||||
personalities.forEach { (trait, description) ->
|
||||
chatCharacter.addPersonality(trait, description)
|
||||
}
|
||||
|
||||
backgrounds.forEach { (topic, description) ->
|
||||
chatCharacter.addBackground(topic, description)
|
||||
}
|
||||
|
||||
relationships.forEach { rr ->
|
||||
chatCharacter.addRelationship(
|
||||
rr.personName,
|
||||
rr.relationshipName,
|
||||
rr.description,
|
||||
rr.importance,
|
||||
rr.relationshipType,
|
||||
rr.currentStatus
|
||||
)
|
||||
}
|
||||
|
||||
return saveChatCharacter(chatCharacter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 수정 시 기본 정보와 함께 추가 정보도 설정
|
||||
* 이름은 변경할 수 없으므로 이름은 변경하지 않음
|
||||
* 변경된 데이터만 업데이트
|
||||
*
|
||||
* @param imagePath 이미지 경로 (null이면 이미지 변경 없음)
|
||||
* @param request 수정 요청 데이터 (id를 제외한 모든 필드는 null 가능)
|
||||
* @return 수정된 ChatCharacter 객체
|
||||
*/
|
||||
@Transactional
|
||||
fun updateChatCharacterWithDetails(
|
||||
imagePath: String? = null,
|
||||
request: ChatCharacterUpdateRequest
|
||||
): ChatCharacter {
|
||||
// 캐릭터 조회
|
||||
val chatCharacter = findById(request.id)
|
||||
?: throw kr.co.vividnext.sodalive.common.SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")
|
||||
|
||||
// isActive가 false이면 isActive = false, name = "inactive_$name"으로 변경하고 나머지는 반영하지 않는다.
|
||||
if (request.isActive != null && !request.isActive) {
|
||||
chatCharacter.isActive = false
|
||||
|
||||
val inactiveName = "inactive_${request.name}"
|
||||
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
|
||||
chatCharacter.name = inactiveName + randomSuffix
|
||||
|
||||
return saveChatCharacter(chatCharacter)
|
||||
}
|
||||
|
||||
// 이미지 경로가 있으면 설정
|
||||
if (imagePath != null) {
|
||||
chatCharacter.imagePath = imagePath
|
||||
}
|
||||
|
||||
// 기본 필드 업데이트 - 변경된 데이터만 업데이트
|
||||
request.name?.let { chatCharacter.name = it }
|
||||
request.systemPrompt?.let { chatCharacter.systemPrompt = it }
|
||||
request.description?.let { chatCharacter.description = it }
|
||||
request.age?.toIntOrNull()?.let { chatCharacter.age = it }
|
||||
request.gender?.let { chatCharacter.gender = it }
|
||||
request.mbti?.let { chatCharacter.mbti = it }
|
||||
request.speechPattern?.let { chatCharacter.speechPattern = it }
|
||||
request.speechStyle?.let { chatCharacter.speechStyle = it }
|
||||
request.appearance?.let { chatCharacter.appearance = it }
|
||||
request.originalTitle?.let { chatCharacter.originalTitle = it }
|
||||
request.originalLink?.let { chatCharacter.originalLink = it }
|
||||
request.characterType?.let {
|
||||
runCatching { CharacterType.valueOf(it) }.getOrNull()?.let { ct -> chatCharacter.characterType = ct }
|
||||
}
|
||||
|
||||
// request에서 변경된 데이터만 업데이트
|
||||
if (request.tags != null) {
|
||||
updateTagsForCharacter(chatCharacter, request.tags)
|
||||
}
|
||||
|
||||
if (request.values != null) {
|
||||
updateValuesForCharacter(chatCharacter, request.values)
|
||||
}
|
||||
|
||||
if (request.hobbies != null) {
|
||||
updateHobbiesForCharacter(chatCharacter, request.hobbies)
|
||||
}
|
||||
|
||||
if (request.goals != null) {
|
||||
updateGoalsForCharacter(chatCharacter, request.goals)
|
||||
}
|
||||
|
||||
// 추가 정보 설정 - 변경된 데이터만 업데이트
|
||||
if (request.memories != null) {
|
||||
updateMemoriesForCharacter(chatCharacter, request.memories)
|
||||
}
|
||||
|
||||
if (request.personalities != null) {
|
||||
updatePersonalitiesForCharacter(chatCharacter, request.personalities)
|
||||
}
|
||||
|
||||
if (request.backgrounds != null) {
|
||||
updateBackgroundsForCharacter(chatCharacter, request.backgrounds)
|
||||
}
|
||||
|
||||
if (request.relationships != null) {
|
||||
updateRelationshipsForCharacter(chatCharacter, request.relationships)
|
||||
}
|
||||
|
||||
return saveChatCharacter(chatCharacter)
|
||||
}
|
||||
}
|
@@ -1,54 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
@@ -1,46 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
@@ -1,65 +0,0 @@
|
||||
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()
|
||||
}
|
@@ -1,22 +0,0 @@
|
||||
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()
|
@@ -1,63 +0,0 @@
|
||||
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>
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
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()
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
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()
|
@@ -1,81 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
@@ -1,95 +0,0 @@
|
||||
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>
|
||||
)
|
@@ -1,10 +0,0 @@
|
||||
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?
|
||||
}
|
@@ -1,68 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
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
|
||||
}
|
@@ -1,63 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
@@ -1,15 +0,0 @@
|
||||
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?
|
||||
}
|
@@ -1,62 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
@@ -1,19 +0,0 @@
|
||||
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()
|
@@ -1,139 +0,0 @@
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user