Compare commits
338 Commits
test
...
eb18e2d009
Author | SHA1 | Date | |
---|---|---|---|
eb18e2d009 | |||
a27852ed44 | |||
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 |
@@ -39,10 +39,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -78,10 +75,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -148,10 +142,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(order.isActive.isTrue)
|
.where(order.isActive.isTrue)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
member.id,
|
member.id,
|
||||||
@@ -239,10 +230,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -263,10 +251,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -296,10 +281,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -319,10 +301,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -352,10 +331,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -375,10 +351,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -409,10 +382,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
|
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import java.time.LocalDateTime
|
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.FetchType
|
import javax.persistence.FetchType
|
||||||
import javax.persistence.JoinColumn
|
import javax.persistence.JoinColumn
|
||||||
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
|
|||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
data class CreatorSettlementRatio(
|
data class CreatorSettlementRatio(
|
||||||
var subsidy: Int,
|
val subsidy: Int,
|
||||||
var liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
var contentSettlementRatio: Int,
|
val contentSettlementRatio: Int,
|
||||||
var communitySettlementRatio: Int
|
val communitySettlementRatio: Int
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "member_id", nullable = false)
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
var member: Member? = null
|
var member: Member? = null
|
||||||
|
|
||||||
var deletedAt: LocalDateTime? = null
|
|
||||||
|
|
||||||
fun softDelete() {
|
|
||||||
this.deletedAt = LocalDateTime.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restore() {
|
|
||||||
this.deletedAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
|
|
||||||
this.subsidy = subsidy
|
|
||||||
this.liveSettlementRatio = live
|
|
||||||
this.contentSettlementRatio = content
|
|
||||||
this.communitySettlementRatio = community
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
@@ -28,14 +27,4 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
|
|||||||
limit = pageable.pageSize.toLong()
|
limit = pageable.pageSize.toLong()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@PostMapping("/update")
|
|
||||||
fun updateCreatorSettlementRatio(
|
|
||||||
@RequestBody request: CreateCreatorSettlementRatioRequest
|
|
||||||
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
|
|
||||||
|
|
||||||
@PostMapping("/delete/{memberId}")
|
|
||||||
fun deleteCreatorSettlementRatio(
|
|
||||||
@PathVariable memberId: Long
|
|
||||||
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
|
|
||||||
}
|
}
|
||||||
|
@@ -7,9 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
|
|
||||||
interface CreatorSettlementRatioRepository :
|
interface CreatorSettlementRatioRepository :
|
||||||
JpaRepository<CreatorSettlementRatio, Long>,
|
JpaRepository<CreatorSettlementRatio, Long>,
|
||||||
CreatorSettlementRatioQueryRepository {
|
CreatorSettlementRatioQueryRepository
|
||||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreatorSettlementRatioQueryRepository {
|
interface CreatorSettlementRatioQueryRepository {
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||||
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCreatorSettlementRatioItem(
|
QGetCreatorSettlementRatioItem(
|
||||||
member.id,
|
|
||||||
member.nickname,
|
member.nickname,
|
||||||
creatorSettlementRatio.subsidy,
|
creatorSettlementRatio.subsidy,
|
||||||
creatorSettlementRatio.liveSettlementRatio,
|
creatorSettlementRatio.liveSettlementRatio,
|
||||||
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
)
|
)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.innerJoin(creatorSettlementRatio.member, member)
|
.innerJoin(creatorSettlementRatio.member, member)
|
||||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
.orderBy(creatorSettlementRatio.id.asc())
|
.orderBy(creatorSettlementRatio.id.asc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(creatorSettlementRatio.id)
|
.select(creatorSettlementRatio.id)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
|
@@ -14,6 +14,8 @@ class CreatorSettlementRatioService(
|
|||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
|
val creatorSettlementRatio = request.toEntity()
|
||||||
|
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
|
||||||
@@ -21,52 +23,10 @@ class CreatorSettlementRatioService(
|
|||||||
throw SodaException("잘못된 크리에이터 입니다.")
|
throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
val existing = repository.findByMemberId(request.memberId)
|
|
||||||
if (existing != null) {
|
|
||||||
// revive if soft-deleted, then update values
|
|
||||||
existing.restore()
|
|
||||||
existing.updateValues(
|
|
||||||
request.subsidy,
|
|
||||||
request.liveSettlementRatio,
|
|
||||||
request.contentSettlementRatio,
|
|
||||||
request.communitySettlementRatio
|
|
||||||
)
|
|
||||||
repository.save(existing)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val creatorSettlementRatio = request.toEntity()
|
|
||||||
creatorSettlementRatio.member = creator
|
creatorSettlementRatio.member = creator
|
||||||
repository.save(creatorSettlementRatio)
|
repository.save(creatorSettlementRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
|
||||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
|
||||||
if (creator.role != MemberRole.CREATOR) {
|
|
||||||
throw SodaException("잘못된 크리에이터 입니다.")
|
|
||||||
}
|
|
||||||
val existing = repository.findByMemberId(request.memberId)
|
|
||||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
|
||||||
existing.restore()
|
|
||||||
existing.updateValues(
|
|
||||||
request.subsidy,
|
|
||||||
request.liveSettlementRatio,
|
|
||||||
request.contentSettlementRatio,
|
|
||||||
request.communitySettlementRatio
|
|
||||||
)
|
|
||||||
repository.save(existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun deleteCreatorSettlementRatio(memberId: Long) {
|
|
||||||
val existing = repository.findByMemberId(memberId)
|
|
||||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
|
||||||
existing.softDelete()
|
|
||||||
repository.save(existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||||
|
@@ -8,7 +8,6 @@ data class GetCreatorSettlementRatioResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||||
val memberId: Long,
|
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val subsidy: Int,
|
val subsidy: Int,
|
||||||
val liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
|
@@ -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()
|
|
||||||
)
|
|
||||||
}
|
|
@@ -3,11 +3,9 @@ package kr.co.vividnext.sodalive.admin.chat.character
|
|||||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
@@ -39,7 +37,6 @@ class AdminChatCharacterController(
|
|||||||
private val service: ChatCharacterService,
|
private val service: ChatCharacterService,
|
||||||
private val adminService: AdminChatCharacterService,
|
private val adminService: AdminChatCharacterService,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
private val originalWorkService: AdminOriginalWorkService,
|
|
||||||
|
|
||||||
@Value("\${weraser.api-key}")
|
@Value("\${weraser.api-key}")
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
@@ -71,26 +68,6 @@ class AdminChatCharacterController(
|
|||||||
ApiResponse.ok(response)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 검색(관리자)
|
|
||||||
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
|
|
||||||
* - 페이징 지원: page, size 파라미터 사용
|
|
||||||
*/
|
|
||||||
@GetMapping("/search")
|
|
||||||
fun searchCharacters(
|
|
||||||
@RequestParam("searchTerm") searchTerm: String,
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageable = adminService.createDefaultPageRequest(page, size)
|
|
||||||
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
|
|
||||||
val response = ChatCharacterSearchListPageResponse(
|
|
||||||
totalCount = resultPage.totalElements,
|
|
||||||
content = resultPage.content
|
|
||||||
)
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐릭터 상세 정보 조회 API
|
* 캐릭터 상세 정보 조회 API
|
||||||
*
|
*
|
||||||
@@ -160,11 +137,6 @@ class AdminChatCharacterController(
|
|||||||
chatCharacter.imagePath = imagePath
|
chatCharacter.imagePath = imagePath
|
||||||
service.saveChatCharacter(chatCharacter)
|
service.saveChatCharacter(chatCharacter)
|
||||||
|
|
||||||
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
|
||||||
if (request.originalWorkId != null) {
|
|
||||||
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
ApiResponse.ok(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,8 +247,7 @@ class AdminChatCharacterController(
|
|||||||
val hasDbOnlyChanges =
|
val hasDbOnlyChanges =
|
||||||
request.originalTitle != null ||
|
request.originalTitle != null ||
|
||||||
request.originalLink != null ||
|
request.originalLink != null ||
|
||||||
request.characterType != null ||
|
request.characterType != null
|
||||||
request.originalWorkId != null
|
|
||||||
|
|
||||||
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
||||||
throw SodaException("변경된 데이터가 없습니다.")
|
throw SodaException("변경된 데이터가 없습니다.")
|
||||||
@@ -315,12 +286,6 @@ class AdminChatCharacterController(
|
|||||||
request = request
|
request = request
|
||||||
)
|
)
|
||||||
|
|
||||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
|
||||||
if (request.originalWorkId != null) {
|
|
||||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
|
||||||
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
ApiResponse.ok(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -2,10 +2,6 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 캐릭터 상세 응답 DTO
|
|
||||||
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
|
|
||||||
*/
|
|
||||||
data class ChatCharacterDetailResponse(
|
data class ChatCharacterDetailResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val characterUUID: String,
|
val characterUUID: String,
|
||||||
@@ -28,8 +24,7 @@ data class ChatCharacterDetailResponse(
|
|||||||
val relationships: List<RelationshipResponse>,
|
val relationships: List<RelationshipResponse>,
|
||||||
val personalities: List<PersonalityResponse>,
|
val personalities: List<PersonalityResponse>,
|
||||||
val backgrounds: List<BackgroundResponse>,
|
val backgrounds: List<BackgroundResponse>,
|
||||||
val memories: List<MemoryResponse>,
|
val memories: List<MemoryResponse>
|
||||||
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||||
@@ -39,20 +34,6 @@ data class ChatCharacterDetailResponse(
|
|||||||
chatCharacter.imagePath ?: ""
|
chatCharacter.imagePath ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val ow = chatCharacter.originalWork
|
|
||||||
val originalWorkBrief = ow?.let {
|
|
||||||
val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) {
|
|
||||||
"$imageHost/${it.imagePath}"
|
|
||||||
} else {
|
|
||||||
it.imagePath
|
|
||||||
}
|
|
||||||
OriginalWorkBriefResponse(
|
|
||||||
id = it.id!!,
|
|
||||||
imageUrl = owImage,
|
|
||||||
title = it.title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChatCharacterDetailResponse(
|
return ChatCharacterDetailResponse(
|
||||||
id = chatCharacter.id!!,
|
id = chatCharacter.id!!,
|
||||||
characterUUID = chatCharacter.characterUUID,
|
characterUUID = chatCharacter.characterUUID,
|
||||||
@@ -90,8 +71,7 @@ data class ChatCharacterDetailResponse(
|
|||||||
},
|
},
|
||||||
memories = chatCharacter.memories.map {
|
memories = chatCharacter.memories.map {
|
||||||
MemoryResponse(it.title, it.content, it.emotion)
|
MemoryResponse(it.title, it.content, it.emotion)
|
||||||
},
|
}
|
||||||
originalWork = originalWorkBrief
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,12 +101,3 @@ data class RelationshipResponse(
|
|||||||
val relationshipType: String,
|
val relationshipType: String,
|
||||||
val currentStatus: String
|
val currentStatus: String
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
|
|
||||||
*/
|
|
||||||
data class OriginalWorkBriefResponse(
|
|
||||||
val id: Long,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val title: String
|
|
||||||
)
|
|
||||||
|
@@ -40,7 +40,6 @@ data class ChatCharacterRegisterRequest(
|
|||||||
@JsonProperty("appearance") val appearance: String?,
|
@JsonProperty("appearance") val appearance: String?,
|
||||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
|
||||||
@JsonProperty("characterType") val characterType: String? = null,
|
@JsonProperty("characterType") val characterType: String? = null,
|
||||||
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
||||||
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
||||||
@@ -76,7 +75,6 @@ data class ChatCharacterUpdateRequest(
|
|||||||
@JsonProperty("appearance") val appearance: String? = null,
|
@JsonProperty("appearance") val appearance: String? = null,
|
||||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
|
||||||
@JsonProperty("characterType") val characterType: String? = null,
|
@JsonProperty("characterType") val characterType: String? = null,
|
||||||
@JsonProperty("isActive") val isActive: Boolean? = null,
|
@JsonProperty("isActive") val isActive: Boolean? = null,
|
||||||
@JsonProperty("tags") val tags: List<String>? = null,
|
@JsonProperty("tags") val tags: List<String>? = null,
|
||||||
|
@@ -1,9 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 검색 결과 페이지 응답 DTO
|
|
||||||
*/
|
|
||||||
data class ChatCharacterSearchListPageResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<ChatCharacterListResponse>
|
|
||||||
)
|
|
@@ -3,16 +3,16 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 원작 연결된 캐릭터 결과 응답 DTO
|
* 캐릭터 검색 결과 응답 DTO
|
||||||
*/
|
*/
|
||||||
data class OriginalWorkChatCharacterResponse(
|
data class ChatCharacterSearchResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val imagePath: String?
|
val imagePath: String?
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
|
fun from(character: ChatCharacter, imageHost: String): ChatCharacterSearchResponse {
|
||||||
return OriginalWorkChatCharacterResponse(
|
return ChatCharacterSearchResponse(
|
||||||
id = character.id!!,
|
id = character.id!!,
|
||||||
name = character.name,
|
name = character.name,
|
||||||
imagePath = character.imagePath?.let { "$imageHost/$it" }
|
imagePath = character.imagePath?.let { "$imageHost/$it" }
|
||||||
@@ -22,9 +22,9 @@ data class OriginalWorkChatCharacterResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 원작 연결된 캐릭터 결과 페이지 응답 DTO
|
* 캐릭터 검색 결과 페이지 응답 DTO
|
||||||
*/
|
*/
|
||||||
data class OriginalWorkChatCharacterListPageResponse(
|
data class ChatCharacterSearchListPageResponse(
|
||||||
val totalCount: Long,
|
val totalCount: Long,
|
||||||
val content: List<OriginalWorkChatCharacterResponse>
|
val content: List<ChatCharacterSearchResponse>
|
||||||
)
|
)
|
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.chat.character.service
|
|||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchResponse
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
@@ -64,15 +65,20 @@ class AdminChatCharacterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
|
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반)
|
||||||
|
*
|
||||||
|
* @param searchTerm 검색어
|
||||||
|
* @param pageable 페이징 정보
|
||||||
|
* @param imageHost 이미지 호스트 URL
|
||||||
|
* @return 검색된 캐릭터 목록 (페이징)
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun searchCharacters(
|
fun searchCharacters(
|
||||||
searchTerm: String,
|
searchTerm: String,
|
||||||
pageable: Pageable,
|
pageable: Pageable,
|
||||||
imageHost: String = ""
|
imageHost: String = ""
|
||||||
): Page<ChatCharacterListResponse> {
|
): Page<ChatCharacterSearchResponse> {
|
||||||
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
|
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
|
||||||
return characters.map { ChatCharacterListResponse.from(it, imageHost) }
|
return characters.map { ChatCharacterSearchResponse.from(it, imageHost) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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,6 +1,5 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character
|
package kr.co.vividnext.sodalive.chat.character
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import javax.persistence.CascadeType
|
import javax.persistence.CascadeType
|
||||||
import javax.persistence.Column
|
import javax.persistence.Column
|
||||||
@@ -8,8 +7,6 @@ import javax.persistence.Entity
|
|||||||
import javax.persistence.EnumType
|
import javax.persistence.EnumType
|
||||||
import javax.persistence.Enumerated
|
import javax.persistence.Enumerated
|
||||||
import javax.persistence.FetchType
|
import javax.persistence.FetchType
|
||||||
import javax.persistence.JoinColumn
|
|
||||||
import javax.persistence.ManyToOne
|
|
||||||
import javax.persistence.OneToMany
|
import javax.persistence.OneToMany
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -47,19 +44,14 @@ class ChatCharacter(
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
var appearance: String? = null,
|
var appearance: String? = null,
|
||||||
|
|
||||||
// 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
// 원작 (optional)
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var originalTitle: String? = null,
|
var originalTitle: String? = null,
|
||||||
|
|
||||||
// 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
// 원작 링크 (optional)
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var originalLink: String? = null,
|
var originalLink: String? = null,
|
||||||
|
|
||||||
// 연관 원작 (한 캐릭터는 하나의 원작에만 속함)
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "original_work_id")
|
|
||||||
var originalWork: OriginalWork? = null,
|
|
||||||
|
|
||||||
// 캐릭터 유형
|
// 캐릭터 유형
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
@@ -22,7 +22,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -68,11 +67,16 @@ class ChatCharacterController(
|
|||||||
// 인기 캐릭터 조회
|
// 인기 캐릭터 조회
|
||||||
val popularCharacters = service.getPopularCharacters()
|
val popularCharacters = service.getPopularCharacters()
|
||||||
|
|
||||||
// 최근 등록된 캐릭터 리스트 조회
|
// 최신 캐릭터 조회 (최대 10개)
|
||||||
val newCharacters = service.getRecentCharactersPage(
|
val newCharacters = service.getNewCharacters(50)
|
||||||
page = 0,
|
.map {
|
||||||
size = 50
|
Character(
|
||||||
).content
|
characterId = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
||||||
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
||||||
@@ -178,19 +182,4 @@ class ChatCharacterController(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 등록된 캐릭터 전체보기
|
|
||||||
* - 기준: 2주 이내 등록된 캐릭터만 페이징 조회
|
|
||||||
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
|
|
||||||
*/
|
|
||||||
@GetMapping("/recent")
|
|
||||||
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
|
|
||||||
ApiResponse.ok(
|
|
||||||
service.getRecentCharactersPage(
|
|
||||||
page = page ?: 0,
|
|
||||||
size = 20
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.dto
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 등록된 캐릭터 전체보기 페이지 응답 DTO
|
|
||||||
*/
|
|
||||||
data class RecentCharactersResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<Character>
|
|
||||||
)
|
|
@@ -10,29 +10,17 @@ import org.springframework.stereotype.Repository
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||||
|
fun findByCharacterUUID(characterUUID: String): ChatCharacter?
|
||||||
fun findByName(name: String): ChatCharacter?
|
fun findByName(name: String): ChatCharacter?
|
||||||
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
||||||
fun findByOriginalWorkIdAndIsActiveTrue(originalWorkId: Long, pageable: Pageable): Page<ChatCharacter>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회
|
* 활성화된 캐릭터를 생성일 기준 내림차순으로 조회
|
||||||
*/
|
*/
|
||||||
@Query(
|
fun findByIsActiveTrueOrderByCreatedAtDesc(pageable: Pageable): List<ChatCharacter>
|
||||||
"""
|
|
||||||
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 이상) 활성 캐릭터 개수
|
* 이름, 설명, MBTI, 태그로 캐릭터 검색
|
||||||
*/
|
|
||||||
fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이름, 설명, MBTI, 태그로 캐릭터 검색 - 페이징
|
|
||||||
*/
|
*/
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
|
@@ -12,7 +12,6 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby
|
|||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
@@ -21,10 +20,8 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepo
|
|||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.cache.annotation.Cacheable
|
import org.springframework.cache.annotation.Cacheable
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.data.domain.Sort
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ChatCharacterService(
|
class ChatCharacterService(
|
||||||
@@ -45,10 +42,10 @@ class ChatCharacterService(
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@Cacheable(
|
@Cacheable(
|
||||||
cacheNames = ["popularCharacters_24h"],
|
cacheNames = ["popularCharacters_24h"],
|
||||||
key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-character').cacheKey"
|
key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-chat-character').cacheKey"
|
||||||
)
|
)
|
||||||
fun getPopularCharacters(limit: Long = 20): List<Character> {
|
fun getPopularCharacters(limit: Long = 20): List<Character> {
|
||||||
val window = RankingWindowCalculator.now("popular-character")
|
val window = RankingWindowCalculator.now("popular-chat-character")
|
||||||
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
|
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
|
||||||
val list = loadCharactersInOrder(topIds)
|
val list = loadCharactersInOrder(topIds)
|
||||||
return list.map {
|
return list.map {
|
||||||
@@ -69,62 +66,11 @@ class ChatCharacterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최근 등록된 캐릭터 전체보기 (페이징) - 전체 개수 포함
|
* 최근 등록된 캐릭터 목록 조회 (최대 10개)
|
||||||
* - 기준: 현재 시각 기준 2주 이내 생성된 활성 캐릭터
|
|
||||||
* - 2주 이내 캐릭터가 0개라면: totalCount=20, 첫 페이지는 최근 등록 활성 캐릭터 20개, 그 외 페이지는 빈 리스트
|
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getRecentCharactersPage(page: Int = 0, size: Int = 20): RecentCharactersResponse {
|
fun getNewCharacters(limit: Int = 10): List<ChatCharacter> {
|
||||||
val safePage = if (page < 0) 0 else page
|
return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit))
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -20,27 +20,19 @@ object RankingWindowCalculator {
|
|||||||
private const val BOUNDARY_HOUR = 20 // 20:00:00 UTC
|
private const val BOUNDARY_HOUR = 20 // 20:00:00 UTC
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun now(prefix: String = "popular-character"): RankingWindow {
|
fun now(prefix: String = "popular-chat-character"): RankingWindow {
|
||||||
val now = ZonedDateTime.now(ZONE)
|
val now = ZonedDateTime.now(ZONE)
|
||||||
val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE)
|
val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE)
|
||||||
|
val (start, endExclusive, nextBoundary) = if (now.isBefore(todayBoundary)) {
|
||||||
// 일일 순위는 "전날" 완료 구간을 보여주기 위해, 언제든 직전 경계까지만 집계한다.
|
val start = todayBoundary.minusDays(1)
|
||||||
// 예) 2025-09-14 20:00:00 직후에도 [2025-09-13 20:00, 2025-09-14 20:00) 윈도우를 사용
|
Triple(start, todayBoundary, todayBoundary)
|
||||||
val lastBoundary = if (now.isBefore(todayBoundary)) {
|
|
||||||
// 아직 오늘 20:00 이전이면, 직전 경계는 어제 20:00
|
|
||||||
todayBoundary.minusDays(1)
|
|
||||||
} else {
|
} else {
|
||||||
// 오늘 20:00을 지났거나 같으면, 직전 경계는 오늘 20:00
|
val next = todayBoundary.plusDays(1)
|
||||||
todayBoundary
|
Triple(todayBoundary, next, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
val start = lastBoundary.minusDays(1)
|
|
||||||
val endExclusive = lastBoundary
|
|
||||||
|
|
||||||
val windowStart = start.toInstant()
|
val windowStart = start.toInstant()
|
||||||
val windowEnd = endExclusive.minusSeconds(1).toInstant() // [start, end]
|
val windowEnd = endExclusive.minusNanos(1).toInstant() // [start, end]
|
||||||
val cacheKey = "$prefix:${windowStart.epochSecond}"
|
val cacheKey = "$prefix:${windowStart.epochSecond}"
|
||||||
// nextBoundary 필드는 기존 시그니처 유지를 위해 endExclusive(=lastBoundary)를 그대로 전달한다.
|
return RankingWindow(windowStart, windowEnd, nextBoundary.toInstant(), cacheKey)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -95,7 +95,6 @@ class SecurityConfig(
|
|||||||
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
.and()
|
.and()
|
||||||
.build()
|
.build()
|
||||||
|
@@ -53,10 +53,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -122,10 +119,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -202,10 +196,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
audioContent.member.id.eq(memberId)
|
audioContent.member.id.eq(memberId)
|
||||||
.and(order.isActive.isTrue)
|
.and(order.isActive.isTrue)
|
||||||
@@ -327,10 +318,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
|
Reference in New Issue
Block a user