Compare commits
406 Commits
83028f7817
...
main
Author | SHA1 | Date | |
---|---|---|---|
aa24de0a5a | |||
12cdd25be7 | |||
59700493eb | |||
e5937d573a | |||
88c3a84972 | |||
db0d3a6ef3 | |||
3d29d27441 | |||
b5f66603bd | |||
6da86e12bd | |||
976eeaa443 | |||
25d1d813f1 | |||
778f0c3ba2 | |||
38c50a4f8a | |||
9049022a74 | |||
c497f321bb | |||
7b6f3a7a5f | |||
84c0768c8b | |||
53e9678efa | |||
efb8d8115f | |||
e4f547fa92 | |||
41183b4648 | |||
36e20bf0d1 | |||
0308e9ad83 | |||
06c0374f16 | |||
c5bc610e2f | |||
a86a24ca34 | |||
cb2e3ea581 | |||
42eaf1d5e3 | |||
02ef706fc2 | |||
085b217abb | |||
0866e0972a | |||
4b13265737 | |||
79cd2b8123 | |||
8cc9641bbf | |||
32935aed88 | |||
c72adbfc4b | |||
bc378cc619 | |||
6327a5d2bf | |||
2ab2a04748 | |||
fb0a9e98a1 | |||
e45fe1bf10 | |||
3d852a8356 | |||
b244944f41 | |||
3c7ba669e2 | |||
81e7e7129c | |||
d7ad110b9e | |||
0c17ea2dcd | |||
78ff13a654 | |||
863c285049 | |||
a3d74c0b57 | |||
9016a72046 | |||
3c32614d1c | |||
51988471cf | |||
8990bd0722 | |||
aab2417976 | |||
1bd6f8da4e | |||
22bd1bf042 | |||
d536a65fb4 | |||
03149a637d | |||
bc6c05b3ea | |||
59ca353b25 | |||
6bc65ec412 | |||
97e95b51ab | |||
b69756ef81 | |||
a6dfa81ba6 | |||
dad517a953 | |||
eb2d093b02 | |||
67186bba55 | |||
1a3a9149a2 | |||
edeecad2ce | |||
387f5388d9 | |||
adcaa0a5fd | |||
47b2c1cb93 | |||
ce120a6d5d | |||
7f3589dcfb | |||
b134c28c10 | |||
41c8d0367d | |||
3b148d549e | |||
b6c96af8a2 | |||
4904625488 | |||
08b5fd23ab | |||
0574f4f629 | |||
4adc3e127c | |||
dd0a1c2293 | |||
a07407417c | |||
e33e3b43b7 | |||
634bf759ca | |||
0ed29c6097 | |||
b752434fbb | |||
eec63cc7b2 | |||
3dc9dd1f35 | |||
88e287067b | |||
eb18e2d009 | |||
27a3f450ef | |||
58a46a09c3 | |||
83a1316a64 | |||
f05f146c89 | |||
a27852ed44 | |||
3782062f4a | |||
fd83abb46c | |||
a9d1b9f4a6 | |||
ad69dad725 | |||
2f55303d16 | |||
3a9128a894 | |||
def6296d4d | |||
034472defa | |||
550e4ac9ce | |||
d26e0a89f6 | |||
6767afdd35 | |||
a58de0cf92 | |||
df93f0e0ce | |||
0b54b126db | |||
a94cf8dad9 | |||
2c3e12a42c | |||
c4dbdc1b8e | |||
42ed4692af | |||
258943535c | |||
0347d767f0 | |||
48b0190242 | |||
15d0952de8 | |||
84ebc1762b | |||
a096b16945 | |||
37ac52116a | |||
fcb68be006 | |||
048c48d754 | |||
6ecac8d331 | |||
8b1dd7cb95 | |||
5a58fe9077 | |||
12574dbe46 | |||
b3e7c00232 | |||
692e060f6d | |||
2ac0a5f896 | |||
f8be99547a | |||
7dd585c3dd | |||
7355949c1e | |||
539b9fb2b2 | |||
99386c6d53 | |||
abbd73ac00 | |||
4bee95c8a6 | |||
090fc81829 | |||
75100cacec | |||
13fd262c94 | |||
8451cdfb80 | |||
c8841856c0 | |||
2a30b28e43 | |||
dd6849b840 | |||
ca27903e45 | |||
aeab6eddc2 | |||
1c0d40aed9 | |||
1444afaae2 | |||
a05bc369b7 | |||
6c7f411869 | |||
f61c45e89a | |||
27ed9f61d0 | |||
df77e31043 | |||
2d65bdb8ee | |||
4966aaeda9 | |||
28bd700b03 | |||
f2ca013b96 | |||
6cf7dabaef | |||
e6d63592ec | |||
3ac4ebded3 | |||
6f9fc659f3 | |||
005bb0ea2e | |||
80a0543e10 | |||
5d42805514 | |||
1b7ae8a2c5 | |||
168b0b13fb | |||
d99fcba468 | |||
147b8b0a42 | |||
eed755fd11 | |||
74a612704e | |||
8defc56d1e | |||
1db20d118d | |||
7a70a770bb | |||
cc9e4f974f | |||
2965b8fea0 | |||
00c617ec2e | |||
01ef738d31 | |||
423cbe7315 | |||
afb003c397 | |||
2dc5a29220 | |||
c525ec0330 | |||
735f1e26df | |||
5129400a29 | |||
a6a01aaa37 | |||
b819df9656 | |||
5d1c5fcc44 | |||
ebad3b31b7 | |||
3e9f7f9e29 | |||
4b3463e97c | |||
002f2c2834 | |||
1509ee0729 | |||
830e41dfa3 | |||
4d1f84cc5c | |||
1bafbed17c | |||
694d9cd05a | |||
60172ae84d | |||
7e7a1122fa | |||
a1533c8e98 | |||
b0a6fc6498 | |||
74ed7b20ba | |||
206c25985a | |||
0001697274 | |||
add21c45c5 | |||
ef8458c7a3 | |||
81f972edc1 | |||
c729a402aa | |||
2335050834 | |||
6340ed27cf | |||
618f80fddc | |||
45b6c8db96 | |||
5132a6b9fa | |||
de6642b675 | |||
3b42399726 | |||
689f9fe48f | |||
73038222cc | |||
c7925c1706 | |||
2659adb7a9 | |||
be59bd7e89 | |||
fcb2ca1917 | |||
51ce143fc2 | |||
804e139385 | |||
f0fc996426 | |||
89eb11f808 | |||
efdb485a3b | |||
30d89987a4 | |||
3d695069a2 | |||
e068b57062 | |||
811810cd36 | |||
c90df4b02b | |||
7c1082f833 | |||
800b8d3216 | |||
ab877beae1 | |||
046c163e6f | |||
7959d3e5ed | |||
8e877a6366 | |||
d18c19dd35 | |||
a99260209b | |||
2192ddc8fa | |||
741a1282a3 | |||
1a6a331ad8 | |||
1ba63e2cab | |||
5696240e03 | |||
885243a5b0 | |||
a849d00c7f | |||
d04b44c931 | |||
a3aad9d2c9 | |||
d98268f809 | |||
34440e9ba3 | |||
d1c889e5f2 | |||
1e29573ef7 | |||
55da259510 | |||
cc2f533dc6 | |||
4436e6f20a | |||
32b0c19f9d | |||
3cedd36e15 | |||
ecbe9b2e93 | |||
9ad6b6ea48 | |||
0d2daf4d2c | |||
edf16a6021 | |||
9af2d768e8 | |||
7551a19b34 | |||
f59f45d9a4 | |||
81e82ad731 | |||
ca870392e2 | |||
a7e167a95f | |||
a49b82a7c2 | |||
704ad12ccf | |||
ab9fd2bc16 | |||
69a63a77d3 | |||
da7e4c2156 | |||
a4b5185f6b | |||
22fc8b22b8 | |||
a8da17162a | |||
5677824cde | |||
f13c221fd6 | |||
4ffa9363a8 | |||
6d2f48f86d | |||
8e01ced1f5 | |||
e8f1bc09f9 | |||
640f5ce6f5 | |||
c0be30027c | |||
832586bd41 | |||
1a774937b3 | |||
d1a936d55b | |||
e508dafb34 | |||
8335717741 | |||
16a2b82ffd | |||
8db5c6443d | |||
9ed717fb95 | |||
dcd4497315 | |||
54c0322398 | |||
e3c33c71a0 | |||
dc97eaa835 | |||
7055bb9872 | |||
dcbe57806c | |||
fd1b17e356 | |||
28427a873a | |||
5bdb101b52 | |||
97b2b38f8e | |||
2268f4a3fc | |||
9eff828249 | |||
b14438cc15 | |||
3275ac5036 | |||
b27d3bd5c6 | |||
e049e0fa3c | |||
03ebc9cfe9 | |||
caee89cf53 | |||
24841b9850 | |||
e67b798714 | |||
dc13053825 | |||
af352256e9 | |||
d35a3d1a8c | |||
b92810efd2 | |||
fcbd809691 | |||
60c4e0b528 | |||
d3ec13e6c0 | |||
a36d9f02d8 | |||
d6db862c9d | |||
56542a7bf1 | |||
36b8e8169e | |||
b102241efd | |||
f36010fefa | |||
aa23d6d50f | |||
6df043dfac | |||
fe84292483 | |||
0f48c71837 | |||
107e8fce55 | |||
3079998a5d | |||
e2d0ae558a | |||
1bca1b27ed | |||
6fc372c898 | |||
ddcd54d3b9 | |||
eb8c8c14e8 | |||
affc0cc235 | |||
f23251f5bb | |||
84f33d1bc2 | |||
73c9a90ae3 | |||
c4e1709b99 | |||
ced35af66d | |||
b915ace6ff | |||
e7a5fd5819 | |||
2fd7419bdd | |||
4bde03643c | |||
fd510710d9 | |||
8a924bd5be | |||
1bc52b56af | |||
73edc0515f | |||
9c33fd93f7 | |||
7870f8ea78 | |||
3c087bc275 | |||
27c5b991cf | |||
8a937f01a4 | |||
3940282ed8 | |||
ca704f38b9 | |||
6ff044e4ab | |||
fa98138541 | |||
cb7917dc26 | |||
58d066af0a | |||
e2daff6463 | |||
7c3b7cffc2 | |||
775391f590 | |||
57adfec490 | |||
24e62c1885 | |||
a70b5d89ec | |||
761d56f4bd | |||
e759f62b5f | |||
9e2d031b5d | |||
b9cb8ad4a8 | |||
c1d4c1ff1d | |||
971683a81e | |||
51dae0f02c | |||
e2c70de2e0 | |||
d94418067f | |||
1cb2ee77b5 | |||
336d3c9434 | |||
8ad13c289e | |||
7649ce6e52 | |||
7577f48a09 | |||
5759a51017 | |||
0251906964 | |||
dd5c121f1f | |||
2723a5f134 | |||
cae3a92a66 | |||
c3c60605fd | |||
562550880c | |||
238f704b22 | |||
a9c68f9971 | |||
5639d8ac8e | |||
d822a4a8ac | |||
e52c914000 | |||
a301f854ba | |||
602d9625e2 | |||
5598bca8d3 | |||
1bbaf8f7b7 | |||
3bb2753607 | |||
08848c783d | |||
9aac591591 | |||
6e229af790 | |||
ce8cc3eb29 | |||
198ecddc89 | |||
ffa8e5aebb | |||
ae439b7e64 | |||
cbbfe014cc | |||
3f1101ff73 |
@@ -7,5 +7,5 @@ indent_size = 4
|
|||||||
indent_style = space
|
indent_style = space
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 120
|
max_line_length = 130
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -323,4 +323,7 @@ gradle-app.setting
|
|||||||
### Gradle Patch ###
|
### Gradle Patch ###
|
||||||
**/build/
|
**/build/
|
||||||
|
|
||||||
|
.kiro/
|
||||||
|
.junie
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
||||||
|
@@ -65,9 +65,14 @@ dependencies {
|
|||||||
// android publisher
|
// android publisher
|
||||||
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0")
|
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0")
|
||||||
|
|
||||||
|
implementation("com.google.api-client:google-api-client:1.32.1")
|
||||||
|
|
||||||
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
||||||
|
|
||||||
|
// file mimetype check
|
||||||
|
implementation("org.apache.tika:tika-core:3.2.0")
|
||||||
|
|
||||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
runtimeOnly("com.h2database:h2")
|
runtimeOnly("com.h2database:h2")
|
||||||
runtimeOnly("com.mysql:mysql-connector-j")
|
runtimeOnly("com.mysql:mysql-connector-j")
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.calculate
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
import com.querydsl.core.types.dsl.DateTimePath
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.core.types.dsl.StringTemplate
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
@@ -38,7 +39,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -51,6 +55,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
|
|
||||||
fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> {
|
fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> {
|
||||||
val orderFormattedDate = getFormattedDate(order.createdAt)
|
val orderFormattedDate = getFormattedDate(order.createdAt)
|
||||||
|
val pointGroup = CaseBuilder()
|
||||||
|
.`when`(order.point.loe(0)).then(0)
|
||||||
|
.otherwise(1)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCalculateContentQueryData(
|
QGetCalculateContentQueryData(
|
||||||
@@ -62,6 +70,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
order.can,
|
order.can,
|
||||||
order.id.count(),
|
order.id.count(),
|
||||||
order.can.sum(),
|
order.can.sum(),
|
||||||
|
order.point.sum(),
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -69,7 +78,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -80,6 +92,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
order.type,
|
order.type,
|
||||||
orderFormattedDate,
|
orderFormattedDate,
|
||||||
order.can,
|
order.can,
|
||||||
|
pointGroup,
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
)
|
)
|
||||||
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
||||||
@@ -113,6 +126,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getCumulativeSalesByContent(offset: Long, limit: Long): List<GetCumulativeSalesByContentQueryData> {
|
fun getCumulativeSalesByContent(offset: Long, limit: Long): List<GetCumulativeSalesByContentQueryData> {
|
||||||
|
val pointGroup = CaseBuilder()
|
||||||
|
.`when`(order.point.loe(0)).then(0)
|
||||||
|
.otherwise(1)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCumulativeSalesByContentQueryData(
|
QGetCumulativeSalesByContentQueryData(
|
||||||
@@ -123,6 +140,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
order.can,
|
order.can,
|
||||||
order.id.count(),
|
order.id.count(),
|
||||||
order.can.sum(),
|
order.can.sum(),
|
||||||
|
order.point.sum(),
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -130,9 +148,19 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(order.isActive.isTrue)
|
.where(order.isActive.isTrue)
|
||||||
.groupBy(member.id, audioContent.id, order.type, order.can)
|
.groupBy(
|
||||||
|
member.id,
|
||||||
|
audioContent.id,
|
||||||
|
order.type,
|
||||||
|
order.can,
|
||||||
|
pointGroup,
|
||||||
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
|
)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.orderBy(member.id.desc(), audioContent.id.desc())
|
.orderBy(member.id.desc(), audioContent.id.desc())
|
||||||
@@ -211,7 +239,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -232,7 +263,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -262,7 +296,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -282,7 +319,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -312,7 +352,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -332,7 +375,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -363,7 +409,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
.on(
|
||||||
|
member.id.eq(creatorSettlementRatio.member.id)
|
||||||
|
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
|
@@ -22,11 +22,15 @@ data class GetCalculateContentQueryData @QueryProjection constructor(
|
|||||||
val numberOfPeople: Long,
|
val numberOfPeople: Long,
|
||||||
// 합계
|
// 합계
|
||||||
val totalCan: Int,
|
val totalCan: Int,
|
||||||
|
// 포인트
|
||||||
|
val totalPoint: Int,
|
||||||
// 정산비율
|
// 정산비율
|
||||||
val settlementRatio: Int?
|
val settlementRatio: Int?
|
||||||
) {
|
) {
|
||||||
fun toGetCalculateContentResponse(): GetCalculateContentResponse {
|
fun toGetCalculateContentResponse(): GetCalculateContentResponse {
|
||||||
val orderTypeStr = if (orderType == OrderType.RENTAL) {
|
val orderTypeStr = if (totalPoint > 0) {
|
||||||
|
"포인트"
|
||||||
|
} else if (orderType == OrderType.RENTAL) {
|
||||||
"대여"
|
"대여"
|
||||||
} else {
|
} else {
|
||||||
"소장"
|
"소장"
|
||||||
|
@@ -21,11 +21,15 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor(
|
|||||||
val numberOfPeople: Long,
|
val numberOfPeople: Long,
|
||||||
// 합계
|
// 합계
|
||||||
val totalCan: Int,
|
val totalCan: Int,
|
||||||
|
// 포인트
|
||||||
|
val totalPoint: Int,
|
||||||
// 정산비율
|
// 정산비율
|
||||||
val settlementRatio: Int?
|
val settlementRatio: Int?
|
||||||
) {
|
) {
|
||||||
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
|
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
|
||||||
val orderTypeStr = if (orderType == OrderType.RENTAL) {
|
val orderTypeStr = if (totalPoint > 0) {
|
||||||
|
"포인트"
|
||||||
|
} else if (orderType == OrderType.RENTAL) {
|
||||||
"대여"
|
"대여"
|
||||||
} else {
|
} else {
|
||||||
"소장"
|
"소장"
|
||||||
|
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import java.time.LocalDateTime
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.FetchType
|
import javax.persistence.FetchType
|
||||||
import javax.persistence.JoinColumn
|
import javax.persistence.JoinColumn
|
||||||
@@ -9,12 +10,29 @@ import javax.persistence.OneToOne
|
|||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
data class CreatorSettlementRatio(
|
data class CreatorSettlementRatio(
|
||||||
val subsidy: Int,
|
var subsidy: Int,
|
||||||
val liveSettlementRatio: Int,
|
var liveSettlementRatio: Int,
|
||||||
val contentSettlementRatio: Int,
|
var contentSettlementRatio: Int,
|
||||||
val communitySettlementRatio: Int
|
var communitySettlementRatio: Int
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "member_id", nullable = false)
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
var member: Member? = null
|
var member: Member? = null
|
||||||
|
|
||||||
|
var deletedAt: LocalDateTime? = null
|
||||||
|
|
||||||
|
fun softDelete() {
|
||||||
|
this.deletedAt = LocalDateTime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restore() {
|
||||||
|
this.deletedAt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
|
||||||
|
this.subsidy = subsidy
|
||||||
|
this.liveSettlementRatio = live
|
||||||
|
this.contentSettlementRatio = content
|
||||||
|
this.communitySettlementRatio = community
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
@@ -27,4 +28,14 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
|
|||||||
limit = pageable.pageSize.toLong()
|
limit = pageable.pageSize.toLong()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@PostMapping("/update")
|
||||||
|
fun updateCreatorSettlementRatio(
|
||||||
|
@RequestBody request: CreateCreatorSettlementRatioRequest
|
||||||
|
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
|
||||||
|
|
||||||
|
@PostMapping("/delete/{memberId}")
|
||||||
|
fun deleteCreatorSettlementRatio(
|
||||||
|
@PathVariable memberId: Long
|
||||||
|
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
|
|
||||||
interface CreatorSettlementRatioRepository :
|
interface CreatorSettlementRatioRepository :
|
||||||
JpaRepository<CreatorSettlementRatio, Long>,
|
JpaRepository<CreatorSettlementRatio, Long>,
|
||||||
CreatorSettlementRatioQueryRepository
|
CreatorSettlementRatioQueryRepository {
|
||||||
|
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
||||||
|
}
|
||||||
|
|
||||||
interface CreatorSettlementRatioQueryRepository {
|
interface CreatorSettlementRatioQueryRepository {
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||||
@@ -21,6 +23,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCreatorSettlementRatioItem(
|
QGetCreatorSettlementRatioItem(
|
||||||
|
member.id,
|
||||||
member.nickname,
|
member.nickname,
|
||||||
creatorSettlementRatio.subsidy,
|
creatorSettlementRatio.subsidy,
|
||||||
creatorSettlementRatio.liveSettlementRatio,
|
creatorSettlementRatio.liveSettlementRatio,
|
||||||
@@ -30,6 +33,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
)
|
)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.innerJoin(creatorSettlementRatio.member, member)
|
.innerJoin(creatorSettlementRatio.member, member)
|
||||||
|
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||||
.orderBy(creatorSettlementRatio.id.asc())
|
.orderBy(creatorSettlementRatio.id.asc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -40,6 +44,7 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(creatorSettlementRatio.id)
|
.select(creatorSettlementRatio.id)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
|
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
|
@@ -14,8 +14,6 @@ class CreatorSettlementRatioService(
|
|||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
val creatorSettlementRatio = request.toEntity()
|
|
||||||
|
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
|
||||||
@@ -23,10 +21,52 @@ class CreatorSettlementRatioService(
|
|||||||
throw SodaException("잘못된 크리에이터 입니다.")
|
throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val existing = repository.findByMemberId(request.memberId)
|
||||||
|
if (existing != null) {
|
||||||
|
// revive if soft-deleted, then update values
|
||||||
|
existing.restore()
|
||||||
|
existing.updateValues(
|
||||||
|
request.subsidy,
|
||||||
|
request.liveSettlementRatio,
|
||||||
|
request.contentSettlementRatio,
|
||||||
|
request.communitySettlementRatio
|
||||||
|
)
|
||||||
|
repository.save(existing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val creatorSettlementRatio = request.toEntity()
|
||||||
creatorSettlementRatio.member = creator
|
creatorSettlementRatio.member = creator
|
||||||
repository.save(creatorSettlementRatio)
|
repository.save(creatorSettlementRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
|
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
if (creator.role != MemberRole.CREATOR) {
|
||||||
|
throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
}
|
||||||
|
val existing = repository.findByMemberId(request.memberId)
|
||||||
|
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
||||||
|
existing.restore()
|
||||||
|
existing.updateValues(
|
||||||
|
request.subsidy,
|
||||||
|
request.liveSettlementRatio,
|
||||||
|
request.contentSettlementRatio,
|
||||||
|
request.communitySettlementRatio
|
||||||
|
)
|
||||||
|
repository.save(existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteCreatorSettlementRatio(memberId: Long) {
|
||||||
|
val existing = repository.findByMemberId(memberId)
|
||||||
|
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
||||||
|
existing.softDelete()
|
||||||
|
repository.save(existing)
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||||
|
@@ -8,6 +8,7 @@ data class GetCreatorSettlementRatioResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||||
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val subsidy: Int,
|
val subsidy: Int,
|
||||||
val liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.CanResponse
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
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.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
|
||||||
@@ -13,6 +15,11 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
@RequestMapping("/admin/can")
|
@RequestMapping("/admin/can")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
class AdminCanController(private val service: AdminCanService) {
|
class AdminCanController(private val service: AdminCanService) {
|
||||||
|
@GetMapping
|
||||||
|
fun getCans(): ApiResponse<List<CanResponse>> {
|
||||||
|
return ApiResponse.ok(service.getCans())
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
||||||
|
|
||||||
|
@@ -1,6 +1,38 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.can.Can
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
|
import kr.co.vividnext.sodalive.can.CanResponse
|
||||||
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||||
|
import kr.co.vividnext.sodalive.can.QCanResponse
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
interface AdminCanRepository : JpaRepository<Can, Long>
|
interface AdminCanRepository : JpaRepository<Can, Long>, AdminCanQueryRepository
|
||||||
|
|
||||||
|
interface AdminCanQueryRepository {
|
||||||
|
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AdminCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminCanQueryRepository {
|
||||||
|
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QCanResponse(
|
||||||
|
can1.id,
|
||||||
|
can1.title,
|
||||||
|
can1.can,
|
||||||
|
can1.rewardCan,
|
||||||
|
can1.price.intValue(),
|
||||||
|
can1.currency,
|
||||||
|
can1.price.stringValue()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(can1)
|
||||||
|
.where(can1.status.eq(status))
|
||||||
|
.orderBy(can1.currency.asc(), can1.price.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.admin.can
|
|||||||
import kr.co.vividnext.sodalive.can.Can
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
import kr.co.vividnext.sodalive.can.CanStatus
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class AdminCanRequest(
|
data class AdminCanRequest(
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val rewardCan: Int,
|
val rewardCan: Int,
|
||||||
val price: Int
|
val price: BigDecimal,
|
||||||
|
val currency: String
|
||||||
) {
|
) {
|
||||||
fun toEntity(): Can {
|
fun toEntity(): Can {
|
||||||
var title = "${can.moneyFormat()} 캔"
|
var title = "${can.moneyFormat()} 캔"
|
||||||
@@ -20,6 +22,7 @@ data class AdminCanRequest(
|
|||||||
can = can,
|
can = can,
|
||||||
rewardCan = rewardCan,
|
rewardCan = rewardCan,
|
||||||
price = price,
|
price = price,
|
||||||
|
currency = currency,
|
||||||
status = CanStatus.SALE
|
status = CanStatus.SALE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.CanResponse
|
||||||
import kr.co.vividnext.sodalive.can.CanStatus
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||||
@@ -20,6 +21,10 @@ class AdminCanService(
|
|||||||
private val chargeRepository: ChargeRepository,
|
private val chargeRepository: ChargeRepository,
|
||||||
private val memberRepository: AdminMemberRepository
|
private val memberRepository: AdminMemberRepository
|
||||||
) {
|
) {
|
||||||
|
fun getCans(): List<CanResponse> {
|
||||||
|
return repository.findAllByStatus(status = CanStatus.SALE)
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun saveCan(request: AdminCanRequest) {
|
fun saveCan(request: AdminCanRequest) {
|
||||||
repository.save(request.toEntity())
|
repository.save(request.toEntity())
|
||||||
|
@@ -21,6 +21,7 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
|
|||||||
@GetMapping("/detail")
|
@GetMapping("/detail")
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@RequestParam paymentGateway: PaymentGateway
|
@RequestParam paymentGateway: PaymentGateway,
|
||||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
|
@RequestParam(value = "currency", required = false) currency: String? = null
|
||||||
|
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import com.querydsl.core.BooleanBuilder
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||||
@@ -14,7 +15,7 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
|
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||||
val formattedDate = Expressions.stringTemplate(
|
val formattedDate = Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
Expressions.dateTimeTemplate(
|
Expressions.dateTimeTemplate(
|
||||||
@@ -26,15 +27,16 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
),
|
),
|
||||||
"%Y-%m-%d"
|
"%Y-%m-%d"
|
||||||
)
|
)
|
||||||
|
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetChargeStatusQueryDto(
|
QGetChargeStatusResponse(
|
||||||
formattedDate,
|
formattedDate,
|
||||||
payment.price.sum(),
|
payment.price.sum(),
|
||||||
can1.price.sum(),
|
|
||||||
payment.id.count(),
|
payment.id.count(),
|
||||||
payment.paymentGateway
|
payment.paymentGateway.stringValue(),
|
||||||
|
currency.coalesce("KRW")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(payment)
|
.from(payment)
|
||||||
@@ -46,15 +48,46 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
)
|
)
|
||||||
.groupBy(formattedDate, payment.paymentGateway)
|
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
|
||||||
.orderBy(formattedDate.desc())
|
.orderBy(formattedDate.desc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getChargeStatusSummary(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||||
|
val currency = Expressions.stringTemplate(
|
||||||
|
"substring({0}, length({0}) - 2, 3)",
|
||||||
|
payment.locale
|
||||||
|
).coalesce("KRW")
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetChargeStatusResponse(
|
||||||
|
Expressions.stringTemplate("'합계'"), // date
|
||||||
|
payment.price.sum(),
|
||||||
|
payment.id.count(),
|
||||||
|
Expressions.stringTemplate("''"),
|
||||||
|
currency
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(payment)
|
||||||
|
.innerJoin(payment.charge, charge)
|
||||||
|
.leftJoin(charge.can, can1)
|
||||||
|
.where(
|
||||||
|
charge.createdAt.goe(startDate)
|
||||||
|
.and(charge.createdAt.loe(endDate))
|
||||||
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
|
)
|
||||||
|
.groupBy(currency)
|
||||||
|
.orderBy(currency.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
startDate: LocalDateTime,
|
startDate: LocalDateTime,
|
||||||
endDate: LocalDateTime,
|
endDate: LocalDateTime,
|
||||||
paymentGateway: PaymentGateway
|
paymentGateway: PaymentGateway,
|
||||||
|
currency: String? = null
|
||||||
): List<GetChargeStatusDetailQueryDto> {
|
): List<GetChargeStatusDetailQueryDto> {
|
||||||
val formattedDate = Expressions.stringTemplate(
|
val formattedDate = Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
@@ -67,6 +100,20 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
),
|
),
|
||||||
"%Y-%m-%d %H:%i:%s"
|
"%Y-%m-%d %H:%i:%s"
|
||||||
)
|
)
|
||||||
|
val currencyExpr = Expressions.stringTemplate(
|
||||||
|
"substring({0}, length({0}) - 2, 3)",
|
||||||
|
payment.locale
|
||||||
|
).coalesce("KRW")
|
||||||
|
val whereBuilder = BooleanBuilder()
|
||||||
|
whereBuilder.and(charge.createdAt.goe(startDate))
|
||||||
|
.and(charge.createdAt.loe(endDate))
|
||||||
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
|
.and(payment.paymentGateway.eq(paymentGateway))
|
||||||
|
|
||||||
|
if (currency != null) {
|
||||||
|
whereBuilder.and(currencyExpr.eq(currency))
|
||||||
|
}
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
@@ -75,8 +122,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
member.nickname,
|
member.nickname,
|
||||||
payment.method.coalesce(""),
|
payment.method.coalesce(""),
|
||||||
payment.price,
|
payment.price,
|
||||||
can1.price,
|
currencyExpr,
|
||||||
payment.locale.coalesce(""),
|
|
||||||
formattedDate
|
formattedDate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -84,13 +130,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
.innerJoin(charge.member, member)
|
.innerJoin(charge.member, member)
|
||||||
.innerJoin(charge.payment, payment)
|
.innerJoin(charge.payment, payment)
|
||||||
.leftJoin(charge.can, can1)
|
.leftJoin(charge.can, can1)
|
||||||
.where(
|
.where(whereBuilder)
|
||||||
charge.createdAt.goe(startDate)
|
|
||||||
.and(charge.createdAt.loe(endDate))
|
|
||||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
|
||||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
|
||||||
.and(payment.paymentGateway.eq(paymentGateway))
|
|
||||||
)
|
|
||||||
.orderBy(formattedDate.desc())
|
.orderBy(formattedDate.desc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
@@ -20,48 +20,17 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
var totalChargeAmount = 0
|
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
|
||||||
var totalChargeCount = 0L
|
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
|
||||||
|
chargeStatusList.addAll(0, summaryRows)
|
||||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
|
|
||||||
.asSequence()
|
|
||||||
.map {
|
|
||||||
val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) {
|
|
||||||
it.pgChargeAmount
|
|
||||||
} else {
|
|
||||||
it.appleChargeAmount.toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
val chargeCount = it.chargeCount
|
|
||||||
|
|
||||||
totalChargeAmount += chargeAmount
|
|
||||||
totalChargeCount += chargeCount
|
|
||||||
|
|
||||||
GetChargeStatusResponse(
|
|
||||||
date = it.date,
|
|
||||||
chargeAmount = chargeAmount,
|
|
||||||
chargeCount = chargeCount,
|
|
||||||
pg = it.paymentGateWay.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.toMutableList()
|
|
||||||
|
|
||||||
chargeStatusList.add(
|
|
||||||
0,
|
|
||||||
GetChargeStatusResponse(
|
|
||||||
date = "합계",
|
|
||||||
chargeAmount = totalChargeAmount,
|
|
||||||
chargeCount = totalChargeCount,
|
|
||||||
pg = ""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return chargeStatusList.toList()
|
return chargeStatusList.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
startDateStr: String,
|
startDateStr: String,
|
||||||
paymentGateway: PaymentGateway
|
paymentGateway: PaymentGateway,
|
||||||
|
currency: String? = null
|
||||||
): List<GetChargeStatusDetailResponse> {
|
): List<GetChargeStatusDetailResponse> {
|
||||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||||
@@ -74,18 +43,16 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
|
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
|
||||||
.asSequence()
|
|
||||||
.map {
|
.map {
|
||||||
GetChargeStatusDetailResponse(
|
GetChargeStatusDetailResponse(
|
||||||
memberId = it.memberId,
|
memberId = it.memberId,
|
||||||
nickname = it.nickname,
|
nickname = it.nickname,
|
||||||
method = it.method,
|
method = it.method,
|
||||||
amount = it.appleChargeAmount.toInt(),
|
amount = it.amount,
|
||||||
locale = it.locale,
|
locale = it.locale,
|
||||||
datetime = it.datetime
|
datetime = it.datetime
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.toList()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,13 +1,13 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val method: String,
|
val method: String,
|
||||||
val appleChargeAmount: Double,
|
val amount: BigDecimal,
|
||||||
val pgChargeAmount: Int,
|
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: String
|
val datetime: String
|
||||||
)
|
)
|
||||||
|
@@ -1,10 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class GetChargeStatusDetailResponse(
|
data class GetChargeStatusDetailResponse(
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val method: String,
|
val method: String,
|
||||||
val amount: Int,
|
val amount: BigDecimal,
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: String
|
val datetime: String
|
||||||
)
|
)
|
||||||
|
@@ -1,12 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
|
||||||
|
|
||||||
data class GetChargeStatusQueryDto @QueryProjection constructor(
|
|
||||||
val date: String,
|
|
||||||
val appleChargeAmount: Double,
|
|
||||||
val pgChargeAmount: Int,
|
|
||||||
val chargeCount: Long,
|
|
||||||
val paymentGateWay: PaymentGateway
|
|
||||||
)
|
|
@@ -1,8 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
data class GetChargeStatusResponse(
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
data class GetChargeStatusResponse @QueryProjection constructor(
|
||||||
val date: String,
|
val date: String,
|
||||||
val chargeAmount: Int,
|
val chargeAmount: BigDecimal,
|
||||||
val chargeCount: Long,
|
val chargeCount: Long,
|
||||||
val pg: String
|
val pg: String,
|
||||||
|
val currency: String
|
||||||
)
|
)
|
||||||
|
@@ -0,0 +1,229 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.dto.UpdateBannerOrdersRequest
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/banner")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminChatBannerController(
|
||||||
|
private val bannerService: ChatCharacterBannerService,
|
||||||
|
private val adminCharacterService: AdminChatCharacterService,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val s3Bucket: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 활성화된 배너 목록 조회 API
|
||||||
|
*
|
||||||
|
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||||
|
* @param size 페이지 크기 (기본값 20)
|
||||||
|
* @return 페이징된 배너 목록
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun getBannerList(
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
||||||
|
val banners = bannerService.getActiveBanners(pageable)
|
||||||
|
val response = ChatCharacterBannerListPageResponse(
|
||||||
|
totalCount = banners.totalElements,
|
||||||
|
content = banners.content.map { ChatCharacterBannerResponse.from(it, imageHost) }
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 상세 조회 API
|
||||||
|
*
|
||||||
|
* @param bannerId 배너 ID
|
||||||
|
* @return 배너 상세 정보
|
||||||
|
*/
|
||||||
|
@GetMapping("/{bannerId}")
|
||||||
|
fun getBannerDetail(@PathVariable bannerId: Long) = run {
|
||||||
|
val banner = bannerService.getBannerById(bannerId)
|
||||||
|
val response = ChatCharacterBannerResponse.from(banner, imageHost)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 검색 API (배너 등록을 위한)
|
||||||
|
*
|
||||||
|
* @param searchTerm 검색어 (이름, 설명, MBTI, 태그)
|
||||||
|
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||||
|
* @param size 페이지 크기 (기본값 20)
|
||||||
|
* @return 검색된 캐릭터 목록
|
||||||
|
*/
|
||||||
|
@GetMapping("/search-character")
|
||||||
|
fun searchCharacters(
|
||||||
|
@RequestParam searchTerm: String,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
||||||
|
val pageResult = adminCharacterService.searchCharacters(searchTerm, pageable, imageHost)
|
||||||
|
val response = ChatCharacterSearchListPageResponse(
|
||||||
|
totalCount = pageResult.totalElements,
|
||||||
|
content = pageResult.content
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 등록 API
|
||||||
|
*
|
||||||
|
* @param image 배너 이미지
|
||||||
|
* @param requestString 배너 등록 요청 정보 (캐릭터 ID와 선택적으로 정렬 순서 포함)
|
||||||
|
* @return 등록된 배너 정보
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun registerBanner(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(
|
||||||
|
requestString,
|
||||||
|
ChatCharacterBannerRegisterRequest::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함)
|
||||||
|
val banner = bannerService.registerBanner(
|
||||||
|
characterId = request.characterId,
|
||||||
|
imagePath = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. 배너 ID를 사용하여 이미지 업로드
|
||||||
|
val imagePath = saveImage(banner.id!!, image)
|
||||||
|
|
||||||
|
// 3. 이미지 경로로 배너 업데이트
|
||||||
|
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
|
||||||
|
|
||||||
|
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지를 S3에 업로드하고 경로를 반환
|
||||||
|
*
|
||||||
|
* @param bannerId 배너 ID (이미지 경로에 사용)
|
||||||
|
* @param image 업로드할 이미지 파일
|
||||||
|
* @return 업로드된 이미지 경로
|
||||||
|
*/
|
||||||
|
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
|
||||||
|
val fileName = generateFileName("character-banner")
|
||||||
|
|
||||||
|
// S3에 이미지 업로드 (배너 ID를 경로에 사용)
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = s3Bucket,
|
||||||
|
filePath = "characters/banners/$bannerId/$fileName",
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 수정 API
|
||||||
|
*
|
||||||
|
* @param image 배너 이미지
|
||||||
|
* @param requestString 배너 수정 요청 정보 (배너 ID와 선택적으로 캐릭터 ID 포함)
|
||||||
|
* @return 수정된 배너 정보
|
||||||
|
*/
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun updateBanner(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(
|
||||||
|
requestString,
|
||||||
|
ChatCharacterBannerUpdateRequest::class.java
|
||||||
|
)
|
||||||
|
// 배너 정보 조회
|
||||||
|
bannerService.getBannerById(request.bannerId)
|
||||||
|
|
||||||
|
// 배너 ID를 사용하여 이미지 업로드
|
||||||
|
val imagePath = saveImage(request.bannerId, image)
|
||||||
|
|
||||||
|
// 배너 수정 (이미지와 캐릭터 모두 수정 가능)
|
||||||
|
val updatedBanner = bannerService.updateBanner(
|
||||||
|
bannerId = request.bannerId,
|
||||||
|
imagePath = imagePath,
|
||||||
|
characterId = request.characterId
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 삭제 API (소프트 삭제)
|
||||||
|
*
|
||||||
|
* @param bannerId 배너 ID
|
||||||
|
* @return 성공 여부
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{bannerId}")
|
||||||
|
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
||||||
|
bannerService.deleteBanner(bannerId)
|
||||||
|
|
||||||
|
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 정렬 순서 일괄 변경 API
|
||||||
|
* ID 목록의 순서대로 정렬 순서를 1부터 순차적으로 설정합니다.
|
||||||
|
*
|
||||||
|
* @param request 정렬 순서 일괄 변경 요청 정보 (배너 ID 목록)
|
||||||
|
* @return 성공 메시지
|
||||||
|
*/
|
||||||
|
@PutMapping("/orders")
|
||||||
|
fun updateBannerOrders(
|
||||||
|
@RequestBody request: UpdateBannerOrdersRequest
|
||||||
|
) = run {
|
||||||
|
bannerService.updateBannerOrders(request.ids)
|
||||||
|
|
||||||
|
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,32 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@RequestMapping("/admin/chat/calculate")
|
||||||
|
class AdminChatCalculateController(
|
||||||
|
private val service: AdminChatCalculateService
|
||||||
|
) {
|
||||||
|
@GetMapping("/characters")
|
||||||
|
fun getCharacterCalculate(
|
||||||
|
@RequestParam startDateStr: String,
|
||||||
|
@RequestParam endDateStr: String,
|
||||||
|
@RequestParam(required = false, defaultValue = "TOTAL_SALES_DESC") sort: ChatCharacterCalculateSort,
|
||||||
|
pageable: Pageable
|
||||||
|
) = ApiResponse.ok(
|
||||||
|
service.getCharacterCalculate(
|
||||||
|
startDateStr,
|
||||||
|
endDateStr,
|
||||||
|
sort,
|
||||||
|
pageable.offset,
|
||||||
|
pageable.pageSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,139 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import com.querydsl.core.types.Projections
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AdminChatCalculateQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
fun getCharacterCalculate(
|
||||||
|
startUtc: LocalDateTime,
|
||||||
|
endInclusiveUtc: LocalDateTime,
|
||||||
|
sort: ChatCharacterCalculateSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<ChatCharacterCalculateQueryData> {
|
||||||
|
val imageCanExpr = CaseBuilder()
|
||||||
|
.`when`(useCan.canUsage.eq(CanUsage.CHARACTER_IMAGE_PURCHASE))
|
||||||
|
.then(useCan.can.add(useCan.rewardCan))
|
||||||
|
.otherwise(0)
|
||||||
|
|
||||||
|
val messageCanExpr = CaseBuilder()
|
||||||
|
.`when`(useCan.canUsage.eq(CanUsage.CHAT_MESSAGE_PURCHASE))
|
||||||
|
.then(useCan.can.add(useCan.rewardCan))
|
||||||
|
.otherwise(0)
|
||||||
|
|
||||||
|
val quotaCanExpr = CaseBuilder()
|
||||||
|
.`when`(useCan.canUsage.eq(CanUsage.CHAT_QUOTA_PURCHASE))
|
||||||
|
.then(useCan.can.add(useCan.rewardCan))
|
||||||
|
.otherwise(0)
|
||||||
|
|
||||||
|
val imageSum = imageCanExpr.sum()
|
||||||
|
val messageSum = messageCanExpr.sum()
|
||||||
|
val quotaSum = quotaCanExpr.sum()
|
||||||
|
val totalSum = imageSum.add(messageSum).add(quotaSum)
|
||||||
|
|
||||||
|
// 캐릭터 조인: 이미지 경로를 통한 캐릭터(c1) + characterId 직접 지정(c2)
|
||||||
|
val c1 = QChatCharacter("c1")
|
||||||
|
val c2 = QChatCharacter("c2")
|
||||||
|
|
||||||
|
val characterIdExpr = c1.id.coalesce(c2.id)
|
||||||
|
val characterNameAgg = Expressions.stringTemplate(
|
||||||
|
"coalesce(max({0}), max({1}), '')",
|
||||||
|
c1.name,
|
||||||
|
c2.name
|
||||||
|
)
|
||||||
|
val characterImagePathAgg = Expressions.stringTemplate(
|
||||||
|
"coalesce(max({0}), max({1}))",
|
||||||
|
c1.imagePath,
|
||||||
|
c2.imagePath
|
||||||
|
)
|
||||||
|
|
||||||
|
val query = queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
ChatCharacterCalculateQueryData::class.java,
|
||||||
|
characterIdExpr,
|
||||||
|
characterNameAgg,
|
||||||
|
characterImagePathAgg.prepend("/").prepend(imageHost),
|
||||||
|
imageSum,
|
||||||
|
messageSum,
|
||||||
|
quotaSum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(useCan)
|
||||||
|
.leftJoin(useCan.characterImage, characterImage)
|
||||||
|
.leftJoin(characterImage.chatCharacter, c1)
|
||||||
|
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(
|
||||||
|
useCan.canUsage.`in`(
|
||||||
|
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_QUOTA_PURCHASE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.and(useCan.createdAt.goe(startUtc))
|
||||||
|
.and(useCan.createdAt.loe(endInclusiveUtc))
|
||||||
|
)
|
||||||
|
.groupBy(characterIdExpr)
|
||||||
|
|
||||||
|
when (sort) {
|
||||||
|
ChatCharacterCalculateSort.TOTAL_SALES_DESC ->
|
||||||
|
query.orderBy(totalSum.desc(), characterIdExpr.desc())
|
||||||
|
|
||||||
|
ChatCharacterCalculateSort.LATEST_DESC ->
|
||||||
|
query.orderBy(characterIdExpr.desc(), totalSum.desc())
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCharacterCalculateTotalCount(
|
||||||
|
startUtc: LocalDateTime,
|
||||||
|
endInclusiveUtc: LocalDateTime
|
||||||
|
): Int {
|
||||||
|
val c1 = QChatCharacter("c1")
|
||||||
|
val c2 = QChatCharacter("c2")
|
||||||
|
val characterIdExpr = c1.id.coalesce(c2.id)
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(characterIdExpr)
|
||||||
|
.from(useCan)
|
||||||
|
.leftJoin(useCan.characterImage, characterImage)
|
||||||
|
.leftJoin(characterImage.chatCharacter, c1)
|
||||||
|
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
||||||
|
.where(
|
||||||
|
useCan.isRefund.isFalse
|
||||||
|
.and(
|
||||||
|
useCan.canUsage.`in`(
|
||||||
|
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||||
|
CanUsage.CHAT_QUOTA_PURCHASE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.and(useCan.createdAt.goe(startUtc))
|
||||||
|
.and(useCan.createdAt.loe(endInclusiveUtc))
|
||||||
|
)
|
||||||
|
.groupBy(characterIdExpr)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminChatCalculateService(
|
||||||
|
private val repository: AdminChatCalculateQueryRepository
|
||||||
|
) {
|
||||||
|
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
|
private val kstZone: ZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getCharacterCalculate(
|
||||||
|
startDateStr: String,
|
||||||
|
endDateStr: String,
|
||||||
|
sort: ChatCharacterCalculateSort,
|
||||||
|
offset: Long,
|
||||||
|
pageSize: Int
|
||||||
|
): ChatCharacterCalculateResponse {
|
||||||
|
// 날짜 유효성 검증 (KST 기준)
|
||||||
|
val startDate = LocalDate.parse(startDateStr, dateFormatter)
|
||||||
|
val endDate = LocalDate.parse(endDateStr, dateFormatter)
|
||||||
|
val todayKst = LocalDate.now(kstZone)
|
||||||
|
|
||||||
|
if (endDate.isAfter(todayKst)) {
|
||||||
|
throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.")
|
||||||
|
}
|
||||||
|
if (startDate.isAfter(endDate)) {
|
||||||
|
throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.")
|
||||||
|
}
|
||||||
|
if (endDate.isAfter(startDate.plusMonths(6))) {
|
||||||
|
throw SodaException("조회 가능 기간은 최대 6개월입니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val startUtc = startDateStr.convertLocalDateTime()
|
||||||
|
val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
val totalCount = repository.getCharacterCalculateTotalCount(startUtc, endInclusiveUtc)
|
||||||
|
val rows = repository.getCharacterCalculate(startUtc, endInclusiveUtc, sort, offset, pageSize.toLong())
|
||||||
|
val items = rows.map { it.toItem() }
|
||||||
|
return ChatCharacterCalculateResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,62 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
// 정렬 옵션
|
||||||
|
enum class ChatCharacterCalculateSort {
|
||||||
|
TOTAL_SALES_DESC,
|
||||||
|
LATEST_DESC
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryDSL 프로젝션용 DTO
|
||||||
|
data class ChatCharacterCalculateQueryData @QueryProjection constructor(
|
||||||
|
val characterId: Long,
|
||||||
|
val characterName: String,
|
||||||
|
val characterImagePath: String?,
|
||||||
|
val imagePurchaseCan: Int?,
|
||||||
|
val messagePurchaseCan: Int?,
|
||||||
|
val quotaPurchaseCan: Int?
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 DTO (아이템)
|
||||||
|
data class ChatCharacterCalculateItem(
|
||||||
|
@JsonProperty("characterId") val characterId: Long,
|
||||||
|
@JsonProperty("characterImage") val characterImage: String?,
|
||||||
|
@JsonProperty("name") val name: String,
|
||||||
|
@JsonProperty("imagePurchaseCan") val imagePurchaseCan: Int,
|
||||||
|
@JsonProperty("messagePurchaseCan") val messagePurchaseCan: Int,
|
||||||
|
@JsonProperty("quotaPurchaseCan") val quotaPurchaseCan: Int,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||||
|
@JsonProperty("settlementKrw") val settlementKrw: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 DTO (전체)
|
||||||
|
data class ChatCharacterCalculateResponse(
|
||||||
|
@JsonProperty("totalCount") val totalCount: Int,
|
||||||
|
@JsonProperty("items") val items: List<ChatCharacterCalculateItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem {
|
||||||
|
val image = imagePurchaseCan ?: 0
|
||||||
|
val message = messagePurchaseCan ?: 0
|
||||||
|
val quota = quotaPurchaseCan ?: 0
|
||||||
|
val total = image + message + quota
|
||||||
|
val totalKrw = BigDecimal(total).multiply(BigDecimal(100))
|
||||||
|
val settlement = totalKrw.multiply(BigDecimal("0.10")).setScale(0, RoundingMode.HALF_UP)
|
||||||
|
|
||||||
|
return ChatCharacterCalculateItem(
|
||||||
|
characterId = characterId,
|
||||||
|
characterImage = characterImagePath,
|
||||||
|
name = characterName,
|
||||||
|
imagePurchaseCan = image,
|
||||||
|
messagePurchaseCan = message,
|
||||||
|
quotaPurchaseCan = quota,
|
||||||
|
totalCan = total,
|
||||||
|
totalKrw = totalKrw.toInt(),
|
||||||
|
settlementKrw = settlement.toInt()
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,423 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/character")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminChatCharacterController(
|
||||||
|
private val service: ChatCharacterService,
|
||||||
|
private val adminService: AdminChatCharacterService,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val originalWorkService: AdminOriginalWorkService,
|
||||||
|
|
||||||
|
@Value("\${weraser.api-key}")
|
||||||
|
private val apiKey: String,
|
||||||
|
|
||||||
|
@Value("\${weraser.api-url}")
|
||||||
|
private val apiUrl: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val s3Bucket: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 활성화된 캐릭터 목록 조회 API
|
||||||
|
*
|
||||||
|
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||||
|
* @param size 페이지 크기 (기본값 20)
|
||||||
|
* @return 페이징된 캐릭터 목록
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun getCharacterList(
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageable = adminService.createDefaultPageRequest(page, size)
|
||||||
|
val response = adminService.getActiveChatCharacters(pageable, imageHost)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 검색(관리자)
|
||||||
|
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
|
||||||
|
* - 페이징 지원: page, size 파라미터 사용
|
||||||
|
*/
|
||||||
|
@GetMapping("/search")
|
||||||
|
fun searchCharacters(
|
||||||
|
@RequestParam("searchTerm") searchTerm: String,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageable = adminService.createDefaultPageRequest(page, size)
|
||||||
|
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
|
||||||
|
val response = ChatCharacterSearchListPageResponse(
|
||||||
|
totalCount = resultPage.totalElements,
|
||||||
|
content = resultPage.content
|
||||||
|
)
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 상세 정보 조회 API
|
||||||
|
*
|
||||||
|
* @param characterId 캐릭터 ID
|
||||||
|
* @return 캐릭터 상세 정보
|
||||||
|
*/
|
||||||
|
@GetMapping("/{characterId}")
|
||||||
|
fun getCharacterDetail(
|
||||||
|
@PathVariable characterId: Long
|
||||||
|
) = run {
|
||||||
|
val response = adminService.getChatCharacterDetail(characterId, imageHost)
|
||||||
|
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun registerCharacter(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
// JSON 문자열을 ChatCharacterRegisterRequest 객체로 변환
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, ChatCharacterRegisterRequest::class.java)
|
||||||
|
|
||||||
|
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
|
||||||
|
val existingCharacter = service.findByName(request.name)
|
||||||
|
if (existingCharacter != null) {
|
||||||
|
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 외부 API 호출
|
||||||
|
val characterUUID = callExternalApi(request)
|
||||||
|
|
||||||
|
// 2. ChatCharacter 저장
|
||||||
|
val chatCharacter = service.createChatCharacterWithDetails(
|
||||||
|
characterUUID = characterUUID,
|
||||||
|
name = request.name,
|
||||||
|
description = request.description,
|
||||||
|
systemPrompt = request.systemPrompt,
|
||||||
|
age = request.age?.toIntOrNull(),
|
||||||
|
gender = request.gender,
|
||||||
|
mbti = request.mbti,
|
||||||
|
speechPattern = request.speechPattern,
|
||||||
|
speechStyle = request.speechStyle,
|
||||||
|
appearance = request.appearance,
|
||||||
|
originalTitle = request.originalTitle,
|
||||||
|
originalLink = request.originalLink,
|
||||||
|
characterType = request.characterType?.let {
|
||||||
|
runCatching { CharacterType.valueOf(it) }
|
||||||
|
.getOrDefault(CharacterType.Character)
|
||||||
|
} ?: CharacterType.Character,
|
||||||
|
tags = request.tags,
|
||||||
|
values = request.values,
|
||||||
|
hobbies = request.hobbies,
|
||||||
|
goals = request.goals,
|
||||||
|
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
|
||||||
|
personalities = request.personalities.map { Pair(it.trait, it.description) },
|
||||||
|
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
|
||||||
|
relationships = request.relationships
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정
|
||||||
|
val imagePath = saveImage(
|
||||||
|
characterId = chatCharacter.id!!,
|
||||||
|
image = image
|
||||||
|
)
|
||||||
|
chatCharacter.imagePath = imagePath
|
||||||
|
service.saveChatCharacter(chatCharacter)
|
||||||
|
|
||||||
|
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||||
|
if (request.originalWorkId != null) {
|
||||||
|
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun callExternalApi(request: ChatCharacterRegisterRequest): String {
|
||||||
|
try {
|
||||||
|
val factory = SimpleClientHttpRequestFactory()
|
||||||
|
factory.setConnectTimeout(20000) // 20초
|
||||||
|
factory.setReadTimeout(20000) // 20초
|
||||||
|
|
||||||
|
val restTemplate = RestTemplate(factory)
|
||||||
|
|
||||||
|
val headers = HttpHeaders()
|
||||||
|
headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요
|
||||||
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
|
|
||||||
|
// 외부 API에 전달하지 않을 필드(originalTitle, originalLink, characterType)를 제외하고 바디 구성
|
||||||
|
val body = mutableMapOf<String, Any>()
|
||||||
|
body["name"] = request.name
|
||||||
|
body["systemPrompt"] = request.systemPrompt
|
||||||
|
body["description"] = request.description
|
||||||
|
request.age?.let { body["age"] = it }
|
||||||
|
request.gender?.let { body["gender"] = it }
|
||||||
|
request.mbti?.let { body["mbti"] = it }
|
||||||
|
request.speechPattern?.let { body["speechPattern"] = it }
|
||||||
|
request.speechStyle?.let { body["speechStyle"] = it }
|
||||||
|
request.appearance?.let { body["appearance"] = it }
|
||||||
|
if (request.tags.isNotEmpty()) body["tags"] = request.tags
|
||||||
|
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
|
||||||
|
if (request.values.isNotEmpty()) body["values"] = request.values
|
||||||
|
if (request.goals.isNotEmpty()) body["goals"] = request.goals
|
||||||
|
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
|
||||||
|
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
|
||||||
|
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
|
||||||
|
if (request.memories.isNotEmpty()) body["memories"] = request.memories
|
||||||
|
|
||||||
|
val httpEntity = HttpEntity(body, headers)
|
||||||
|
|
||||||
|
val response = restTemplate.exchange(
|
||||||
|
"$apiUrl/api/characters",
|
||||||
|
HttpMethod.POST,
|
||||||
|
httpEntity,
|
||||||
|
String::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 파싱
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
||||||
|
|
||||||
|
// success가 false이면 throw
|
||||||
|
if (!apiResponse.success) {
|
||||||
|
throw SodaException(apiResponse.message ?: "등록에 실패했습니다. 다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// success가 true이면 data.id 반환
|
||||||
|
return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveImage(characterId: Long, image: MultipartFile): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
|
||||||
|
// S3에 이미지 업로드
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = s3Bucket,
|
||||||
|
filePath = "characters/$characterId/${generateFileName(prefix = "character")}",
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 수정 API
|
||||||
|
* 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
||||||
|
* 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
||||||
|
* 3. 이미지 있는지 확인
|
||||||
|
* 4. 2, 3번 중 하나라도 해당 하면 계속 진행
|
||||||
|
* 5. 2, 3번에 데이터 없으면 throw SodaException('변경된 데이터가 없습니다.')
|
||||||
|
*
|
||||||
|
* @param image 캐릭터 이미지 (선택적)
|
||||||
|
* @param requestString ChatCharacterUpdateRequest 객체를 JSON 문자열로 변환한 값
|
||||||
|
* @return ApiResponse 객체
|
||||||
|
* @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun updateCharacter(
|
||||||
|
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
// 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java)
|
||||||
|
|
||||||
|
// 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
||||||
|
val hasChangedData = hasChanges(request) // 외부 API 대상으로의 변경 여부(3가지 필드 제외)
|
||||||
|
|
||||||
|
// 3. 이미지 있는지 확인
|
||||||
|
val hasImage = image != null && !image.isEmpty
|
||||||
|
|
||||||
|
// 3가지만 변경된 경우(외부 API 변경은 없지만 DB 변경은 있는 경우)를 허용하기 위해 별도 플래그 계산
|
||||||
|
val hasDbOnlyChanges =
|
||||||
|
request.originalTitle != null ||
|
||||||
|
request.originalLink != null ||
|
||||||
|
request.characterType != null ||
|
||||||
|
request.originalWorkId != null
|
||||||
|
|
||||||
|
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
||||||
|
throw SodaException("변경된 데이터가 없습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
|
||||||
|
if (hasChangedData) {
|
||||||
|
val chatCharacter = service.findById(request.id)
|
||||||
|
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")
|
||||||
|
|
||||||
|
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
|
||||||
|
if (request.name != null && request.name != chatCharacter.name) {
|
||||||
|
val existingCharacter = service.findByName(request.name)
|
||||||
|
if (existingCharacter != null) {
|
||||||
|
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callExternalApiForUpdate(chatCharacter.characterUUID, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지 경로 변수 초기화
|
||||||
|
// 이미지가 있으면 이미지 저장
|
||||||
|
val imagePath = if (hasImage) {
|
||||||
|
saveImage(
|
||||||
|
characterId = request.id,
|
||||||
|
image = image!!
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엔티티 수정
|
||||||
|
service.updateChatCharacterWithDetails(
|
||||||
|
imagePath = imagePath,
|
||||||
|
request = request
|
||||||
|
)
|
||||||
|
|
||||||
|
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||||
|
if (request.originalWorkId != null) {
|
||||||
|
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||||
|
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청에 변경된 데이터가 있는지 확인
|
||||||
|
* id를 제외한 모든 필드가 null이면 변경된 데이터가 없는 것으로 판단
|
||||||
|
*
|
||||||
|
* @param request 수정 요청 데이터
|
||||||
|
* @return 변경된 데이터가 있으면 true, 없으면 false
|
||||||
|
*/
|
||||||
|
private fun hasChanges(request: ChatCharacterUpdateRequest): Boolean {
|
||||||
|
return request.systemPrompt != null ||
|
||||||
|
request.description != null ||
|
||||||
|
request.age != null ||
|
||||||
|
request.gender != null ||
|
||||||
|
request.mbti != null ||
|
||||||
|
request.speechPattern != null ||
|
||||||
|
request.speechStyle != null ||
|
||||||
|
request.appearance != null ||
|
||||||
|
request.isActive != null ||
|
||||||
|
request.tags != null ||
|
||||||
|
request.hobbies != null ||
|
||||||
|
request.values != null ||
|
||||||
|
request.goals != null ||
|
||||||
|
request.relationships != null ||
|
||||||
|
request.personalities != null ||
|
||||||
|
request.backgrounds != null ||
|
||||||
|
request.memories != null ||
|
||||||
|
request.name != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API 호출 - 수정 API
|
||||||
|
* 변경된 데이터만 요청에 포함
|
||||||
|
*
|
||||||
|
* @param characterUUID 캐릭터 UUID
|
||||||
|
* @param request 수정 요청 데이터
|
||||||
|
*/
|
||||||
|
private fun callExternalApiForUpdate(characterUUID: String, request: ChatCharacterUpdateRequest) {
|
||||||
|
try {
|
||||||
|
val factory = SimpleClientHttpRequestFactory()
|
||||||
|
factory.setConnectTimeout(20000) // 20초
|
||||||
|
factory.setReadTimeout(20000) // 20초
|
||||||
|
|
||||||
|
val restTemplate = RestTemplate(factory)
|
||||||
|
|
||||||
|
val headers = HttpHeaders()
|
||||||
|
headers.set("x-api-key", apiKey)
|
||||||
|
headers.contentType = MediaType.APPLICATION_JSON
|
||||||
|
|
||||||
|
// 변경된 데이터만 포함하는 맵 생성
|
||||||
|
val updateData = mutableMapOf<String, Any>()
|
||||||
|
|
||||||
|
// isActive = false인 경우 처리
|
||||||
|
if (request.isActive != null && !request.isActive) {
|
||||||
|
val inactiveName = "inactive_${request.name}"
|
||||||
|
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
|
||||||
|
updateData["name"] = inactiveName + randomSuffix
|
||||||
|
} else {
|
||||||
|
request.name?.let { updateData["name"] = it }
|
||||||
|
request.systemPrompt?.let { updateData["systemPrompt"] = it }
|
||||||
|
request.description?.let { updateData["description"] = it }
|
||||||
|
request.age?.let { updateData["age"] = it }
|
||||||
|
request.gender?.let { updateData["gender"] = it }
|
||||||
|
request.mbti?.let { updateData["mbti"] = it }
|
||||||
|
request.speechPattern?.let { updateData["speechPattern"] = it }
|
||||||
|
request.speechStyle?.let { updateData["speechStyle"] = it }
|
||||||
|
request.appearance?.let { updateData["appearance"] = it }
|
||||||
|
request.tags?.let { updateData["tags"] = it }
|
||||||
|
request.hobbies?.let { updateData["hobbies"] = it }
|
||||||
|
request.values?.let { updateData["values"] = it }
|
||||||
|
request.goals?.let { updateData["goals"] = it }
|
||||||
|
request.relationships?.let { updateData["relationships"] = it }
|
||||||
|
request.personalities?.let { updateData["personalities"] = it }
|
||||||
|
request.backgrounds?.let { updateData["backgrounds"] = it }
|
||||||
|
request.memories?.let { updateData["memories"] = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
val httpEntity = HttpEntity(updateData, headers)
|
||||||
|
val response = restTemplate.exchange(
|
||||||
|
"$apiUrl/api/characters/$characterUUID",
|
||||||
|
HttpMethod.PUT,
|
||||||
|
httpEntity,
|
||||||
|
String::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 파싱
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
||||||
|
|
||||||
|
// success가 false이면 throw
|
||||||
|
if (!apiResponse.success) {
|
||||||
|
throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,82 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/character/curation")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class CharacterCurationAdminController(
|
||||||
|
private val service: CharacterCurationAdminService,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun listAll(): ApiResponse<List<CharacterCurationListItemResponse>> =
|
||||||
|
ApiResponse.ok(service.listAll())
|
||||||
|
|
||||||
|
@GetMapping("/{curationId}/characters")
|
||||||
|
fun listCharacters(
|
||||||
|
@PathVariable curationId: Long
|
||||||
|
): ApiResponse<List<CharacterCurationCharacterItemResponse>> {
|
||||||
|
val characters = service.listCharacters(curationId)
|
||||||
|
val items = characters.map {
|
||||||
|
CharacterCurationCharacterItemResponse(
|
||||||
|
id = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun register(@RequestBody request: CharacterCurationRegisterRequest) =
|
||||||
|
ApiResponse.ok(service.register(request).id)
|
||||||
|
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun update(@RequestBody request: CharacterCurationUpdateRequest) =
|
||||||
|
ApiResponse.ok(service.update(request).id)
|
||||||
|
|
||||||
|
@DeleteMapping("/{curationId}")
|
||||||
|
fun delete(@PathVariable curationId: Long) =
|
||||||
|
ApiResponse.ok(service.softDelete(curationId))
|
||||||
|
|
||||||
|
@PutMapping("/reorder")
|
||||||
|
fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) =
|
||||||
|
ApiResponse.ok(service.reorder(request.ids))
|
||||||
|
|
||||||
|
@PostMapping("/{curationId}/characters")
|
||||||
|
fun addCharacter(
|
||||||
|
@PathVariable curationId: Long,
|
||||||
|
@RequestBody request: CharacterCurationAddCharacterRequest
|
||||||
|
): ApiResponse<Boolean> {
|
||||||
|
val ids = request.characterIds.filter { it > 0 }.distinct()
|
||||||
|
if (ids.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
||||||
|
service.addCharacters(curationId, ids)
|
||||||
|
return ApiResponse.ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{curationId}/characters/{characterId}")
|
||||||
|
fun removeCharacter(
|
||||||
|
@PathVariable curationId: Long,
|
||||||
|
@PathVariable characterId: Long
|
||||||
|
) = ApiResponse.ok(service.removeCharacter(curationId, characterId))
|
||||||
|
|
||||||
|
@PutMapping("/{curationId}/characters/reorder")
|
||||||
|
fun reorderCharacters(
|
||||||
|
@PathVariable curationId: Long,
|
||||||
|
@RequestBody request: CharacterCurationReorderCharactersRequest
|
||||||
|
) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds))
|
||||||
|
}
|
@@ -0,0 +1,45 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||||
|
|
||||||
|
data class CharacterCurationRegisterRequest(
|
||||||
|
val title: String,
|
||||||
|
val isAdult: Boolean = false,
|
||||||
|
val isActive: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationUpdateRequest(
|
||||||
|
val id: Long,
|
||||||
|
val title: String? = null,
|
||||||
|
val isAdult: Boolean? = null,
|
||||||
|
val isActive: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationOrderUpdateRequest(
|
||||||
|
val ids: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationAddCharacterRequest(
|
||||||
|
val characterIds: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationReorderCharactersRequest(
|
||||||
|
val characterIds: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationListItemResponse(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isActive: Boolean,
|
||||||
|
val characterCount: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO
|
||||||
|
// id, name, description, 이미지 URL
|
||||||
|
// 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성
|
||||||
|
|
||||||
|
data class CharacterCurationCharacterItemResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val imageUrl: String
|
||||||
|
)
|
@@ -0,0 +1,153 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class CharacterCurationAdminService(
|
||||||
|
private val curationRepository: CharacterCurationRepository,
|
||||||
|
private val mappingRepository: CharacterCurationMappingRepository,
|
||||||
|
private val characterRepository: ChatCharacterRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun register(request: CharacterCurationRegisterRequest): CharacterCuration {
|
||||||
|
val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1
|
||||||
|
val curation = CharacterCuration(
|
||||||
|
title = request.title,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
isActive = request.isActive,
|
||||||
|
sortOrder = sortOrder
|
||||||
|
)
|
||||||
|
return curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
|
||||||
|
val curation = curationRepository.findById(request.id)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") }
|
||||||
|
|
||||||
|
request.title?.let { curation.title = it }
|
||||||
|
request.isAdult?.let { curation.isAdult = it }
|
||||||
|
request.isActive?.let { curation.isActive = it }
|
||||||
|
|
||||||
|
return curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun softDelete(curationId: Long) {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
curation.isActive = false
|
||||||
|
curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun reorder(ids: List<Long>) {
|
||||||
|
ids.forEachIndexed { index, id ->
|
||||||
|
val curation = curationRepository.findById(id)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
|
||||||
|
curation.sortOrder = index + 1
|
||||||
|
curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun addCharacters(curationId: Long, characterIds: List<Long>) {
|
||||||
|
if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
||||||
|
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
|
||||||
|
|
||||||
|
val uniqueIds = characterIds.filter { it > 0 }.distinct()
|
||||||
|
if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다")
|
||||||
|
|
||||||
|
// 활성 캐릭터만 조회 (조회 단계에서 검증 포함)
|
||||||
|
val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds)
|
||||||
|
val characterMap = characters.associateBy { it.id!! }
|
||||||
|
|
||||||
|
// 조회 결과에 존재하는 캐릭터만 유효
|
||||||
|
val validIds = uniqueIds.filter { id -> characterMap.containsKey(id) }
|
||||||
|
|
||||||
|
val existingMappings = mappingRepository.findByCuration(curation)
|
||||||
|
val existingCharacterIds = existingMappings.mapNotNull { it.chatCharacter.id }.toSet()
|
||||||
|
var nextOrder = (existingMappings.maxOfOrNull { it.sortOrder } ?: 0) + 1
|
||||||
|
|
||||||
|
val toSave = mutableListOf<CharacterCurationMapping>()
|
||||||
|
validIds.forEach { id ->
|
||||||
|
if (!existingCharacterIds.contains(id)) {
|
||||||
|
val character = characterMap[id] ?: return@forEach
|
||||||
|
toSave += CharacterCurationMapping(
|
||||||
|
curation = curation,
|
||||||
|
chatCharacter = character,
|
||||||
|
sortOrder = nextOrder++
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toSave.isNotEmpty()) {
|
||||||
|
mappingRepository.saveAll(toSave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun removeCharacter(curationId: Long, characterId: Long) {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
val mappings = mappingRepository.findByCuration(curation)
|
||||||
|
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
|
||||||
|
?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId")
|
||||||
|
mappingRepository.delete(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
val mappings = mappingRepository.findByCuration(curation)
|
||||||
|
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
|
||||||
|
|
||||||
|
characterIds.forEachIndexed { index, cid ->
|
||||||
|
val mapping = mappingByCharacterId[cid]
|
||||||
|
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
|
||||||
|
mapping.sortOrder = index + 1
|
||||||
|
mappingRepository.save(mapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun listAll(): List<CharacterCurationListItemResponse> {
|
||||||
|
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
|
||||||
|
if (curations.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
// DB 집계로 활성 캐릭터 수 카운트
|
||||||
|
val counts = mappingRepository.countActiveCharactersByCurations(curations)
|
||||||
|
val countByCurationId: Map<Long, Int> = counts.associate { it.curationId to it.count.toInt() }
|
||||||
|
|
||||||
|
return curations.map { curation ->
|
||||||
|
CharacterCurationListItemResponse(
|
||||||
|
id = curation.id!!,
|
||||||
|
title = curation.title,
|
||||||
|
isAdult = curation.isAdult,
|
||||||
|
isActive = curation.isActive,
|
||||||
|
characterCount = countByCurationId[curation.id!!] ?: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun listCharacters(curationId: Long): List<ChatCharacter> {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
|
||||||
|
return mappings.map { it.chatCharacter }
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,132 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 캐릭터 상세 응답 DTO
|
||||||
|
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
|
||||||
|
*/
|
||||||
|
data class ChatCharacterDetailResponse(
|
||||||
|
val id: Long,
|
||||||
|
val characterUUID: String,
|
||||||
|
val name: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val description: String,
|
||||||
|
val systemPrompt: String,
|
||||||
|
val characterType: String,
|
||||||
|
val age: Int?,
|
||||||
|
val gender: String?,
|
||||||
|
val mbti: String?,
|
||||||
|
val speechPattern: String?,
|
||||||
|
val speechStyle: String?,
|
||||||
|
val appearance: String?,
|
||||||
|
val isActive: Boolean,
|
||||||
|
val tags: List<String>,
|
||||||
|
val hobbies: List<String>,
|
||||||
|
val values: List<String>,
|
||||||
|
val goals: List<String>,
|
||||||
|
val relationships: List<RelationshipResponse>,
|
||||||
|
val personalities: List<PersonalityResponse>,
|
||||||
|
val backgrounds: List<BackgroundResponse>,
|
||||||
|
val memories: List<MemoryResponse>,
|
||||||
|
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||||
|
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${chatCharacter.imagePath}"
|
||||||
|
} else {
|
||||||
|
chatCharacter.imagePath ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val ow = chatCharacter.originalWork
|
||||||
|
val originalWorkBrief = ow?.let {
|
||||||
|
val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${it.imagePath}"
|
||||||
|
} else {
|
||||||
|
it.imagePath
|
||||||
|
}
|
||||||
|
OriginalWorkBriefResponse(
|
||||||
|
id = it.id!!,
|
||||||
|
imageUrl = owImage,
|
||||||
|
title = it.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChatCharacterDetailResponse(
|
||||||
|
id = chatCharacter.id!!,
|
||||||
|
characterUUID = chatCharacter.characterUUID,
|
||||||
|
name = chatCharacter.name,
|
||||||
|
imageUrl = fullImagePath,
|
||||||
|
description = chatCharacter.description,
|
||||||
|
systemPrompt = chatCharacter.systemPrompt,
|
||||||
|
characterType = chatCharacter.characterType.name,
|
||||||
|
age = chatCharacter.age,
|
||||||
|
gender = chatCharacter.gender,
|
||||||
|
mbti = chatCharacter.mbti,
|
||||||
|
speechPattern = chatCharacter.speechPattern,
|
||||||
|
speechStyle = chatCharacter.speechStyle,
|
||||||
|
appearance = chatCharacter.appearance,
|
||||||
|
isActive = chatCharacter.isActive,
|
||||||
|
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
||||||
|
hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby },
|
||||||
|
values = chatCharacter.valueMappings.map { it.value.value },
|
||||||
|
goals = chatCharacter.goalMappings.map { it.goal.goal },
|
||||||
|
relationships = chatCharacter.relationships.map {
|
||||||
|
RelationshipResponse(
|
||||||
|
personName = it.personName,
|
||||||
|
relationshipName = it.relationshipName,
|
||||||
|
description = it.description,
|
||||||
|
importance = it.importance,
|
||||||
|
relationshipType = it.relationshipType,
|
||||||
|
currentStatus = it.currentStatus
|
||||||
|
)
|
||||||
|
},
|
||||||
|
personalities = chatCharacter.personalities.map {
|
||||||
|
PersonalityResponse(it.trait, it.description)
|
||||||
|
},
|
||||||
|
backgrounds = chatCharacter.backgrounds.map {
|
||||||
|
BackgroundResponse(it.topic, it.description)
|
||||||
|
},
|
||||||
|
memories = chatCharacter.memories.map {
|
||||||
|
MemoryResponse(it.title, it.content, it.emotion)
|
||||||
|
},
|
||||||
|
originalWork = originalWorkBrief
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PersonalityResponse(
|
||||||
|
val trait: String,
|
||||||
|
val description: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BackgroundResponse(
|
||||||
|
val topic: String,
|
||||||
|
val description: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MemoryResponse(
|
||||||
|
val title: String,
|
||||||
|
val content: String,
|
||||||
|
val emotion: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RelationshipResponse(
|
||||||
|
val personName: String,
|
||||||
|
val relationshipName: String,
|
||||||
|
val description: String,
|
||||||
|
val importance: Int,
|
||||||
|
val relationshipType: String,
|
||||||
|
val currentStatus: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
|
||||||
|
*/
|
||||||
|
data class OriginalWorkBriefResponse(
|
||||||
|
val id: Long,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val title: String
|
||||||
|
)
|
@@ -0,0 +1,90 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class ChatCharacterPersonalityRequest(
|
||||||
|
@JsonProperty("trait") val trait: String,
|
||||||
|
@JsonProperty("description") val description: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatCharacterBackgroundRequest(
|
||||||
|
@JsonProperty("topic") val topic: String,
|
||||||
|
@JsonProperty("description") val description: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatCharacterMemoryRequest(
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("content") val content: String,
|
||||||
|
@JsonProperty("emotion") val emotion: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatCharacterRelationshipRequest(
|
||||||
|
@JsonProperty("personName") val personName: String,
|
||||||
|
@JsonProperty("relationshipName") val relationshipName: String,
|
||||||
|
@JsonProperty("description") val description: String,
|
||||||
|
@JsonProperty("importance") val importance: Int,
|
||||||
|
@JsonProperty("relationshipType") val relationshipType: String,
|
||||||
|
@JsonProperty("currentStatus") val currentStatus: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatCharacterRegisterRequest(
|
||||||
|
@JsonProperty("name") val name: String,
|
||||||
|
@JsonProperty("systemPrompt") val systemPrompt: String,
|
||||||
|
@JsonProperty("description") val description: String,
|
||||||
|
@JsonProperty("age") val age: String?,
|
||||||
|
@JsonProperty("gender") val gender: String?,
|
||||||
|
@JsonProperty("mbti") val mbti: String?,
|
||||||
|
@JsonProperty("speechPattern") val speechPattern: String?,
|
||||||
|
@JsonProperty("speechStyle") val speechStyle: String?,
|
||||||
|
@JsonProperty("appearance") val appearance: String?,
|
||||||
|
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||||
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
|
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||||
|
@JsonProperty("characterType") val characterType: String? = null,
|
||||||
|
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
||||||
|
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
||||||
|
@JsonProperty("values") val values: List<String> = emptyList(),
|
||||||
|
@JsonProperty("goals") val goals: List<String> = emptyList(),
|
||||||
|
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest> = emptyList(),
|
||||||
|
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest> = emptyList(),
|
||||||
|
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest> = emptyList(),
|
||||||
|
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ExternalApiResponse(
|
||||||
|
@JsonProperty("success") val success: Boolean,
|
||||||
|
@JsonProperty("data") val data: ExternalApiData? = null,
|
||||||
|
@JsonProperty("message") val message: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ExternalApiData(
|
||||||
|
@JsonProperty("id") val id: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatCharacterUpdateRequest(
|
||||||
|
@JsonProperty("id") val id: Long,
|
||||||
|
@JsonProperty("name") val name: String? = null,
|
||||||
|
@JsonProperty("systemPrompt") val systemPrompt: String? = null,
|
||||||
|
@JsonProperty("description") val description: String? = null,
|
||||||
|
@JsonProperty("age") val age: String? = null,
|
||||||
|
@JsonProperty("gender") val gender: String? = null,
|
||||||
|
@JsonProperty("mbti") val mbti: String? = null,
|
||||||
|
@JsonProperty("speechPattern") val speechPattern: String? = null,
|
||||||
|
@JsonProperty("speechStyle") val speechStyle: String? = null,
|
||||||
|
@JsonProperty("appearance") val appearance: String? = null,
|
||||||
|
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||||
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
|
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||||
|
@JsonProperty("characterType") val characterType: String? = null,
|
||||||
|
@JsonProperty("isActive") val isActive: Boolean? = null,
|
||||||
|
@JsonProperty("tags") val tags: List<String>? = null,
|
||||||
|
@JsonProperty("hobbies") val hobbies: List<String>? = null,
|
||||||
|
@JsonProperty("values") val values: List<String>? = null,
|
||||||
|
@JsonProperty("goals") val goals: List<String>? = null,
|
||||||
|
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest>? = null,
|
||||||
|
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest>? = null,
|
||||||
|
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
|
||||||
|
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest>? = null
|
||||||
|
)
|
@@ -0,0 +1,62 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
data class ChatCharacterListResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val description: String,
|
||||||
|
val gender: String?,
|
||||||
|
val age: Int?,
|
||||||
|
val mbti: String?,
|
||||||
|
val speechStyle: String?,
|
||||||
|
val speechPattern: String?,
|
||||||
|
val tags: List<String>,
|
||||||
|
val createdAt: String?,
|
||||||
|
val updatedAt: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
private val seoulZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
|
||||||
|
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterListResponse {
|
||||||
|
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${chatCharacter.imagePath}"
|
||||||
|
} else {
|
||||||
|
chatCharacter.imagePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC에서 Asia/Seoul로 시간대 변환 및 문자열 포맷팅
|
||||||
|
val createdAtStr = chatCharacter.createdAt?.atZone(ZoneId.of("UTC"))
|
||||||
|
?.withZoneSameInstant(seoulZoneId)
|
||||||
|
?.format(formatter)
|
||||||
|
|
||||||
|
val updatedAtStr = chatCharacter.updatedAt?.atZone(ZoneId.of("UTC"))
|
||||||
|
?.withZoneSameInstant(seoulZoneId)
|
||||||
|
?.format(formatter)
|
||||||
|
|
||||||
|
return ChatCharacterListResponse(
|
||||||
|
id = chatCharacter.id!!,
|
||||||
|
name = chatCharacter.name,
|
||||||
|
imageUrl = fullImagePath,
|
||||||
|
description = chatCharacter.description,
|
||||||
|
gender = chatCharacter.gender,
|
||||||
|
age = chatCharacter.age,
|
||||||
|
mbti = chatCharacter.mbti,
|
||||||
|
speechStyle = chatCharacter.speechStyle,
|
||||||
|
speechPattern = chatCharacter.speechPattern,
|
||||||
|
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
||||||
|
createdAt = createdAtStr,
|
||||||
|
updatedAt = updatedAtStr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ChatCharacterListPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<ChatCharacterListResponse>
|
||||||
|
)
|
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 검색 결과 페이지 응답 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterSearchListPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<ChatCharacterListResponse>
|
||||||
|
)
|
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 연결된 캐릭터 결과 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkChatCharacterResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val imagePath: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
|
||||||
|
return OriginalWorkChatCharacterResponse(
|
||||||
|
id = character.id!!,
|
||||||
|
name = character.name,
|
||||||
|
imagePath = character.imagePath?.let { "$imageHost/$it" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 연결된 캐릭터 결과 페이지 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkChatCharacterListPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<OriginalWorkChatCharacterResponse>
|
||||||
|
)
|
@@ -0,0 +1,170 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.image
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest
|
||||||
|
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.ImageBlurUtil
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/character/image")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminCharacterImageController(
|
||||||
|
private val imageService: CharacterImageService,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val imageCloudFront: ImageContentCloudFront,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.content-bucket}")
|
||||||
|
private val s3Bucket: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val freeBucket: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun list(@RequestParam characterId: Long) = run {
|
||||||
|
val expiration = 5L * 60L * 1000L // 5분
|
||||||
|
val list = imageService.listActiveByCharacter(characterId)
|
||||||
|
.map { img ->
|
||||||
|
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||||
|
AdminCharacterImageResponse.fromWithUrl(img, signedUrl)
|
||||||
|
}
|
||||||
|
ApiResponse.ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{imageId}")
|
||||||
|
fun detail(@PathVariable imageId: Long) = run {
|
||||||
|
val img = imageService.getById(imageId)
|
||||||
|
val expiration = 5L * 60L * 1000L // 5분
|
||||||
|
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||||
|
ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun register(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java)
|
||||||
|
|
||||||
|
// 업로드 키 생성
|
||||||
|
val s3Key = buildS3Key(characterId = request.characterId)
|
||||||
|
|
||||||
|
// 원본 저장 (content-bucket)
|
||||||
|
val imagePath = saveImageToBucket(s3Key, image, s3Bucket)
|
||||||
|
|
||||||
|
// 블러 생성 및 저장 (무료 이미지 버킷)
|
||||||
|
val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket)
|
||||||
|
|
||||||
|
imageService.registerImage(
|
||||||
|
characterId = request.characterId,
|
||||||
|
imagePath = imagePath,
|
||||||
|
blurImagePath = blurImagePath,
|
||||||
|
imagePriceCan = request.imagePriceCan,
|
||||||
|
messagePriceCan = request.messagePriceCan,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
triggers = request.triggers ?: emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{imageId}/triggers")
|
||||||
|
fun updateTriggers(
|
||||||
|
@PathVariable imageId: Long,
|
||||||
|
@RequestBody request: UpdateCharacterImageTriggersRequest
|
||||||
|
) = run {
|
||||||
|
if (!request.triggers.isNullOrEmpty()) {
|
||||||
|
imageService.updateTriggers(imageId, request.triggers)
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{imageId}")
|
||||||
|
fun delete(@PathVariable imageId: Long) = run {
|
||||||
|
imageService.deleteImage(imageId)
|
||||||
|
ApiResponse.ok(null, "이미지가 삭제되었습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/orders")
|
||||||
|
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
|
||||||
|
if (request.characterId == null) throw SodaException("characterId는 필수입니다")
|
||||||
|
imageService.updateOrders(request.characterId, request.ids)
|
||||||
|
ApiResponse.ok(null, "정렬 순서가 변경되었습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildS3Key(characterId: Long): String {
|
||||||
|
val fileName = generateFileName("character-image")
|
||||||
|
return "characters/$characterId/images/$fileName"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = filePath,
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||||
|
try {
|
||||||
|
// 멀티파트를 BufferedImage로 읽기
|
||||||
|
val bytes = image.bytes
|
||||||
|
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
||||||
|
?: throw SodaException("이미지 포맷을 인식할 수 없습니다.")
|
||||||
|
val blurred = ImageBlurUtil.blurFast(bimg)
|
||||||
|
|
||||||
|
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
||||||
|
val baos = java.io.ByteArrayOutputStream()
|
||||||
|
val format = when (image.contentType?.lowercase()) {
|
||||||
|
"image/png" -> "png"
|
||||||
|
else -> "jpg"
|
||||||
|
}
|
||||||
|
javax.imageio.ImageIO.write(blurred, format, baos)
|
||||||
|
val inputStream = java.io.ByteArrayInputStream(baos.toByteArray())
|
||||||
|
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = baos.size().toLong()
|
||||||
|
metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg"
|
||||||
|
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = inputStream,
|
||||||
|
bucket = bucket,
|
||||||
|
filePath = filePath,
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,53 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.image.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||||
|
|
||||||
|
// 요청 DTOs
|
||||||
|
|
||||||
|
data class RegisterCharacterImageRequest(
|
||||||
|
@JsonProperty("characterId") val characterId: Long,
|
||||||
|
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
|
||||||
|
@JsonProperty("messagePriceCan") val messagePriceCan: Long,
|
||||||
|
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
||||||
|
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateCharacterImageTriggersRequest(
|
||||||
|
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateCharacterImageOrdersRequest(
|
||||||
|
@JsonProperty("characterId") val characterId: Long?,
|
||||||
|
@JsonProperty("ids") val ids: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 응답 DTOs
|
||||||
|
|
||||||
|
data class AdminCharacterImageResponse(
|
||||||
|
val id: Long,
|
||||||
|
val characterId: Long,
|
||||||
|
val imagePriceCan: Long,
|
||||||
|
val messagePriceCan: Long,
|
||||||
|
val imageUrl: String,
|
||||||
|
val triggers: List<String>,
|
||||||
|
val isAdult: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse {
|
||||||
|
return base(entity, signedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse {
|
||||||
|
return AdminCharacterImageResponse(
|
||||||
|
id = entity.id!!,
|
||||||
|
characterId = entity.chatCharacter.id!!,
|
||||||
|
imagePriceCan = entity.imagePriceCan,
|
||||||
|
messagePriceCan = entity.messagePriceCan,
|
||||||
|
imageUrl = url,
|
||||||
|
triggers = entity.triggerMappings.map { it.tag.word },
|
||||||
|
isAdult = entity.isAdult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,78 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.domain.Sort
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminChatCharacterService(
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 활성화된 캐릭터 목록을 페이징하여 조회
|
||||||
|
*
|
||||||
|
* @param pageable 페이징 정보
|
||||||
|
* @return 페이징된 캐릭터 목록
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse {
|
||||||
|
// isActive가 true인 캐릭터만 조회
|
||||||
|
val page = chatCharacterRepository.findByIsActiveTrue(pageable)
|
||||||
|
|
||||||
|
// 페이지 정보 생성
|
||||||
|
val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) }
|
||||||
|
|
||||||
|
return ChatCharacterListPageResponse(
|
||||||
|
totalCount = page.totalElements,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 페이지 요청 생성
|
||||||
|
*
|
||||||
|
* @param page 페이지 번호 (0부터 시작)
|
||||||
|
* @param size 페이지 크기
|
||||||
|
* @return 페이지 요청 객체
|
||||||
|
*/
|
||||||
|
fun createDefaultPageRequest(page: Int = 0, size: Int = 20): PageRequest {
|
||||||
|
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 상세 정보 조회
|
||||||
|
*
|
||||||
|
* @param characterId 캐릭터 ID
|
||||||
|
* @param imageHost 이미지 호스트 URL
|
||||||
|
* @return 캐릭터 상세 정보
|
||||||
|
* @throws SodaException 캐릭터를 찾을 수 없는 경우
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||||
|
val chatCharacter = chatCharacterRepository.findById(characterId)
|
||||||
|
.orElseThrow { SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") }
|
||||||
|
|
||||||
|
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun searchCharacters(
|
||||||
|
searchTerm: String,
|
||||||
|
pageable: Pageable,
|
||||||
|
imageHost: String = ""
|
||||||
|
): Page<ChatCharacterListResponse> {
|
||||||
|
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
|
||||||
|
return characters.map { ChatCharacterListResponse.from(it, imageHost) }
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 등록 요청 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterBannerRegisterRequest(
|
||||||
|
// 캐릭터 ID
|
||||||
|
@JsonProperty("characterId") val characterId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 수정 요청 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterBannerUpdateRequest(
|
||||||
|
// 배너 ID
|
||||||
|
@JsonProperty("bannerId") val bannerId: Long,
|
||||||
|
|
||||||
|
// 캐릭터 ID (변경할 캐릭터)
|
||||||
|
@JsonProperty("characterId") val characterId: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 정렬 순서 일괄 변경 요청 DTO
|
||||||
|
*/
|
||||||
|
data class UpdateBannerOrdersRequest(
|
||||||
|
// 배너 ID 목록 (순서대로 정렬됨)
|
||||||
|
@JsonProperty("ids") val ids: List<Long>
|
||||||
|
)
|
@@ -0,0 +1,32 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.dto
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 응답 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterBannerResponse(
|
||||||
|
val id: Long,
|
||||||
|
val imagePath: String,
|
||||||
|
val characterId: Long,
|
||||||
|
val characterName: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse {
|
||||||
|
return ChatCharacterBannerResponse(
|
||||||
|
id = banner.id!!,
|
||||||
|
imagePath = "$imageHost/${banner.imagePath}",
|
||||||
|
characterId = banner.chatCharacter.id!!,
|
||||||
|
characterName = banner.chatCharacter.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 목록 페이지 응답 DTO
|
||||||
|
*/
|
||||||
|
data class ChatCharacterBannerListPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<ChatCharacterBannerResponse>
|
||||||
|
)
|
@@ -0,0 +1,199 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.original
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작(오리지널 작품) 관리자 API
|
||||||
|
* - 원작 등록/수정/삭제
|
||||||
|
* - 원작과 캐릭터 연결(배정) 및 해제
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/original")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminOriginalWorkController(
|
||||||
|
private val originalWorkService: AdminOriginalWorkService,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val s3Bucket: String,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 등록
|
||||||
|
* - 이미지 파일과 JSON 요청을 멀티파트로 받는다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun register(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java)
|
||||||
|
|
||||||
|
// 서비스 계층을 통해 원작을 생성
|
||||||
|
val saved = originalWorkService.createOriginalWork(request)
|
||||||
|
|
||||||
|
// 이미지 업로드 후 이미지 경로 업데이트
|
||||||
|
val imagePath = uploadImage(saved.id!!, image)
|
||||||
|
originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath)
|
||||||
|
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 수정
|
||||||
|
* - 이미지가 있으면 교체, 없으면 유지
|
||||||
|
*/
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun update(
|
||||||
|
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java)
|
||||||
|
|
||||||
|
// 이미지가 전달된 경우 먼저 업로드하여 경로를 생성
|
||||||
|
val imagePath = if (image != null && !image.isEmpty) {
|
||||||
|
uploadImage(request.id, image)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
originalWorkService.updateOriginalWork(request, imagePath)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 삭제
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
fun delete(@PathVariable id: Long) = run {
|
||||||
|
originalWorkService.deleteOriginalWork(id)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 목록(페이징)
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun list(
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageRes = originalWorkService.getOriginalWorkPage(page, size)
|
||||||
|
val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) }
|
||||||
|
ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 검색(관리자)
|
||||||
|
* - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외
|
||||||
|
* - 페이징 제거: 전체 목록 반환
|
||||||
|
*/
|
||||||
|
@GetMapping("/search")
|
||||||
|
fun search(
|
||||||
|
@RequestParam("searchTerm") searchTerm: String
|
||||||
|
) = run {
|
||||||
|
val list = originalWorkService.searchOriginalWorksAll(searchTerm)
|
||||||
|
val content = list.map { OriginalWorkResponse.from(it, imageHost) }
|
||||||
|
ApiResponse.ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 상세
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun detail(@PathVariable id: Long) = run {
|
||||||
|
ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작에 기존 캐릭터들을 배정
|
||||||
|
* - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 이 원작으로 설정
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/assign-characters")
|
||||||
|
fun assignCharacters(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestBody body: OriginalWorkAssignCharactersRequest
|
||||||
|
) = run {
|
||||||
|
originalWorkService.assignCharacters(id, body.characterIds)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작에서 캐릭터들 해제
|
||||||
|
* - 캐릭터들의 originalWork를 null로 설정
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/unassign-characters")
|
||||||
|
fun unassignCharacters(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestBody body: OriginalWorkAssignCharactersRequest
|
||||||
|
) = run {
|
||||||
|
originalWorkService.unassignCharacters(id, body.characterIds)
|
||||||
|
ApiResponse.ok(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회
|
||||||
|
* - 활성 캐릭터만 포함
|
||||||
|
* - 응답 항목: 캐릭터 이미지(URL), 이름
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/characters")
|
||||||
|
fun listCharactersOfOriginal(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
|
||||||
|
val content = pageRes.content.map { OriginalWorkChatCharacterResponse.from(it, imageHost) }
|
||||||
|
ApiResponse.ok(
|
||||||
|
OriginalWorkChatCharacterListPageResponse(
|
||||||
|
totalCount = pageRes.totalElements,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이미지 업로드 공통 처리 */
|
||||||
|
private fun uploadImage(originalWorkId: Long, image: MultipartFile): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = s3Bucket,
|
||||||
|
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,95 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.original.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 등록 요청 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkRegisterRequest(
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("contentType") val contentType: String,
|
||||||
|
@JsonProperty("category") val category: String,
|
||||||
|
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
||||||
|
@JsonProperty("description") val description: String = "",
|
||||||
|
@JsonProperty("originalWork") val originalWork: String? = null,
|
||||||
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
|
@JsonProperty("writer") val writer: String? = null,
|
||||||
|
@JsonProperty("studio") val studio: String? = null,
|
||||||
|
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
||||||
|
@JsonProperty("tags") val tags: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 수정 요청 DTO (부분 수정 가능)
|
||||||
|
*/
|
||||||
|
data class OriginalWorkUpdateRequest(
|
||||||
|
@JsonProperty("id") val id: Long,
|
||||||
|
@JsonProperty("title") val title: String? = null,
|
||||||
|
@JsonProperty("contentType") val contentType: String? = null,
|
||||||
|
@JsonProperty("category") val category: String? = null,
|
||||||
|
@JsonProperty("isAdult") val isAdult: Boolean? = null,
|
||||||
|
@JsonProperty("description") val description: String? = null,
|
||||||
|
@JsonProperty("originalWork") val originalWork: String? = null,
|
||||||
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
|
@JsonProperty("writer") val writer: String? = null,
|
||||||
|
@JsonProperty("studio") val studio: String? = null,
|
||||||
|
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
||||||
|
@JsonProperty("tags") val tags: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작 상세/목록 응답 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkResponse(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val contentType: String,
|
||||||
|
val category: String,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val description: String,
|
||||||
|
val originalWork: String?,
|
||||||
|
val originalLink: String?,
|
||||||
|
val writer: String?,
|
||||||
|
val studio: String?,
|
||||||
|
val originalLinks: List<String>,
|
||||||
|
val tags: List<String>,
|
||||||
|
val imageUrl: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkResponse {
|
||||||
|
val fullImagePath = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
|
"$imageHost/${entity.imagePath}"
|
||||||
|
} else {
|
||||||
|
entity.imagePath
|
||||||
|
}
|
||||||
|
return OriginalWorkResponse(
|
||||||
|
id = entity.id!!,
|
||||||
|
title = entity.title,
|
||||||
|
contentType = entity.contentType,
|
||||||
|
category = entity.category,
|
||||||
|
isAdult = entity.isAdult,
|
||||||
|
description = entity.description,
|
||||||
|
originalWork = entity.originalWork,
|
||||||
|
originalLink = entity.originalLink,
|
||||||
|
writer = entity.writer,
|
||||||
|
studio = entity.studio,
|
||||||
|
originalLinks = entity.originalLinks.map { it.url },
|
||||||
|
tags = entity.tagMappings.map { it.tag.tag },
|
||||||
|
imageUrl = fullImagePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class OriginalWorkPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<OriginalWorkResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작-캐릭터 연결/해제 요청 DTO
|
||||||
|
*/
|
||||||
|
data class OriginalWorkAssignCharactersRequest(
|
||||||
|
@JsonProperty("characterIds") val characterIds: List<Long>
|
||||||
|
)
|
@@ -0,0 +1,213 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.chat.original.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.data.domain.Sort
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작(오리지널 작품) 관련 관리자 서비스
|
||||||
|
* - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class AdminOriginalWorkService(
|
||||||
|
private val originalWorkRepository: OriginalWorkRepository,
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
private val originalWorkTagRepository: OriginalWorkTagRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 원작 등록 (중복 제목 방지 포함) */
|
||||||
|
@Transactional
|
||||||
|
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
|
||||||
|
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
|
||||||
|
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
|
||||||
|
}
|
||||||
|
val entity = OriginalWork(
|
||||||
|
title = request.title,
|
||||||
|
contentType = request.contentType,
|
||||||
|
category = request.category,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
description = request.description,
|
||||||
|
originalWork = request.originalWork,
|
||||||
|
originalLink = request.originalLink,
|
||||||
|
writer = request.writer,
|
||||||
|
studio = request.studio
|
||||||
|
)
|
||||||
|
// 링크 리스트 생성
|
||||||
|
request.originalLinks?.filter { it.isNotBlank() }?.forEach { link ->
|
||||||
|
entity.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = entity))
|
||||||
|
}
|
||||||
|
// 태그 매핑 생성 (기존 태그 재사용)
|
||||||
|
request.tags?.let { tags ->
|
||||||
|
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
||||||
|
normalized.forEach { t ->
|
||||||
|
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
||||||
|
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalWorkRepository.save(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
||||||
|
@Transactional
|
||||||
|
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
|
||||||
|
request.title?.let { ow.title = it }
|
||||||
|
request.contentType?.let { ow.contentType = it }
|
||||||
|
request.category?.let { ow.category = it }
|
||||||
|
request.isAdult?.let { ow.isAdult = it }
|
||||||
|
request.description?.let { ow.description = it }
|
||||||
|
request.originalWork?.let { ow.originalWork = it }
|
||||||
|
request.originalLink?.let { ow.originalLink = it }
|
||||||
|
request.writer?.let { ow.writer = it }
|
||||||
|
request.studio?.let { ow.studio = it }
|
||||||
|
// 링크 리스트가 전달되면 기존 것을 교체
|
||||||
|
request.originalLinks?.let { links ->
|
||||||
|
ow.originalLinks.clear()
|
||||||
|
links.filter { it.isNotBlank() }.forEach { link ->
|
||||||
|
ow.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = ow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 태그 변경사항만 반영 (요청이 null이면 변경 없음)
|
||||||
|
request.tags?.let { tags ->
|
||||||
|
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
||||||
|
val current = ow.tagMappings.map { it.tag.tag }.toSet()
|
||||||
|
val toAdd = normalized.minus(current)
|
||||||
|
val toRemove = current.minus(normalized)
|
||||||
|
|
||||||
|
if (toRemove.isNotEmpty()) {
|
||||||
|
val itr = ow.tagMappings.iterator()
|
||||||
|
while (itr.hasNext()) {
|
||||||
|
val m = itr.next()
|
||||||
|
if (toRemove.contains(m.tag.tag)) {
|
||||||
|
itr.remove() // orphanRemoval=true로 매핑 삭제
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toAdd.isNotEmpty()) {
|
||||||
|
toAdd.forEach { t ->
|
||||||
|
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
||||||
|
ow.tagMappings.add(OriginalWorkTagMapping(originalWork = ow, tag = tagEntity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imagePath != null) {
|
||||||
|
ow.imagePath = imagePath
|
||||||
|
}
|
||||||
|
return originalWorkRepository.save(ow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 이미지 경로만 별도 갱신 */
|
||||||
|
@Transactional
|
||||||
|
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
ow.imagePath = imagePath
|
||||||
|
return originalWorkRepository.save(ow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 삭제 (소프트 삭제) */
|
||||||
|
@Transactional
|
||||||
|
fun deleteOriginalWork(id: Long) {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
|
||||||
|
ow.isDeleted = true
|
||||||
|
originalWorkRepository.save(ow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 상세 조회 (소프트 삭제 제외) */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getOriginalWork(id: Long): OriginalWork {
|
||||||
|
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 페이징 조회 */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> {
|
||||||
|
val safePage = if (page < 0) 0 else page
|
||||||
|
val safeSize = when {
|
||||||
|
size <= 0 -> 20
|
||||||
|
size > 100 -> 100
|
||||||
|
else -> size
|
||||||
|
}
|
||||||
|
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||||
|
return originalWorkRepository.findByIsDeletedFalse(pageable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
|
||||||
|
// 원작 존재 및 소프트 삭제 여부 확인
|
||||||
|
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
|
||||||
|
val safePage = if (page < 0) 0 else page
|
||||||
|
val safeSize = when {
|
||||||
|
size <= 0 -> 20
|
||||||
|
size > 100 -> 100
|
||||||
|
else -> size
|
||||||
|
}
|
||||||
|
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||||
|
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
|
||||||
|
return originalWorkRepository.searchNoPaging(searchTerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작에 기존 캐릭터들을 배정 */
|
||||||
|
@Transactional
|
||||||
|
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
if (characterIds.isEmpty()) return
|
||||||
|
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||||
|
characters.forEach { it.originalWork = ow }
|
||||||
|
chatCharacterRepository.saveAll(characters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 원작에서 캐릭터들 해제 */
|
||||||
|
@Transactional
|
||||||
|
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||||
|
// 원작 존재 확인 (소프트 삭제 제외)
|
||||||
|
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
if (characterIds.isEmpty()) return
|
||||||
|
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||||
|
characters.forEach { it.originalWork = null }
|
||||||
|
chatCharacterRepository.saveAll(characters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단일 캐릭터를 지정 원작에 배정 */
|
||||||
|
@Transactional
|
||||||
|
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
|
||||||
|
val character = chatCharacterRepository.findById(characterId)
|
||||||
|
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
|
||||||
|
|
||||||
|
if (originalWorkId == 0L) {
|
||||||
|
character.originalWork = null
|
||||||
|
} else {
|
||||||
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
|
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||||
|
character.originalWork = ow
|
||||||
|
}
|
||||||
|
|
||||||
|
chatCharacterRepository.save(character)
|
||||||
|
}
|
||||||
|
}
|
@@ -140,6 +140,7 @@ class AdminAudioContentQueryRepositoryImpl(
|
|||||||
audioContent.duration.isNotNull
|
audioContent.duration.isNotNull
|
||||||
.and(audioContent.member.isNotNull)
|
.and(audioContent.member.isNotNull)
|
||||||
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
|
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
|
||||||
|
.and(audioContentHashTag.isActive.isTrue)
|
||||||
)
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
@@ -38,6 +38,7 @@ class AdminRecommendSeriesQueryRepositoryImpl(
|
|||||||
.and(series.isActive.isTrue)
|
.and(series.isActive.isTrue)
|
||||||
.and(recommendSeries.isFree.eq(isFree))
|
.and(recommendSeries.isFree.eq(isFree))
|
||||||
)
|
)
|
||||||
|
.orderBy(recommendSeries.orders.asc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -156,8 +156,8 @@ class AdminEventBannerService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!link.isNullOrBlank() && event.link != link) {
|
if (event.link != link) {
|
||||||
event.link = link
|
event.link = if (link.isNullOrBlank()) null else link
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!title.isNullOrBlank() && event.title != title) {
|
if (!title.isNullOrBlank() && event.title != title) {
|
||||||
|
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.member
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberProvider
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
@@ -98,6 +99,13 @@ class AdminMemberService(
|
|||||||
MemberRole.BOT -> "봇"
|
MemberRole.BOT -> "봇"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val loginType = when (it.provider) {
|
||||||
|
MemberProvider.EMAIL -> "이메일"
|
||||||
|
MemberProvider.KAKAO -> "카카오"
|
||||||
|
MemberProvider.GOOGLE -> "구글"
|
||||||
|
MemberProvider.APPLE -> "애플"
|
||||||
|
}
|
||||||
|
|
||||||
val signUpDate = it.createdAt!!
|
val signUpDate = it.createdAt!!
|
||||||
.atZone(ZoneId.of("UTC"))
|
.atZone(ZoneId.of("UTC"))
|
||||||
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||||
@@ -122,6 +130,7 @@ class AdminMemberService(
|
|||||||
"$cloudFrontHost/profile/default-profile.png"
|
"$cloudFrontHost/profile/default-profile.png"
|
||||||
},
|
},
|
||||||
userType = userType,
|
userType = userType,
|
||||||
|
loginType = loginType,
|
||||||
container = it.container,
|
container = it.container,
|
||||||
auth = it.auth != null,
|
auth = it.auth != null,
|
||||||
signUpDate = signUpDate,
|
signUpDate = signUpDate,
|
||||||
|
@@ -11,6 +11,7 @@ data class GetAdminMemberListResponseItem(
|
|||||||
val nickname: String,
|
val nickname: String,
|
||||||
val profileUrl: String,
|
val profileUrl: String,
|
||||||
val userType: String,
|
val userType: String,
|
||||||
|
val loginType: String,
|
||||||
val container: String,
|
val container: String,
|
||||||
val auth: Boolean,
|
val auth: Boolean,
|
||||||
val signUpDate: String,
|
val signUpDate: String,
|
||||||
|
@@ -0,0 +1,45 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.point
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.point.PointRewardPolicy
|
||||||
|
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||||
|
import kr.co.vividnext.sodalive.useraction.PolicyType
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
data class CreatePointRewardPolicyRequest(
|
||||||
|
val title: String,
|
||||||
|
val policyType: PolicyType,
|
||||||
|
val actionType: ActionType,
|
||||||
|
val threshold: Int,
|
||||||
|
val availableCount: Int,
|
||||||
|
val pointAmount: Int,
|
||||||
|
val startDate: String,
|
||||||
|
val endDate: String
|
||||||
|
) {
|
||||||
|
fun toEntity(): PointRewardPolicy {
|
||||||
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
|
|
||||||
|
return PointRewardPolicy(
|
||||||
|
title = title,
|
||||||
|
policyType = policyType,
|
||||||
|
actionType = actionType,
|
||||||
|
threshold = threshold,
|
||||||
|
availableCount = availableCount,
|
||||||
|
pointAmount = pointAmount,
|
||||||
|
startDate = LocalDateTime.parse(startDate, dateTimeFormatter)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
|
.toLocalDateTime(),
|
||||||
|
endDate = if (endDate.isNotBlank()) {
|
||||||
|
LocalDateTime.parse(endDate, dateTimeFormatter).withSecond(59)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
|
.toLocalDateTime()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
isActive = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,23 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.point
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||||
|
import kr.co.vividnext.sodalive.useraction.PolicyType
|
||||||
|
|
||||||
|
data class GetPointRewardPolicyListResponse(
|
||||||
|
val totalCount: Int,
|
||||||
|
val items: List<GetPointRewardPolicyListItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GetPointRewardPolicyListItem @QueryProjection constructor(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val policyType: PolicyType,
|
||||||
|
val actionType: ActionType,
|
||||||
|
val threshold: Int,
|
||||||
|
val availableCount: Int,
|
||||||
|
val pointAmount: Int,
|
||||||
|
val startDate: String,
|
||||||
|
val endDate: String,
|
||||||
|
val isActive: Boolean
|
||||||
|
)
|
@@ -0,0 +1,8 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.point
|
||||||
|
|
||||||
|
data class ModifyPointRewardPolicyRequest(
|
||||||
|
val title: String?,
|
||||||
|
val startDate: String?,
|
||||||
|
val endDate: String?,
|
||||||
|
val isActive: Boolean?
|
||||||
|
)
|
@@ -0,0 +1,36 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.point
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/point-policies")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class PointPolicyController(private val service: PointPolicyService) {
|
||||||
|
@GetMapping
|
||||||
|
fun getAll(pageable: Pageable) = ApiResponse.ok(
|
||||||
|
service.getAll(
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
fun create(
|
||||||
|
@RequestBody request: CreatePointRewardPolicyRequest
|
||||||
|
) = ApiResponse.ok(service.create(request))
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
fun update(
|
||||||
|
@PathVariable id: Long,
|
||||||
|
@RequestBody request: ModifyPointRewardPolicyRequest
|
||||||
|
) = ApiResponse.ok(service.update(id, request))
|
||||||
|
}
|
@@ -0,0 +1,59 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.point
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.point.PointRewardPolicy
|
||||||
|
import kr.co.vividnext.sodalive.point.QPointRewardPolicy.pointRewardPolicy
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface PointPolicyRepository : JpaRepository<PointRewardPolicy, Long>, PointPolicyQueryRepository
|
||||||
|
|
||||||
|
interface PointPolicyQueryRepository {
|
||||||
|
fun getTotalCount(): Int
|
||||||
|
fun getAll(offset: Long, limit: Long): List<GetPointRewardPolicyListItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
class PointPolicyQueryRepositoryImpl(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) : PointPolicyQueryRepository {
|
||||||
|
override fun getTotalCount(): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(pointRewardPolicy.id)
|
||||||
|
.from(pointRewardPolicy)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAll(offset: Long, limit: Long): List<GetPointRewardPolicyListItem> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetPointRewardPolicyListItem(
|
||||||
|
pointRewardPolicy.id,
|
||||||
|
pointRewardPolicy.title,
|
||||||
|
pointRewardPolicy.policyType,
|
||||||
|
pointRewardPolicy.actionType,
|
||||||
|
pointRewardPolicy.threshold,
|
||||||
|
pointRewardPolicy.availableCount,
|
||||||
|
pointRewardPolicy.pointAmount,
|
||||||
|
getFormattedDate(pointRewardPolicy.startDate),
|
||||||
|
getFormattedDate(pointRewardPolicy.endDate),
|
||||||
|
pointRewardPolicy.isActive
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(pointRewardPolicy)
|
||||||
|
.orderBy(pointRewardPolicy.isActive.desc(), pointRewardPolicy.startDate.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
||||||
|
return Expressions.stringTemplate(
|
||||||
|
"COALESCE(DATE_FORMAT(CONVERT_TZ({0}, 'UTC', 'Asia/Seoul'), '%Y-%m-%d %H:%i'), '')",
|
||||||
|
dateTimePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,55 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.point
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class PointPolicyService(private val repository: PointPolicyRepository) {
|
||||||
|
fun getAll(offset: Long, limit: Long): GetPointRewardPolicyListResponse {
|
||||||
|
val totalCount = repository.getTotalCount()
|
||||||
|
val items = repository.getAll(offset, limit)
|
||||||
|
|
||||||
|
return GetPointRewardPolicyListResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun create(request: CreatePointRewardPolicyRequest) {
|
||||||
|
val pointPolicy = request.toEntity()
|
||||||
|
repository.save(pointPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun update(id: Long, request: ModifyPointRewardPolicyRequest) {
|
||||||
|
val pointPolicy = repository.findByIdOrNull(id)
|
||||||
|
?: throw SodaException("잘못된 접근입니다.")
|
||||||
|
|
||||||
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
|
|
||||||
|
if (request.title != null) {
|
||||||
|
pointPolicy.title = request.title
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.startDate != null) {
|
||||||
|
pointPolicy.startDate = LocalDateTime.parse(request.startDate, dateTimeFormatter)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
|
.toLocalDateTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.endDate != null) {
|
||||||
|
pointPolicy.endDate = LocalDateTime.parse(request.endDate, dateTimeFormatter).withSecond(59)
|
||||||
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
|
.toLocalDateTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isActive != null) {
|
||||||
|
pointPolicy.isActive = request.isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.admin.statistics.ad
|
|||||||
import com.querydsl.core.types.dsl.CaseBuilder
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
import com.querydsl.core.types.dsl.DateTimePath
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.core.types.dsl.NumberExpression
|
|
||||||
import com.querydsl.core.types.dsl.StringTemplate
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||||
@@ -46,6 +45,12 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.otherwise(0)
|
.otherwise(0)
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
|
val launchCount = CaseBuilder()
|
||||||
|
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.APP_LAUNCH))
|
||||||
|
.then(1)
|
||||||
|
.otherwise(0)
|
||||||
|
.sum()
|
||||||
|
|
||||||
val loginCount = CaseBuilder()
|
val loginCount = CaseBuilder()
|
||||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.LOGIN))
|
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.LOGIN))
|
||||||
.then(1)
|
.then(1)
|
||||||
@@ -61,7 +66,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val firstPaymentTotalAmount = CaseBuilder()
|
val firstPaymentTotalAmount = CaseBuilder()
|
||||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(Expressions.constant(0.0))
|
.otherwise(0.toBigDecimal())
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
val repeatPaymentCount = CaseBuilder()
|
val repeatPaymentCount = CaseBuilder()
|
||||||
@@ -73,7 +78,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val repeatPaymentTotalAmount = CaseBuilder()
|
val repeatPaymentTotalAmount = CaseBuilder()
|
||||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(Expressions.constant(0.0))
|
.otherwise(0.toBigDecimal())
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
val allPaymentCount = CaseBuilder()
|
val allPaymentCount = CaseBuilder()
|
||||||
@@ -91,7 +96,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||||
)
|
)
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(Expressions.constant(0.0))
|
.otherwise(0.toBigDecimal())
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
@@ -101,14 +106,15 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
adTrackingHistory.mediaGroup,
|
adTrackingHistory.mediaGroup,
|
||||||
adTrackingHistory.pid,
|
adTrackingHistory.pid,
|
||||||
adTrackingHistory.pidName,
|
adTrackingHistory.pidName,
|
||||||
|
launchCount,
|
||||||
loginCount,
|
loginCount,
|
||||||
signUpCount,
|
signUpCount,
|
||||||
firstPaymentCount,
|
firstPaymentCount,
|
||||||
roundedValueDecimalPlaces2(firstPaymentTotalAmount),
|
firstPaymentTotalAmount,
|
||||||
repeatPaymentCount,
|
repeatPaymentCount,
|
||||||
roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
|
repeatPaymentTotalAmount,
|
||||||
allPaymentCount,
|
allPaymentCount,
|
||||||
roundedValueDecimalPlaces2(allPaymentTotalAmount)
|
allPaymentTotalAmount
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(adTrackingHistory)
|
.from(adTrackingHistory)
|
||||||
@@ -141,13 +147,4 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
"%Y-%m-%d"
|
"%Y-%m-%d"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun roundedValueDecimalPlaces2(valueExpression: NumberExpression<Double>): NumberExpression<Double> {
|
|
||||||
return Expressions.numberTemplate(
|
|
||||||
Double::class.java,
|
|
||||||
"ROUND({0}, {1})",
|
|
||||||
valueExpression,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.statistics.ad
|
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class GetAdminAdStatisticsResponse(
|
data class GetAdminAdStatisticsResponse(
|
||||||
val totalCount: Int,
|
val totalCount: Int,
|
||||||
@@ -12,12 +13,13 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
|
|||||||
val mediaGroup: String,
|
val mediaGroup: String,
|
||||||
val pid: String,
|
val pid: String,
|
||||||
val pidName: String,
|
val pidName: String,
|
||||||
|
val launchCount: Int,
|
||||||
val loginCount: Int,
|
val loginCount: Int,
|
||||||
val signUpCount: Int,
|
val signUpCount: Int,
|
||||||
val firstPaymentCount: Int,
|
val firstPaymentCount: Int,
|
||||||
val firstPaymentTotalAmount: Double,
|
val firstPaymentTotalAmount: BigDecimal,
|
||||||
val repeatPaymentCount: Int,
|
val repeatPaymentCount: Int,
|
||||||
val repeatPaymentTotalAmount: Double,
|
val repeatPaymentTotalAmount: BigDecimal,
|
||||||
val allPaymentCount: Int,
|
val allPaymentCount: Int,
|
||||||
val allPaymentTotalAmount: Double
|
val allPaymentTotalAmount: BigDecimal
|
||||||
)
|
)
|
||||||
|
@@ -8,8 +8,10 @@ import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
|||||||
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
|
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
|
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberProvider
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
import kr.co.vividnext.sodalive.member.QSignOut.signOut
|
import kr.co.vividnext.sodalive.member.QSignOut.signOut
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.QAuth.auth
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@@ -27,6 +29,57 @@ class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory)
|
|||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getTotalSignUpEmailCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(member.id)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.createdAt.goe(startDate),
|
||||||
|
member.createdAt.loe(endDate),
|
||||||
|
member.provider.eq(MemberProvider.EMAIL)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTotalSignUpKakaoCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(member.id)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.createdAt.goe(startDate),
|
||||||
|
member.createdAt.loe(endDate),
|
||||||
|
member.provider.eq(MemberProvider.KAKAO)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTotalSignUpGoogleCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(member.id)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.createdAt.goe(startDate),
|
||||||
|
member.createdAt.loe(endDate),
|
||||||
|
member.provider.eq(MemberProvider.GOOGLE)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTotalAuthCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(auth.id)
|
||||||
|
.from(auth)
|
||||||
|
.where(
|
||||||
|
auth.createdAt.goe(startDate),
|
||||||
|
auth.createdAt.loe(endDate)
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
fun getTotalSignOutCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
fun getTotalSignOutCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(signOut.id)
|
.select(signOut.id)
|
||||||
@@ -79,6 +132,81 @@ class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory)
|
|||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getSignUpEmailCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QDateAndMemberCount(
|
||||||
|
getFormattedDate(member.createdAt),
|
||||||
|
member.id.countDistinct().castToNum(Int::class.java)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.createdAt.goe(startDate),
|
||||||
|
member.createdAt.loe(endDate),
|
||||||
|
member.provider.eq(MemberProvider.EMAIL)
|
||||||
|
)
|
||||||
|
.groupBy(getFormattedDate(member.createdAt))
|
||||||
|
.orderBy(getFormattedDate(member.createdAt).desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSignUpKakaoCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QDateAndMemberCount(
|
||||||
|
getFormattedDate(member.createdAt),
|
||||||
|
member.id.countDistinct().castToNum(Int::class.java)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.createdAt.goe(startDate),
|
||||||
|
member.createdAt.loe(endDate),
|
||||||
|
member.provider.eq(MemberProvider.KAKAO)
|
||||||
|
)
|
||||||
|
.groupBy(getFormattedDate(member.createdAt))
|
||||||
|
.orderBy(getFormattedDate(member.createdAt).desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSignUpGoogleCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QDateAndMemberCount(
|
||||||
|
getFormattedDate(member.createdAt),
|
||||||
|
member.id.countDistinct().castToNum(Int::class.java)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.createdAt.goe(startDate),
|
||||||
|
member.createdAt.loe(endDate),
|
||||||
|
member.provider.eq(MemberProvider.GOOGLE)
|
||||||
|
)
|
||||||
|
.groupBy(getFormattedDate(member.createdAt))
|
||||||
|
.orderBy(getFormattedDate(member.createdAt).desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAuthCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QDateAndMemberCount(
|
||||||
|
getFormattedDate(auth.createdAt),
|
||||||
|
auth.id.countDistinct().castToNum(Int::class.java)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(auth)
|
||||||
|
.where(
|
||||||
|
auth.createdAt.goe(startDate),
|
||||||
|
auth.createdAt.loe(endDate)
|
||||||
|
)
|
||||||
|
.groupBy(getFormattedDate(auth.createdAt))
|
||||||
|
.orderBy(getFormattedDate(auth.createdAt).desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
fun getSignOutCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
|
fun getSignOutCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
|
@@ -46,6 +46,19 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
|
|||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
val totalSignUpCount = repository.getTotalSignUpCount(startDate = startDateTime, endDate = endDateTime)
|
val totalSignUpCount = repository.getTotalSignUpCount(startDate = startDateTime, endDate = endDateTime)
|
||||||
|
val totalSignUpEmailCount = repository.getTotalSignUpEmailCount(
|
||||||
|
startDate = startDateTime,
|
||||||
|
endDate = endDateTime
|
||||||
|
)
|
||||||
|
val totalSignUpKakaoCount = repository.getTotalSignUpKakaoCount(
|
||||||
|
startDate = startDateTime,
|
||||||
|
endDate = endDateTime
|
||||||
|
)
|
||||||
|
val totalSignUpGoogleCount = repository.getTotalSignUpGoogleCount(
|
||||||
|
startDate = startDateTime,
|
||||||
|
endDate = endDateTime
|
||||||
|
)
|
||||||
|
val totalAuthCount = repository.getTotalAuthCount(startDate = startDateTime, endDate = endDateTime)
|
||||||
val totalSignOutCount = repository.getTotalSignOutCount(startDate = startDateTime, endDate = endDateTime)
|
val totalSignOutCount = repository.getTotalSignOutCount(startDate = startDateTime, endDate = endDateTime)
|
||||||
val totalPaymentMemberCount = repository.getPaymentMemberCount(startDate = startDateTime, endDate = endDateTime)
|
val totalPaymentMemberCount = repository.getPaymentMemberCount(startDate = startDateTime, endDate = endDateTime)
|
||||||
|
|
||||||
@@ -64,6 +77,26 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
|
|||||||
endDate = endDateTime
|
endDate = endDateTime
|
||||||
).associateBy({ it.date }, { it.memberCount })
|
).associateBy({ it.date }, { it.memberCount })
|
||||||
|
|
||||||
|
val signUpEmailCountInRange = repository.getSignUpEmailCountInRange(
|
||||||
|
startDate = startDateTime,
|
||||||
|
endDate = endDateTime
|
||||||
|
).associateBy({ it.date }, { it.memberCount })
|
||||||
|
|
||||||
|
val signUpKakaoCountInRange = repository.getSignUpKakaoCountInRange(
|
||||||
|
startDate = startDateTime,
|
||||||
|
endDate = endDateTime
|
||||||
|
).associateBy({ it.date }, { it.memberCount })
|
||||||
|
|
||||||
|
val signUpGoogleCountInRange = repository.getSignUpGoogleCountInRange(
|
||||||
|
startDate = startDateTime,
|
||||||
|
endDate = endDateTime
|
||||||
|
).associateBy({ it.date }, { it.memberCount })
|
||||||
|
|
||||||
|
val authCountInRange = repository.getAuthCountInRange(
|
||||||
|
startDate = startDateTime,
|
||||||
|
endDate = endDateTime
|
||||||
|
).associateBy({ it.date }, { it.memberCount })
|
||||||
|
|
||||||
val signOutCountInRange = repository.getSignOutCountInRange(
|
val signOutCountInRange = repository.getSignOutCountInRange(
|
||||||
startDate = startDateTime,
|
startDate = startDateTime,
|
||||||
endDate = endDateTime
|
endDate = endDateTime
|
||||||
@@ -83,7 +116,11 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
|
|||||||
val date = it.format(formatter)
|
val date = it.format(formatter)
|
||||||
GetMemberStatisticsItem(
|
GetMemberStatisticsItem(
|
||||||
date = date,
|
date = date,
|
||||||
|
authCount = authCountInRange[date] ?: 0,
|
||||||
signUpCount = signUpCountInRange[date] ?: 0,
|
signUpCount = signUpCountInRange[date] ?: 0,
|
||||||
|
signUpEmailCount = signUpEmailCountInRange[date] ?: 0,
|
||||||
|
signUpKakaoCount = signUpKakaoCountInRange[date] ?: 0,
|
||||||
|
signUpGoogleCount = signUpGoogleCountInRange[date] ?: 0,
|
||||||
signOutCount = signOutCountInRange[date] ?: 0,
|
signOutCount = signOutCountInRange[date] ?: 0,
|
||||||
paymentMemberCount = paymentMemberCountInRangeMap[date] ?: 0
|
paymentMemberCount = paymentMemberCountInRangeMap[date] ?: 0
|
||||||
)
|
)
|
||||||
@@ -92,7 +129,11 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
|
|||||||
|
|
||||||
return GetMemberStatisticsResponse(
|
return GetMemberStatisticsResponse(
|
||||||
totalCount = dateRange.totalDays,
|
totalCount = dateRange.totalDays,
|
||||||
|
totalAuthCount = totalAuthCount,
|
||||||
totalSignUpCount = totalSignUpCount,
|
totalSignUpCount = totalSignUpCount,
|
||||||
|
totalSignUpEmailCount = totalSignUpEmailCount,
|
||||||
|
totalSignUpKakaoCount = totalSignUpKakaoCount,
|
||||||
|
totalSignUpGoogleCount = totalSignUpGoogleCount,
|
||||||
totalSignOutCount = totalSignOutCount,
|
totalSignOutCount = totalSignOutCount,
|
||||||
totalPaymentMemberCount = totalPaymentMemberCount,
|
totalPaymentMemberCount = totalPaymentMemberCount,
|
||||||
items = items
|
items = items
|
||||||
|
@@ -2,7 +2,11 @@ package kr.co.vividnext.sodalive.admin.statistics.member
|
|||||||
|
|
||||||
data class GetMemberStatisticsResponse(
|
data class GetMemberStatisticsResponse(
|
||||||
val totalCount: Int,
|
val totalCount: Int,
|
||||||
|
val totalAuthCount: Int,
|
||||||
val totalSignUpCount: Int,
|
val totalSignUpCount: Int,
|
||||||
|
val totalSignUpEmailCount: Int,
|
||||||
|
val totalSignUpKakaoCount: Int,
|
||||||
|
val totalSignUpGoogleCount: Int,
|
||||||
val totalSignOutCount: Int,
|
val totalSignOutCount: Int,
|
||||||
val totalPaymentMemberCount: Int,
|
val totalPaymentMemberCount: Int,
|
||||||
val items: List<GetMemberStatisticsItem>
|
val items: List<GetMemberStatisticsItem>
|
||||||
@@ -10,7 +14,11 @@ data class GetMemberStatisticsResponse(
|
|||||||
|
|
||||||
data class GetMemberStatisticsItem(
|
data class GetMemberStatisticsItem(
|
||||||
val date: String,
|
val date: String,
|
||||||
|
val authCount: Int,
|
||||||
val signUpCount: Int,
|
val signUpCount: Int,
|
||||||
|
val signUpEmailCount: Int,
|
||||||
|
val signUpKakaoCount: Int,
|
||||||
|
val signUpGoogleCount: Int,
|
||||||
val signOutCount: Int,
|
val signOutCount: Int,
|
||||||
val paymentMemberCount: Int
|
val paymentMemberCount: Int
|
||||||
)
|
)
|
||||||
|
@@ -0,0 +1,28 @@
|
|||||||
|
package kr.co.vividnext.sodalive.api.home
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.audition.GetAuditionListItem
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||||
|
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
|
||||||
|
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
||||||
|
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
||||||
|
import kr.co.vividnext.sodalive.event.GetEventResponse
|
||||||
|
import kr.co.vividnext.sodalive.explorer.GetExplorerSectionCreatorResponse
|
||||||
|
import kr.co.vividnext.sodalive.live.room.GetRoomListResponse
|
||||||
|
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelResponse
|
||||||
|
|
||||||
|
data class GetHomeResponse(
|
||||||
|
val liveList: List<GetRoomListResponse>,
|
||||||
|
val creatorRanking: List<GetExplorerSectionCreatorResponse>,
|
||||||
|
val latestContentThemeList: List<String>,
|
||||||
|
val latestContentList: List<AudioContentMainItem>,
|
||||||
|
val bannerList: List<GetAudioContentBannerResponse>,
|
||||||
|
val eventBannerList: GetEventResponse,
|
||||||
|
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
|
||||||
|
val auditionList: List<GetAuditionListItem>,
|
||||||
|
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
|
||||||
|
val contentRanking: List<GetAudioContentRankingItem>,
|
||||||
|
val recommendChannelList: List<RecommendChannelResponse>,
|
||||||
|
val freeContentList: List<AudioContentMainItem>,
|
||||||
|
val curationList: List<GetContentCurationResponse>
|
||||||
|
)
|
@@ -0,0 +1,66 @@
|
|||||||
|
package kr.co.vividnext.sodalive.api.home
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/home")
|
||||||
|
class HomeController(private val service: HomeService) {
|
||||||
|
@GetMapping
|
||||||
|
fun fetchData(
|
||||||
|
@RequestParam timezone: String,
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
service.fetchData(
|
||||||
|
timezone = timezone,
|
||||||
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
member
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/latest-content")
|
||||||
|
fun getLatestContentByTheme(
|
||||||
|
@RequestParam("theme") theme: String,
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
service.getLatestContentByTheme(
|
||||||
|
theme = theme,
|
||||||
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
member
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/day-of-week-series")
|
||||||
|
fun getDayOfWeekSeriesList(
|
||||||
|
@RequestParam("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
service.getDayOfWeekSeriesList(
|
||||||
|
dayOfWeek = dayOfWeek,
|
||||||
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
member
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
265
src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt
Normal file
265
src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package kr.co.vividnext.sodalive.api.home
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.audition.AuditionService
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
||||||
|
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
||||||
|
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||||
|
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.event.GetEventResponse
|
||||||
|
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||||
|
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
||||||
|
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberService
|
||||||
|
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
|
||||||
|
import kr.co.vividnext.sodalive.rank.RankingRepository
|
||||||
|
import kr.co.vividnext.sodalive.rank.RankingService
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.temporal.TemporalAdjusters
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class HomeService(
|
||||||
|
private val memberService: MemberService,
|
||||||
|
private val liveRoomService: LiveRoomService,
|
||||||
|
private val auditionService: AuditionService,
|
||||||
|
private val seriesService: ContentSeriesService,
|
||||||
|
private val contentService: AudioContentService,
|
||||||
|
private val bannerService: AudioContentBannerService,
|
||||||
|
private val curationService: AudioContentCurationService,
|
||||||
|
private val contentThemeService: AudioContentThemeService,
|
||||||
|
private val recommendChannelService: RecommendChannelQueryService,
|
||||||
|
|
||||||
|
private val rankingService: RankingService,
|
||||||
|
private val rankingRepository: RankingRepository,
|
||||||
|
private val explorerQueryRepository: ExplorerQueryRepository,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
fun fetchData(
|
||||||
|
timezone: String,
|
||||||
|
isAdultContentVisible: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
member: Member?
|
||||||
|
): GetHomeResponse {
|
||||||
|
val memberId = member?.id
|
||||||
|
val isAdult = member?.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
|
val liveList = liveRoomService.getRoomList(
|
||||||
|
dateString = null,
|
||||||
|
status = LiveRoomStatus.NOW,
|
||||||
|
isAdultContentVisible = isAdultContentVisible,
|
||||||
|
pageable = Pageable.ofSize(10),
|
||||||
|
member = member,
|
||||||
|
timezone = timezone
|
||||||
|
)
|
||||||
|
|
||||||
|
val creatorRanking = rankingRepository
|
||||||
|
.getCreatorRankings()
|
||||||
|
.filter {
|
||||||
|
if (memberId != null) {
|
||||||
|
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.id!!)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map {
|
||||||
|
val followerCount = explorerQueryRepository.getNotificationUserIds(it.id!!).size
|
||||||
|
val follow = if (memberId != null) {
|
||||||
|
explorerQueryRepository.isFollow(it.id!!, memberId = memberId)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
it.toExplorerSectionCreator(imageHost, follow, followerCount = followerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
val latestContentThemeList = contentThemeService.getActiveThemeOfContent(
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType
|
||||||
|
)
|
||||||
|
|
||||||
|
val latestContentList = contentService.getLatestContentByTheme(
|
||||||
|
theme = latestContentThemeList,
|
||||||
|
contentType = contentType,
|
||||||
|
isFree = false,
|
||||||
|
isAdult = isAdult
|
||||||
|
).filter {
|
||||||
|
if (memberId != null) {
|
||||||
|
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventBannerList = GetEventResponse(
|
||||||
|
totalCount = 0,
|
||||||
|
eventList = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
val bannerList = bannerService.getBannerList(
|
||||||
|
tabId = 1,
|
||||||
|
memberId = member?.id,
|
||||||
|
isAdult = isAdult
|
||||||
|
)
|
||||||
|
|
||||||
|
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType
|
||||||
|
)
|
||||||
|
|
||||||
|
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
||||||
|
|
||||||
|
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
||||||
|
memberId = memberId,
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType,
|
||||||
|
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentDateTime = LocalDateTime.now()
|
||||||
|
val startDate = currentDateTime
|
||||||
|
.withHour(15)
|
||||||
|
.withMinute(0)
|
||||||
|
.withSecond(0)
|
||||||
|
.minusWeeks(1)
|
||||||
|
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||||
|
val endDate = startDate
|
||||||
|
.plusDays(6)
|
||||||
|
|
||||||
|
val contentRanking = rankingService.getContentRanking(
|
||||||
|
memberId = memberId,
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType,
|
||||||
|
startDate = startDate.minusDays(1),
|
||||||
|
endDate = endDate,
|
||||||
|
sortType = "매출"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO 오디오 북
|
||||||
|
|
||||||
|
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
||||||
|
memberId = memberId,
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType
|
||||||
|
)
|
||||||
|
|
||||||
|
val freeContentList = contentService.getLatestContentByTheme(
|
||||||
|
theme = contentThemeService.getActiveThemeOfContent(
|
||||||
|
isAdult = isAdult,
|
||||||
|
isFree = true,
|
||||||
|
contentType = contentType
|
||||||
|
),
|
||||||
|
contentType = contentType,
|
||||||
|
isFree = true,
|
||||||
|
isAdult = isAdult
|
||||||
|
).filter {
|
||||||
|
if (memberId != null) {
|
||||||
|
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val curationList = curationService.getContentCurationList(
|
||||||
|
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType,
|
||||||
|
memberId = memberId
|
||||||
|
)
|
||||||
|
|
||||||
|
return GetHomeResponse(
|
||||||
|
liveList = liveList,
|
||||||
|
creatorRanking = creatorRanking,
|
||||||
|
latestContentThemeList = latestContentThemeList,
|
||||||
|
latestContentList = latestContentList,
|
||||||
|
bannerList = bannerList,
|
||||||
|
eventBannerList = eventBannerList,
|
||||||
|
originalAudioDramaList = originalAudioDramaList,
|
||||||
|
auditionList = auditionList,
|
||||||
|
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
||||||
|
contentRanking = contentRanking,
|
||||||
|
recommendChannelList = recommendChannelList,
|
||||||
|
freeContentList = freeContentList,
|
||||||
|
curationList = curationList
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLatestContentByTheme(
|
||||||
|
theme: String,
|
||||||
|
isAdultContentVisible: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
member: Member?
|
||||||
|
): List<AudioContentMainItem> {
|
||||||
|
val memberId = member?.id
|
||||||
|
val isAdult = member?.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
|
val themeList = if (theme.isBlank()) {
|
||||||
|
contentThemeService.getActiveThemeOfContent(
|
||||||
|
isAdult = isAdult,
|
||||||
|
isFree = true,
|
||||||
|
contentType = contentType
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentService.getLatestContentByTheme(
|
||||||
|
theme = themeList,
|
||||||
|
contentType = contentType,
|
||||||
|
isFree = false,
|
||||||
|
isAdult = isAdult
|
||||||
|
).filter {
|
||||||
|
if (memberId != null) {
|
||||||
|
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDayOfWeekSeriesList(
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek,
|
||||||
|
isAdultContentVisible: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
member: Member?
|
||||||
|
): List<GetSeriesListResponse.SeriesListItem> {
|
||||||
|
val memberId = member?.id
|
||||||
|
val isAdult = member?.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
|
return seriesService.getDayOfWeekSeriesList(
|
||||||
|
memberId = memberId,
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType,
|
||||||
|
dayOfWeek = dayOfWeek
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek {
|
||||||
|
val systemTime = LocalDateTime.now()
|
||||||
|
val zoneId = ZoneId.of(timezone)
|
||||||
|
val zonedDateTime = systemTime.atZone(ZoneId.systemDefault()).withZoneSameInstant(zoneId)
|
||||||
|
|
||||||
|
val dayToSeriesPublishedDaysOfWeek = mapOf(
|
||||||
|
DayOfWeek.MONDAY to SeriesPublishedDaysOfWeek.MON,
|
||||||
|
DayOfWeek.TUESDAY to SeriesPublishedDaysOfWeek.TUE,
|
||||||
|
DayOfWeek.WEDNESDAY to SeriesPublishedDaysOfWeek.WED,
|
||||||
|
DayOfWeek.THURSDAY to SeriesPublishedDaysOfWeek.THU,
|
||||||
|
DayOfWeek.FRIDAY to SeriesPublishedDaysOfWeek.FRI,
|
||||||
|
DayOfWeek.SATURDAY to SeriesPublishedDaysOfWeek.SAT,
|
||||||
|
DayOfWeek.SUNDAY to SeriesPublishedDaysOfWeek.SUN
|
||||||
|
)
|
||||||
|
|
||||||
|
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,33 @@
|
|||||||
|
package kr.co.vividnext.sodalive.api.live
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/live")
|
||||||
|
class LiveApiController(
|
||||||
|
private val service: LiveApiService
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun fetchData(
|
||||||
|
@RequestParam timezone: String,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
service.fetchData(
|
||||||
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
timezone = timezone,
|
||||||
|
member = member
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,94 @@
|
|||||||
|
package kr.co.vividnext.sodalive.api.live
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||||
|
import kr.co.vividnext.sodalive.live.recommend.LiveRecommendService
|
||||||
|
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
||||||
|
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class LiveApiService(
|
||||||
|
private val liveService: LiveRoomService,
|
||||||
|
private val contentService: AudioContentService,
|
||||||
|
private val recommendService: LiveRecommendService,
|
||||||
|
private val creatorCommunityService: CreatorCommunityService,
|
||||||
|
|
||||||
|
private val blockMemberRepository: BlockMemberRepository
|
||||||
|
) {
|
||||||
|
fun fetchData(
|
||||||
|
isAdultContentVisible: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
timezone: String,
|
||||||
|
member: Member?
|
||||||
|
): LiveMainResponse {
|
||||||
|
val memberId = member?.id
|
||||||
|
val isAdult = member?.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
|
val liveOnAirRoomList = liveService.getRoomList(
|
||||||
|
dateString = null,
|
||||||
|
status = LiveRoomStatus.NOW,
|
||||||
|
isAdultContentVisible = isAdultContentVisible,
|
||||||
|
pageable = Pageable.ofSize(20),
|
||||||
|
member = member,
|
||||||
|
timezone = timezone
|
||||||
|
)
|
||||||
|
|
||||||
|
val communityPostList = if (memberId != null) {
|
||||||
|
creatorCommunityService.getLatestPostListFromCreatorsYouFollow(
|
||||||
|
timezone = timezone,
|
||||||
|
memberId = memberId,
|
||||||
|
isAdult = isAdult
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
val recommendLiveList = recommendService.getRecommendLive(member)
|
||||||
|
|
||||||
|
val latestFinishedLiveList = liveService.getLatestFinishedLive(member)
|
||||||
|
|
||||||
|
val replayLive = contentService.getLatestContentByTheme(
|
||||||
|
theme = listOf("다시듣기"),
|
||||||
|
contentType = contentType,
|
||||||
|
isFree = false,
|
||||||
|
isAdult = isAdult
|
||||||
|
)
|
||||||
|
.filter { content ->
|
||||||
|
if (memberId != null) {
|
||||||
|
!blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = content.creatorId)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val followingChannelList = if (memberId != null) {
|
||||||
|
recommendService.getFollowingChannelList(member)
|
||||||
|
} else {
|
||||||
|
listOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
val liveReservationRoomList = liveService.getRoomList(
|
||||||
|
dateString = null,
|
||||||
|
status = LiveRoomStatus.RESERVATION,
|
||||||
|
isAdultContentVisible = isAdultContentVisible,
|
||||||
|
pageable = Pageable.ofSize(10),
|
||||||
|
member = member,
|
||||||
|
timezone = timezone
|
||||||
|
)
|
||||||
|
|
||||||
|
return LiveMainResponse(
|
||||||
|
liveOnAirRoomList = liveOnAirRoomList,
|
||||||
|
communityPostList = communityPostList,
|
||||||
|
recommendLiveList = recommendLiveList,
|
||||||
|
latestFinishedLiveList = latestFinishedLiveList,
|
||||||
|
replayLive = replayLive,
|
||||||
|
followingChannelList = followingChannelList,
|
||||||
|
liveReservationRoomList = liveReservationRoomList
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,18 @@
|
|||||||
|
package kr.co.vividnext.sodalive.api.live
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse
|
||||||
|
import kr.co.vividnext.sodalive.live.recommend.GetRecommendChannelResponse
|
||||||
|
import kr.co.vividnext.sodalive.live.recommend.GetRecommendLiveResponse
|
||||||
|
import kr.co.vividnext.sodalive.live.room.GetLatestFinishedLiveResponse
|
||||||
|
import kr.co.vividnext.sodalive.live.room.GetRoomListResponse
|
||||||
|
|
||||||
|
data class LiveMainResponse(
|
||||||
|
val liveOnAirRoomList: List<GetRoomListResponse>,
|
||||||
|
val communityPostList: List<GetCommunityPostListResponse>,
|
||||||
|
val recommendLiveList: List<GetRecommendLiveResponse>,
|
||||||
|
val latestFinishedLiveList: List<GetLatestFinishedLiveResponse>,
|
||||||
|
val replayLive: List<AudioContentMainItem>,
|
||||||
|
val followingChannelList: List<GetRecommendChannelResponse>,
|
||||||
|
val liveReservationRoomList: List<GetRoomListResponse>
|
||||||
|
)
|
@@ -12,6 +12,7 @@ interface AuditionQueryRepository {
|
|||||||
fun getCompletedAuditionCount(isAdult: Boolean): Int
|
fun getCompletedAuditionCount(isAdult: Boolean): Int
|
||||||
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem>
|
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem>
|
||||||
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
|
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
|
||||||
|
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem>
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuditionQueryRepositoryImpl(
|
class AuditionQueryRepositoryImpl(
|
||||||
@@ -94,4 +95,27 @@ class AuditionQueryRepositoryImpl(
|
|||||||
.where(audition.id.eq(auditionId))
|
.where(audition.id.eq(auditionId))
|
||||||
.fetchFirst()
|
.fetchFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
|
||||||
|
var where = audition.isActive.isTrue
|
||||||
|
.and(audition.status.eq(AuditionStatus.IN_PROGRESS))
|
||||||
|
|
||||||
|
if (!isAdult) {
|
||||||
|
where = where.and(audition.isAdult.isFalse)
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAuditionListItem(
|
||||||
|
audition.id,
|
||||||
|
audition.title,
|
||||||
|
audition.imagePath.prepend("/").prepend(cloudFrontHost),
|
||||||
|
audition.status.eq(AuditionStatus.COMPLETED)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(audition)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(audition.status.desc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -28,4 +28,8 @@ class AuditionService(
|
|||||||
roleList = roleList
|
roleList = roleList
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
|
||||||
|
return repository.getInProgressAuditionList(isAdult)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -49,10 +49,12 @@ class AuditionApplicantQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(auditionApplicant.id)
|
.select(auditionApplicant.id)
|
||||||
.from(auditionApplicant)
|
.from(auditionApplicant)
|
||||||
|
.innerJoin(auditionApplicant.member, member)
|
||||||
.innerJoin(auditionApplicant.role, auditionRole)
|
.innerJoin(auditionApplicant.role, auditionRole)
|
||||||
.where(
|
.where(
|
||||||
auditionRole.id.eq(auditionRoleId),
|
auditionRole.id.eq(auditionRoleId),
|
||||||
auditionApplicant.isActive.isTrue
|
auditionApplicant.isActive.isTrue,
|
||||||
|
member.isActive.isTrue
|
||||||
)
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
@@ -87,7 +89,8 @@ class AuditionApplicantQueryRepositoryImpl(
|
|||||||
.leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id))
|
.leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id))
|
||||||
.where(
|
.where(
|
||||||
auditionRole.id.eq(auditionRoleId),
|
auditionRole.id.eq(auditionRoleId),
|
||||||
auditionApplicant.isActive.isTrue
|
auditionApplicant.isActive.isTrue,
|
||||||
|
member.isActive.isTrue
|
||||||
)
|
)
|
||||||
.groupBy(auditionApplicant.id)
|
.groupBy(auditionApplicant.id)
|
||||||
.orderBy(orderBy)
|
.orderBy(orderBy)
|
||||||
|
@@ -0,0 +1,48 @@
|
|||||||
|
package kr.co.vividnext.sodalive.aws.cloudfront
|
||||||
|
|
||||||
|
import com.amazonaws.services.cloudfront.CloudFrontUrlSigner
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지(CloudFront) 서명 URL 생성기
|
||||||
|
* - cloud.aws.cloud-front.* 설정을 사용
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class ImageContentCloudFront(
|
||||||
|
@Value("\${cloud.aws.content-cloud-front.host}")
|
||||||
|
private val cloudfrontDomain: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.content-cloud-front.private-key-file-path}")
|
||||||
|
private val privateKeyFilePath: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.content-cloud-front.key-pair-id}")
|
||||||
|
private val keyPairId: String
|
||||||
|
) {
|
||||||
|
fun generateSignedURL(
|
||||||
|
resourcePath: String,
|
||||||
|
expirationTimeMillis: Long
|
||||||
|
): String {
|
||||||
|
val privateKey = loadPrivateKey(privateKeyFilePath)
|
||||||
|
return CloudFrontUrlSigner.getSignedURLWithCannedPolicy(
|
||||||
|
"$cloudfrontDomain/$resourcePath",
|
||||||
|
keyPairId,
|
||||||
|
privateKey,
|
||||||
|
Date(System.currentTimeMillis() + expirationTimeMillis)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPrivateKey(resourceName: String): PrivateKey {
|
||||||
|
val path = Paths.get(resourceName)
|
||||||
|
val bytes = Files.readAllBytes(path)
|
||||||
|
val keySpec = PKCS8EncodedKeySpec(bytes)
|
||||||
|
val keyFactory = KeyFactory.getInstance("RSA")
|
||||||
|
return keyFactory.generatePrivate(keySpec)
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.can
|
package kr.co.vividnext.sodalive.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import javax.persistence.Column
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.EnumType
|
import javax.persistence.EnumType
|
||||||
import javax.persistence.Enumerated
|
import javax.persistence.Enumerated
|
||||||
@@ -10,7 +12,10 @@ data class Can(
|
|||||||
var title: String,
|
var title: String,
|
||||||
var can: Int,
|
var can: Int,
|
||||||
var rewardCan: Int,
|
var rewardCan: Int,
|
||||||
var price: Int,
|
@Column(precision = 10, scale = 4, nullable = false)
|
||||||
|
var price: BigDecimal,
|
||||||
|
@Column(length = 3, nullable = false, columnDefinition = "CHAR(3)")
|
||||||
|
var currency: String,
|
||||||
@Enumerated(value = EnumType.STRING)
|
@Enumerated(value = EnumType.STRING)
|
||||||
var status: CanStatus
|
var status: CanStatus
|
||||||
) : BaseEntity()
|
) : BaseEntity()
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.can
|
package kr.co.vividnext.sodalive.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.GeoCountry
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
@@ -9,13 +10,15 @@ import org.springframework.web.bind.annotation.GetMapping
|
|||||||
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.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/can")
|
@RequestMapping("/can")
|
||||||
class CanController(private val service: CanService) {
|
class CanController(private val service: CanService) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
fun getCans(request: HttpServletRequest): ApiResponse<List<CanResponse>> {
|
||||||
return ApiResponse.ok(service.getCans())
|
val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER
|
||||||
|
return ApiResponse.ok(service.getCans(geoCountry))
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
|
@@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository
|
|||||||
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
|
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
|
||||||
|
|
||||||
interface CanQueryRepository {
|
interface CanQueryRepository {
|
||||||
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse>
|
||||||
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
|
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
|
||||||
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
|
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
|
||||||
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
|
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
|
||||||
@@ -32,7 +32,7 @@ interface CanQueryRepository {
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
|
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
|
||||||
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QCanResponse(
|
QCanResponse(
|
||||||
@@ -40,11 +40,16 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
|
|||||||
can1.title,
|
can1.title,
|
||||||
can1.can,
|
can1.can,
|
||||||
can1.rewardCan,
|
can1.rewardCan,
|
||||||
can1.price
|
can1.price.intValue(),
|
||||||
|
can1.currency,
|
||||||
|
can1.price.stringValue()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(can1)
|
.from(can1)
|
||||||
.where(can1.status.eq(status))
|
.where(
|
||||||
|
can1.status.eq(status),
|
||||||
|
can1.currency.eq(currency)
|
||||||
|
)
|
||||||
.orderBy(can1.can.asc())
|
.orderBy(can1.can.asc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
@@ -64,11 +69,13 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
|
|||||||
val chargeStatusCondition = when (container) {
|
val chargeStatusCondition = when (container) {
|
||||||
"aos" -> {
|
"aos" -> {
|
||||||
charge.payment.paymentGateway.eq(PaymentGateway.PG)
|
charge.payment.paymentGateway.eq(PaymentGateway.PG)
|
||||||
|
.or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||||
.or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
.or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
||||||
}
|
}
|
||||||
|
|
||||||
"ios" -> {
|
"ios" -> {
|
||||||
charge.payment.paymentGateway.eq(PaymentGateway.PG)
|
charge.payment.paymentGateway.eq(PaymentGateway.PG)
|
||||||
|
.or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||||
.or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
.or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -7,5 +7,7 @@ data class CanResponse @QueryProjection constructor(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val rewardCan: Int,
|
val rewardCan: Int,
|
||||||
val price: Int
|
val price: Int,
|
||||||
|
val currency: String,
|
||||||
|
val priceStr: String
|
||||||
)
|
)
|
||||||
|
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.can
|
|||||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.common.GeoCountry
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -11,8 +12,12 @@ import java.time.format.DateTimeFormatter
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class CanService(private val repository: CanRepository) {
|
class CanService(private val repository: CanRepository) {
|
||||||
fun getCans(): List<CanResponse> {
|
fun getCans(geoCountry: GeoCountry): List<CanResponse> {
|
||||||
return repository.findAllByStatus(status = CanStatus.SALE)
|
val currency = when (geoCountry) {
|
||||||
|
GeoCountry.KR -> "KRW"
|
||||||
|
else -> "USD"
|
||||||
|
}
|
||||||
|
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
|
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
|
||||||
@@ -72,6 +77,10 @@ class CanService(private val repository: CanRepository) {
|
|||||||
CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}"
|
CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}"
|
||||||
CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}"
|
CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}"
|
||||||
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
|
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
|
||||||
|
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||||
|
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||||
|
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
|
||||||
|
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
|
||||||
}
|
}
|
||||||
|
|
||||||
val createdAt = it.createdAt!!
|
val createdAt = it.createdAt!!
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
package kr.co.vividnext.sodalive.can.charge
|
package kr.co.vividnext.sodalive.can.charge
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class ChargeCompleteResponse(
|
data class ChargeCompleteResponse(
|
||||||
val price: Double,
|
val price: BigDecimal,
|
||||||
val currencyCode: String,
|
val currencyCode: String,
|
||||||
val isFirstCharged: Boolean
|
val isFirstCharged: Boolean
|
||||||
)
|
)
|
||||||
|
@@ -6,20 +6,77 @@ import kr.co.vividnext.sodalive.common.SodaException
|
|||||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||||
import kr.co.vividnext.sodalive.marketing.AdTrackingService
|
import kr.co.vividnext.sodalive.marketing.AdTrackingService
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
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
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/charge")
|
@RequestMapping("/charge")
|
||||||
class ChargeController(
|
class ChargeController(
|
||||||
private val service: ChargeService,
|
private val service: ChargeService,
|
||||||
private val trackingService: AdTrackingService
|
private val trackingService: AdTrackingService,
|
||||||
|
|
||||||
|
@Value("\${payverse.inbound-ip}")
|
||||||
|
private val payverseInboundIp: String
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@PostMapping("/payverse")
|
||||||
|
fun payverseCharge(
|
||||||
|
@RequestBody request: PayverseChargeRequest,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) {
|
||||||
|
throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(service.payverseCharge(member, request))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/payverse/verify")
|
||||||
|
fun payverseVerify(
|
||||||
|
@RequestBody verifyRequest: PayverseVerifyRequest,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) {
|
||||||
|
throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
|
||||||
|
trackingCharge(member, response)
|
||||||
|
ApiResponse.ok(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payverse Webhook 엔드포인트 (payverseVerify 아래)
|
||||||
|
@PostMapping("/payverse/webhook")
|
||||||
|
fun payverseWebhook(
|
||||||
|
@RequestBody request: PayverseWebhookRequest,
|
||||||
|
servletRequest: HttpServletRequest
|
||||||
|
): PayverseWebhookResponse {
|
||||||
|
val header = servletRequest.getHeader("X-Forwarded-For")
|
||||||
|
val remoteIp = if (header.isNullOrEmpty()) {
|
||||||
|
servletRequest.remoteAddr
|
||||||
|
} else {
|
||||||
|
header.split(",")[0].trim() // 첫 번째 값이 클라이언트 IP
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteIp != payverseInboundIp) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
val success = service.payverseWebhook(request)
|
||||||
|
if (!success) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
|
}
|
||||||
|
return PayverseWebhookResponse(receiveResult = "SUCCESS")
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun charge(
|
fun charge(
|
||||||
@RequestBody chargeRequest: ChargeRequest,
|
@RequestBody chargeRequest: ChargeRequest,
|
||||||
@@ -111,8 +168,7 @@ class ChargeController(
|
|||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
chargeId = chargeId,
|
chargeId = chargeId,
|
||||||
productId = request.productId,
|
productId = request.productId,
|
||||||
purchaseToken = request.purchaseToken,
|
purchaseToken = request.purchaseToken
|
||||||
paymentGateway = request.paymentGateway
|
|
||||||
)
|
)
|
||||||
|
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
|
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.charge
|
|||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
|
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
|
||||||
|
|
||||||
@@ -20,14 +21,14 @@ data class VerifyResult(
|
|||||||
val method: String,
|
val method: String,
|
||||||
val pg: String,
|
val pg: String,
|
||||||
val status: Int,
|
val status: Int,
|
||||||
val price: Int
|
val price: BigDecimal
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AppleChargeRequest(
|
data class AppleChargeRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val chargeCan: Int,
|
val chargeCan: Int,
|
||||||
val paymentGateway: PaymentGateway,
|
val paymentGateway: PaymentGateway,
|
||||||
var price: Double? = null,
|
var price: BigDecimal? = null,
|
||||||
var locale: String? = null
|
var locale: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,9 +39,53 @@ data class AppleVerifyResponse(val status: Int)
|
|||||||
data class GoogleChargeRequest(
|
data class GoogleChargeRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val chargeCan: Int,
|
val chargeCan: Int,
|
||||||
val price: Double,
|
val price: BigDecimal,
|
||||||
val currencyCode: String,
|
val currencyCode: String,
|
||||||
val productId: String,
|
val productId: String,
|
||||||
val purchaseToken: String,
|
val purchaseToken: String,
|
||||||
val paymentGateway: PaymentGateway
|
val paymentGateway: PaymentGateway
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class PayverseChargeRequest(
|
||||||
|
val canId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PayverseChargeResponse(
|
||||||
|
val chargeId: Long,
|
||||||
|
val payloadJson: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PayverseVerifyRequest(
|
||||||
|
val transactionId: String,
|
||||||
|
val orderId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PayverseVerifyResponse(
|
||||||
|
val resultStatus: String,
|
||||||
|
val tid: String,
|
||||||
|
val schemeGroup: String,
|
||||||
|
val schemeCode: String,
|
||||||
|
val transactionType: String,
|
||||||
|
val transactionStatus: String,
|
||||||
|
val transactionMessage: String,
|
||||||
|
val orderId: String,
|
||||||
|
val customerId: String,
|
||||||
|
val requestCurrency: String,
|
||||||
|
val requestAmount: BigDecimal
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PayverseWebhookRequest(
|
||||||
|
val type: String,
|
||||||
|
val mid: String,
|
||||||
|
val tid: String,
|
||||||
|
val schemeGroup: String,
|
||||||
|
val schemeCode: String,
|
||||||
|
val orderId: String,
|
||||||
|
val requestCurrency: String,
|
||||||
|
val requestAmount: BigDecimal,
|
||||||
|
val resultStatus: String,
|
||||||
|
val approvalDay: String,
|
||||||
|
val sign: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PayverseWebhookResponse(val receiveResult: String)
|
||||||
|
@@ -5,6 +5,7 @@ import kr.co.bootpay.Bootpay
|
|||||||
import kr.co.vividnext.sodalive.can.CanRepository
|
import kr.co.vividnext.sodalive.can.CanRepository
|
||||||
import kr.co.vividnext.sodalive.can.charge.event.ChargeSpringEvent
|
import kr.co.vividnext.sodalive.can.charge.event.ChargeSpringEvent
|
||||||
import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository
|
import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.coupon.CouponType
|
||||||
import kr.co.vividnext.sodalive.can.payment.Payment
|
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
@@ -12,10 +13,16 @@ import kr.co.vividnext.sodalive.common.SodaException
|
|||||||
import kr.co.vividnext.sodalive.google.GooglePlayService
|
import kr.co.vividnext.sodalive.google.GooglePlayService
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.point.MemberPoint
|
||||||
|
import kr.co.vividnext.sodalive.point.MemberPointRepository
|
||||||
|
import kr.co.vividnext.sodalive.point.PointGrantLog
|
||||||
|
import kr.co.vividnext.sodalive.point.PointGrantLogRepository
|
||||||
|
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.apache.commons.codec.digest.DigestUtils
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -27,6 +34,8 @@ import org.springframework.stereotype.Service
|
|||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -36,6 +45,9 @@ class ChargeService(
|
|||||||
private val memberRepository: MemberRepository,
|
private val memberRepository: MemberRepository,
|
||||||
private val couponNumberRepository: CanCouponNumberRepository,
|
private val couponNumberRepository: CanCouponNumberRepository,
|
||||||
|
|
||||||
|
private val grantLogRepository: PointGrantLogRepository,
|
||||||
|
private val memberPointRepository: MemberPointRepository,
|
||||||
|
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
@@ -53,34 +65,341 @@ class ChargeService(
|
|||||||
@Value("\${apple.iap-verify-sandbox-url}")
|
@Value("\${apple.iap-verify-sandbox-url}")
|
||||||
private val appleInAppVerifySandBoxUrl: String,
|
private val appleInAppVerifySandBoxUrl: String,
|
||||||
@Value("\${apple.iap-verify-url}")
|
@Value("\${apple.iap-verify-url}")
|
||||||
private val appleInAppVerifyUrl: String
|
private val appleInAppVerifyUrl: String,
|
||||||
|
|
||||||
|
@Value("\${payverse.mid}")
|
||||||
|
private val payverseMid: String,
|
||||||
|
@Value("\${payverse.client-key}")
|
||||||
|
private val payverseClientKey: String,
|
||||||
|
@Value("\${payverse.secret-key}")
|
||||||
|
private val payverseSecretKey: String,
|
||||||
|
|
||||||
|
@Value("\${payverse.usd-mid}")
|
||||||
|
private val payverseUsdMid: String,
|
||||||
|
@Value("\${payverse.usd-client-key}")
|
||||||
|
private val payverseUsdClientKey: String,
|
||||||
|
@Value("\${payverse.usd-secret-key}")
|
||||||
|
private val payverseUsdSecretKey: String,
|
||||||
|
|
||||||
|
@Value("\${payverse.host}")
|
||||||
|
private val payverseHost: String,
|
||||||
|
|
||||||
|
@Value("\${server.env}")
|
||||||
|
private val serverEnv: String
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun chargeByCoupon(couponNumber: String, member: Member) {
|
fun payverseWebhook(request: PayverseWebhookRequest): Boolean {
|
||||||
|
val chargeId = request.orderId.toLongOrNull() ?: return false
|
||||||
|
val charge = chargeRepository.findByIdOrNull(chargeId) ?: return false
|
||||||
|
|
||||||
|
// 결제수단 확인
|
||||||
|
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결제 상태 분기 처리
|
||||||
|
return when (charge.payment?.status) {
|
||||||
|
PaymentStatus.REQUEST -> {
|
||||||
|
// 성공 조건 검증
|
||||||
|
val mid = if (request.requestCurrency == "KRW") {
|
||||||
|
payverseMid
|
||||||
|
} else {
|
||||||
|
payverseUsdMid
|
||||||
|
}
|
||||||
|
val expectedSign = DigestUtils.sha512Hex(
|
||||||
|
String.format(
|
||||||
|
"||%s||%s||%s||%s||%s||",
|
||||||
|
if (request.requestCurrency == "KRW") {
|
||||||
|
payverseSecretKey
|
||||||
|
} else {
|
||||||
|
payverseUsdSecretKey
|
||||||
|
},
|
||||||
|
mid,
|
||||||
|
request.orderId,
|
||||||
|
request.requestAmount,
|
||||||
|
request.approvalDay
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val isAmountMatch = request.requestAmount.compareTo(
|
||||||
|
charge.payment!!.price
|
||||||
|
) == 0
|
||||||
|
|
||||||
|
val isSuccess = request.resultStatus == "SUCCESS" &&
|
||||||
|
request.mid == mid &&
|
||||||
|
request.orderId.toLongOrNull() == charge.id &&
|
||||||
|
isAmountMatch &&
|
||||||
|
request.sign == expectedSign
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
// payverseVerify의 226~246 라인과 동일 처리
|
||||||
|
charge.payment?.receiptId = request.tid
|
||||||
|
val mappedMethod = if (request.schemeGroup == "PVKR") {
|
||||||
|
mapPayverseSchemeToMethodByCode(request.schemeCode)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
charge.payment?.method = mappedMethod ?: request.schemeCode
|
||||||
|
charge.payment?.status = PaymentStatus.COMPLETE
|
||||||
|
charge.payment?.locale = request.requestCurrency
|
||||||
|
|
||||||
|
val member = charge.member!!
|
||||||
|
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
ChargeSpringEvent(
|
||||||
|
chargeId = charge.id!!,
|
||||||
|
memberId = member.id!!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PaymentStatus.COMPLETE -> {
|
||||||
|
// 이미 결제가 완료된 경우 성공 처리(idempotent)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// 그 외 상태는 404
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun chargeByCoupon(couponNumber: String, member: Member): String {
|
||||||
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
||||||
?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.")
|
?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.")
|
||||||
|
|
||||||
if (canCouponNumber.member != null) {
|
if (canCouponNumber.member != null) {
|
||||||
throw SodaException("이미 사용한 쿠폰번호 입니다.")
|
throw SodaException("이미 사용한 쿠폰번호 입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
canCouponNumber.member = member
|
canCouponNumber.member = member
|
||||||
|
|
||||||
val coupon = canCouponNumber.canCoupon!!
|
val coupon = canCouponNumber.canCoupon!!
|
||||||
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
|
|
||||||
couponCharge.title = "${coupon.can} 캔"
|
|
||||||
couponCharge.member = member
|
|
||||||
|
|
||||||
val payment = Payment(
|
when (coupon.couponType) {
|
||||||
status = PaymentStatus.COMPLETE,
|
CouponType.CAN -> {
|
||||||
paymentGateway = PaymentGateway.PG
|
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
|
||||||
|
couponCharge.title = "${coupon.can} 캔"
|
||||||
|
couponCharge.member = member
|
||||||
|
|
||||||
|
val payment = Payment(
|
||||||
|
status = PaymentStatus.COMPLETE,
|
||||||
|
paymentGateway = PaymentGateway.PG
|
||||||
|
)
|
||||||
|
payment.method = coupon.couponName
|
||||||
|
couponCharge.payment = payment
|
||||||
|
chargeRepository.save(couponCharge)
|
||||||
|
|
||||||
|
member.charge(0, coupon.can, "pg")
|
||||||
|
return "쿠폰 사용이 완료되었습니다.\n${coupon.can}캔이 지급되었습니다."
|
||||||
|
}
|
||||||
|
|
||||||
|
CouponType.POINT -> {
|
||||||
|
val memberId = member.id!!
|
||||||
|
val point = coupon.can
|
||||||
|
val actionType = ActionType.COUPON
|
||||||
|
|
||||||
|
grantLogRepository.save(
|
||||||
|
PointGrantLog(
|
||||||
|
memberId = memberId,
|
||||||
|
point = point,
|
||||||
|
actionType = actionType,
|
||||||
|
policyId = null,
|
||||||
|
orderId = null,
|
||||||
|
couponName = coupon.couponName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
memberPointRepository.save(
|
||||||
|
MemberPoint(
|
||||||
|
memberId = memberId,
|
||||||
|
point = point,
|
||||||
|
actionType = actionType,
|
||||||
|
expiresAt = LocalDateTime.now().plusDays(3)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return "쿠폰 사용이 완료되었습니다.\n${coupon.can}포인트가 지급되었습니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun payverseCharge(member: Member, request: PayverseChargeRequest): PayverseChargeResponse {
|
||||||
|
val can = canRepository.findByIdOrNull(request.canId)
|
||||||
|
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
|
||||||
|
|
||||||
|
val requestCurrency = can.currency
|
||||||
|
val isKrw = requestCurrency == "KRW"
|
||||||
|
val mid = if (isKrw) {
|
||||||
|
payverseMid
|
||||||
|
} else {
|
||||||
|
payverseUsdMid
|
||||||
|
}
|
||||||
|
val clientKey = if (isKrw) {
|
||||||
|
payverseClientKey
|
||||||
|
} else {
|
||||||
|
payverseUsdClientKey
|
||||||
|
}
|
||||||
|
val secretKey = if (isKrw) {
|
||||||
|
payverseSecretKey
|
||||||
|
} else {
|
||||||
|
payverseUsdSecretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
val charge = Charge(can.can, can.rewardCan)
|
||||||
|
charge.title = can.title
|
||||||
|
charge.member = member
|
||||||
|
charge.can = can
|
||||||
|
|
||||||
|
val payment = Payment(paymentGateway = PaymentGateway.PAYVERSE)
|
||||||
|
payment.price = can.price
|
||||||
|
charge.payment = payment
|
||||||
|
|
||||||
|
val savedCharge = chargeRepository.save(charge)
|
||||||
|
|
||||||
|
val chargeId = savedCharge.id!!
|
||||||
|
val amount = BigDecimal(
|
||||||
|
savedCharge.payment!!.price
|
||||||
|
.setScale(4, RoundingMode.HALF_UP)
|
||||||
|
.stripTrailingZeros()
|
||||||
|
.toPlainString()
|
||||||
)
|
)
|
||||||
payment.method = coupon.couponName
|
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||||
couponCharge.payment = payment
|
val sign = DigestUtils.sha512Hex(
|
||||||
chargeRepository.save(couponCharge)
|
String.format(
|
||||||
|
"||%s||%s||%s||%s||%s||",
|
||||||
|
secretKey,
|
||||||
|
mid,
|
||||||
|
chargeId,
|
||||||
|
amount,
|
||||||
|
reqDate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val customerId = "${serverEnv}_user_${member.id!!}"
|
||||||
|
|
||||||
member.charge(0, coupon.can, "pg")
|
val payload = linkedMapOf(
|
||||||
|
"mid" to mid,
|
||||||
|
"clientKey" to clientKey,
|
||||||
|
"orderId" to chargeId.toString(),
|
||||||
|
"customerId" to customerId,
|
||||||
|
"productName" to can.title,
|
||||||
|
"requestCurrency" to requestCurrency,
|
||||||
|
"requestAmount" to amount,
|
||||||
|
"reqDate" to reqDate,
|
||||||
|
"sign" to sign
|
||||||
|
)
|
||||||
|
val payloadJson = objectMapper.writeValueAsString(payload)
|
||||||
|
|
||||||
|
return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
|
||||||
|
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
||||||
|
?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
|
val member = memberRepository.findByIdOrNull(memberId)
|
||||||
|
?: throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
|
val isKrw = charge.can?.currency == "KRW"
|
||||||
|
val mid = if (isKrw) {
|
||||||
|
payverseMid
|
||||||
|
} else {
|
||||||
|
payverseUsdMid
|
||||||
|
}
|
||||||
|
val clientKey = if (isKrw) {
|
||||||
|
payverseClientKey
|
||||||
|
} else {
|
||||||
|
payverseUsdClientKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결제수단 확인
|
||||||
|
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
|
||||||
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결제 상태에 따른 분기 처리
|
||||||
|
when (charge.payment?.status) {
|
||||||
|
PaymentStatus.REQUEST -> {
|
||||||
|
try {
|
||||||
|
val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("mid", mid)
|
||||||
|
.addHeader("clientKey", clientKey)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = okHttpClient.newCall(request).execute()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
|
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
|
||||||
|
|
||||||
|
val customerId = "${serverEnv}_user_${member.id!!}"
|
||||||
|
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
|
||||||
|
verifyResponse.transactionStatus == "SUCCESS" &&
|
||||||
|
verifyResponse.orderId.toLongOrNull() == charge.id &&
|
||||||
|
verifyResponse.customerId == customerId &&
|
||||||
|
verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
// verify 함수의 232~248 라인과 동일 처리
|
||||||
|
charge.payment?.receiptId = verifyResponse.tid
|
||||||
|
val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") {
|
||||||
|
mapPayverseSchemeToMethodByCode(verifyResponse.schemeCode)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
charge.payment?.method = mappedMethod ?: verifyResponse.schemeCode
|
||||||
|
charge.payment?.status = PaymentStatus.COMPLETE
|
||||||
|
// 통화코드 설정
|
||||||
|
charge.payment?.locale = verifyResponse.requestCurrency
|
||||||
|
|
||||||
|
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
ChargeSpringEvent(
|
||||||
|
chargeId = charge.id!!,
|
||||||
|
memberId = member.id!!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ChargeCompleteResponse(
|
||||||
|
price = charge.payment!!.price,
|
||||||
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PaymentStatus.COMPLETE -> {
|
||||||
|
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
|
||||||
|
return ChargeCompleteResponse(
|
||||||
|
price = charge.payment!!.price,
|
||||||
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -94,7 +413,7 @@ class ChargeService(
|
|||||||
charge.can = can
|
charge.can = can
|
||||||
|
|
||||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||||
payment.price = can.price.toDouble()
|
payment.price = can.price
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -133,14 +452,14 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -165,7 +484,7 @@ class ChargeService(
|
|||||||
VerifyResult::class.java
|
VerifyResult::class.java
|
||||||
)
|
)
|
||||||
|
|
||||||
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
|
if (verifyResult.status == 1) {
|
||||||
charge.payment?.receiptId = verifyResult.receiptId
|
charge.payment?.receiptId = verifyResult.receiptId
|
||||||
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
|
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
|
||||||
"${verifyResult.pg}-${verifyResult.method}"
|
"${verifyResult.pg}-${verifyResult.method}"
|
||||||
@@ -183,14 +502,14 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -208,7 +527,7 @@ class ChargeService(
|
|||||||
payment.price = if (request.price != null) {
|
payment.price = if (request.price != null) {
|
||||||
request.price!!
|
request.price!!
|
||||||
} else {
|
} else {
|
||||||
0.toDouble()
|
0.toBigDecimal()
|
||||||
}
|
}
|
||||||
|
|
||||||
payment.locale = request.locale
|
payment.locale = request.locale
|
||||||
@@ -243,7 +562,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -260,7 +579,7 @@ class ChargeService(
|
|||||||
member: Member,
|
member: Member,
|
||||||
title: String,
|
title: String,
|
||||||
chargeCan: Int,
|
chargeCan: Int,
|
||||||
price: Double,
|
price: BigDecimal,
|
||||||
currencyCode: String,
|
currencyCode: String,
|
||||||
productId: String,
|
productId: String,
|
||||||
purchaseToken: String,
|
purchaseToken: String,
|
||||||
@@ -288,8 +607,7 @@ class ChargeService(
|
|||||||
memberId: Long,
|
memberId: Long,
|
||||||
chargeId: Long,
|
chargeId: Long,
|
||||||
productId: String,
|
productId: String,
|
||||||
purchaseToken: String,
|
purchaseToken: String
|
||||||
paymentGateway: PaymentGateway
|
|
||||||
): ChargeCompleteResponse {
|
): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
||||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
@@ -311,7 +629,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -393,4 +711,13 @@ class ChargeService(
|
|||||||
throw SodaException("결제를 완료하지 못했습니다.")
|
throw SodaException("결제를 완료하지 못했습니다.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환
|
||||||
|
private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? {
|
||||||
|
val cardCodes = setOf(
|
||||||
|
"041", "044", "361", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "381",
|
||||||
|
"218", "071", "002", "089", "045", "050", "048", "090", "092"
|
||||||
|
)
|
||||||
|
return if (schemeCode != null && cardCodes.contains(schemeCode)) "카드" else null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.can.charge.temp
|
package kr.co.vividnext.sodalive.can.charge.temp
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class ChargeTempRequest(
|
data class ChargeTempRequest(
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val price: Int,
|
val price: BigDecimal,
|
||||||
val paymentGateway: PaymentGateway
|
val paymentGateway: PaymentGateway
|
||||||
)
|
)
|
||||||
|
@@ -41,7 +41,7 @@ class ChargeTempService(
|
|||||||
charge.member = member
|
charge.member = member
|
||||||
|
|
||||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||||
payment.price = request.price.toDouble()
|
payment.price = request.price
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -66,7 +66,7 @@ class ChargeTempService(
|
|||||||
VerifyResult::class.java
|
VerifyResult::class.java
|
||||||
)
|
)
|
||||||
|
|
||||||
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) {
|
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price) {
|
||||||
charge.payment?.receiptId = verifyResult.receiptId
|
charge.payment?.receiptId = verifyResult.receiptId
|
||||||
charge.payment?.method = verifyResult.method
|
charge.payment?.method = verifyResult.method
|
||||||
charge.payment?.status = PaymentStatus.COMPLETE
|
charge.payment?.status = PaymentStatus.COMPLETE
|
||||||
@@ -74,7 +74,7 @@ class ChargeTempService(
|
|||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@@ -3,13 +3,21 @@ package kr.co.vividnext.sodalive.can.coupon
|
|||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.EnumType
|
||||||
|
import javax.persistence.Enumerated
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
data class CanCoupon(
|
data class CanCoupon(
|
||||||
val couponName: String,
|
val couponName: String,
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
val couponType: CouponType,
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val couponCount: Int,
|
val couponCount: Int,
|
||||||
var validity: LocalDateTime,
|
var validity: LocalDateTime,
|
||||||
var isActive: Boolean,
|
var isActive: Boolean,
|
||||||
var isMultipleUse: Boolean
|
var isMultipleUse: Boolean
|
||||||
) : BaseEntity()
|
) : BaseEntity()
|
||||||
|
|
||||||
|
enum class CouponType {
|
||||||
|
CAN, POINT
|
||||||
|
}
|
||||||
|
@@ -109,11 +109,11 @@ class CanCouponController(private val service: CanCouponService) {
|
|||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
ApiResponse.ok(
|
val completeMessage = service.useCanCoupon(
|
||||||
service.useCanCoupon(
|
couponNumber = request.couponNumber,
|
||||||
couponNumber = request.couponNumber,
|
memberId = member.id!!
|
||||||
memberId = member.id!!
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(Unit, completeMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -79,6 +79,7 @@ class CanCouponNumberQueryRepositoryImpl(private val queryFactory: JPAQueryFacto
|
|||||||
override fun findByCouponNumber(couponNumber: String): CanCouponNumber? {
|
override fun findByCouponNumber(couponNumber: String): CanCouponNumber? {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.selectFrom(canCouponNumber)
|
.selectFrom(canCouponNumber)
|
||||||
|
.innerJoin(canCouponNumber.canCoupon, canCoupon)
|
||||||
.where(canCouponNumber.couponNumber.eq(couponNumber))
|
.where(canCouponNumber.couponNumber.eq(couponNumber))
|
||||||
.fetchFirst()
|
.fetchFirst()
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.can.coupon
|
package kr.co.vividnext.sodalive.can.coupon
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
import com.querydsl.core.types.dsl.DateTimePath
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.core.types.dsl.StringTemplate
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
@@ -30,6 +31,9 @@ class CanCouponQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) :
|
|||||||
QGetCouponListItemResponse(
|
QGetCouponListItemResponse(
|
||||||
canCoupon.id,
|
canCoupon.id,
|
||||||
canCoupon.couponName,
|
canCoupon.couponName,
|
||||||
|
CaseBuilder()
|
||||||
|
.`when`(canCoupon.couponType.eq(CouponType.POINT)).then("포인트 쿠폰")
|
||||||
|
.otherwise("캔 쿠폰"),
|
||||||
canCoupon.can,
|
canCoupon.can,
|
||||||
canCoupon.couponCount,
|
canCoupon.couponCount,
|
||||||
Expressions.ZERO,
|
Expressions.ZERO,
|
||||||
|
@@ -68,15 +68,12 @@ class CanCouponService(
|
|||||||
|
|
||||||
fun getCouponList(offset: Long, limit: Long): GetCouponListResponse {
|
fun getCouponList(offset: Long, limit: Long): GetCouponListResponse {
|
||||||
val totalCount = repository.getCouponTotalCount()
|
val totalCount = repository.getCouponTotalCount()
|
||||||
|
|
||||||
val items = repository.getCouponList(offset = offset, limit = limit)
|
val items = repository.getCouponList(offset = offset, limit = limit)
|
||||||
.asSequence()
|
|
||||||
.map {
|
.map {
|
||||||
val useCouponCount = couponNumberRepository.getUseCouponCount(id = it.id)
|
val useCouponCount = couponNumberRepository.getUseCouponCount(id = it.id)
|
||||||
it.useCouponCount = useCouponCount
|
it.useCouponCount = useCouponCount
|
||||||
it
|
it
|
||||||
}
|
}
|
||||||
.toList()
|
|
||||||
|
|
||||||
return GetCouponListResponse(totalCount, items)
|
return GetCouponListResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
@@ -124,7 +121,7 @@ class CanCouponService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun useCanCoupon(couponNumber: String, memberId: Long) {
|
fun useCanCoupon(couponNumber: String, memberId: Long): String {
|
||||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
?: throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
@@ -132,7 +129,7 @@ class CanCouponService(
|
|||||||
|
|
||||||
issueService.validateAvailableUseCoupon(couponNumber, memberId)
|
issueService.validateAvailableUseCoupon(couponNumber, memberId)
|
||||||
|
|
||||||
chargeService.chargeByCoupon(couponNumber, member)
|
return chargeService.chargeByCoupon(couponNumber, member)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun insertHyphens(input: String): String {
|
private fun insertHyphens(input: String): String {
|
||||||
|
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty
|
|||||||
|
|
||||||
data class GenerateCanCouponRequest(
|
data class GenerateCanCouponRequest(
|
||||||
@JsonProperty("couponName") val couponName: String,
|
@JsonProperty("couponName") val couponName: String,
|
||||||
|
@JsonProperty("couponType") val couponType: CouponType,
|
||||||
@JsonProperty("can") val can: Int,
|
@JsonProperty("can") val can: Int,
|
||||||
@JsonProperty("validity") val validity: String,
|
@JsonProperty("validity") val validity: String,
|
||||||
@JsonProperty("isMultipleUse") val isMultipleUse: Boolean,
|
@JsonProperty("isMultipleUse") val isMultipleUse: Boolean,
|
||||||
|
@@ -10,6 +10,7 @@ data class GetCouponListResponse(
|
|||||||
data class GetCouponListItemResponse @QueryProjection constructor(
|
data class GetCouponListItemResponse @QueryProjection constructor(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val couponName: String,
|
val couponName: String,
|
||||||
|
val couponType: String,
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val couponCount: Int,
|
val couponCount: Int,
|
||||||
var useCouponCount: Int,
|
var useCouponCount: Int,
|
||||||
|
@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
|||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
import kr.co.vividnext.sodalive.content.order.Order
|
import kr.co.vividnext.sodalive.content.order.Order
|
||||||
@@ -37,6 +38,8 @@ class CanPaymentService(
|
|||||||
memberId: Long,
|
memberId: Long,
|
||||||
needCan: Int,
|
needCan: Int,
|
||||||
canUsage: CanUsage,
|
canUsage: CanUsage,
|
||||||
|
chatRoomId: Long? = null,
|
||||||
|
characterId: Long? = null,
|
||||||
isSecret: Boolean = false,
|
isSecret: Boolean = false,
|
||||||
liveRoom: LiveRoom? = null,
|
liveRoom: LiveRoom? = null,
|
||||||
order: Order? = null,
|
order: Order? = null,
|
||||||
@@ -109,6 +112,14 @@ class CanPaymentService(
|
|||||||
recipientId = liveRoom.member!!.id!!
|
recipientId = liveRoom.member!!.id!!
|
||||||
useCan.room = liveRoom
|
useCan.room = liveRoom
|
||||||
useCan.member = member
|
useCan.member = member
|
||||||
|
} else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE && chatRoomId != null && characterId != null) {
|
||||||
|
useCan.member = member
|
||||||
|
useCan.chatRoomId = chatRoomId
|
||||||
|
useCan.characterId = characterId
|
||||||
|
} else if (canUsage == CanUsage.CHAT_ROOM_RESET) {
|
||||||
|
useCan.member = member
|
||||||
|
useCan.chatRoomId = chatRoomId
|
||||||
|
useCan.characterId = characterId
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("잘못된 요청입니다.")
|
throw SodaException("잘못된 요청입니다.")
|
||||||
}
|
}
|
||||||
@@ -327,4 +338,98 @@ class CanPaymentService(
|
|||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun spendCanForCharacterImage(
|
||||||
|
memberId: Long,
|
||||||
|
needCan: Int,
|
||||||
|
image: CharacterImage,
|
||||||
|
container: String
|
||||||
|
) {
|
||||||
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
|
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||||
|
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
||||||
|
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
||||||
|
throw SodaException(
|
||||||
|
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
|
||||||
|
"캔이 부족합니다. 충전 후 이용해 주세요."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
||||||
|
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val useCan = UseCan(
|
||||||
|
canUsage = CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||||
|
can = useChargeCan?.total ?: 0,
|
||||||
|
rewardCan = useRewardCan.total,
|
||||||
|
isSecret = false
|
||||||
|
)
|
||||||
|
useCan.member = member
|
||||||
|
useCan.characterImage = image
|
||||||
|
|
||||||
|
useCanRepository.save(useCan)
|
||||||
|
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun spendCanForChatMessage(
|
||||||
|
memberId: Long,
|
||||||
|
needCan: Int,
|
||||||
|
message: kr.co.vividnext.sodalive.chat.room.ChatMessage,
|
||||||
|
container: String
|
||||||
|
) {
|
||||||
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
|
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||||
|
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
||||||
|
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
||||||
|
throw SodaException(
|
||||||
|
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
|
||||||
|
"캔이 부족합니다. 충전 후 이용해 주세요."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
||||||
|
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val useCan = UseCan(
|
||||||
|
canUsage = CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||||
|
can = useChargeCan?.total ?: 0,
|
||||||
|
rewardCan = useRewardCan.total,
|
||||||
|
isSecret = false
|
||||||
|
)
|
||||||
|
useCan.member = member
|
||||||
|
useCan.chatMessage = message
|
||||||
|
// 이미지 메시지의 경우 이미지 연관도 함께 기록
|
||||||
|
message.characterImage?.let { img ->
|
||||||
|
useCan.characterImage = img
|
||||||
|
}
|
||||||
|
|
||||||
|
useCanRepository.save(useCan)
|
||||||
|
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.payment
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import java.math.BigDecimal
|
||||||
import javax.persistence.Column
|
import javax.persistence.Column
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.EnumType
|
import javax.persistence.EnumType
|
||||||
@@ -25,7 +26,8 @@ data class Payment(
|
|||||||
var receiptId: String? = null
|
var receiptId: String? = null
|
||||||
var method: String? = null
|
var method: String? = null
|
||||||
|
|
||||||
var price: Double = 0.toDouble()
|
@Column(precision = 10, scale = 4, nullable = false)
|
||||||
|
var price: BigDecimal = 0.toBigDecimal()
|
||||||
var locale: String? = null
|
var locale: String? = null
|
||||||
var orderId: String? = null
|
var orderId: String? = null
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
package kr.co.vividnext.sodalive.can.payment
|
package kr.co.vividnext.sodalive.can.payment
|
||||||
|
|
||||||
enum class PaymentGateway {
|
enum class PaymentGateway {
|
||||||
PG, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
|
PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
|
||||||
}
|
}
|
||||||
|
@@ -9,5 +9,9 @@ enum class CanUsage {
|
|||||||
SPIN_ROULETTE,
|
SPIN_ROULETTE,
|
||||||
PAID_COMMUNITY_POST,
|
PAID_COMMUNITY_POST,
|
||||||
ALARM_SLOT,
|
ALARM_SLOT,
|
||||||
AUDITION_VOTE
|
AUDITION_VOTE,
|
||||||
|
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
||||||
|
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
|
||||||
|
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
|
||||||
|
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.can.use
|
package kr.co.vividnext.sodalive.can.use
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.audition.AuditionApplicant
|
import kr.co.vividnext.sodalive.audition.AuditionApplicant
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.ChatMessage
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
import kr.co.vividnext.sodalive.content.order.Order
|
import kr.co.vividnext.sodalive.content.order.Order
|
||||||
@@ -28,7 +30,11 @@ data class UseCan(
|
|||||||
|
|
||||||
var isRefund: Boolean = false,
|
var isRefund: Boolean = false,
|
||||||
|
|
||||||
val isSecret: Boolean = false
|
val isSecret: Boolean = false,
|
||||||
|
|
||||||
|
// 채팅 연동을 위한 식별자 (옵션)
|
||||||
|
var chatRoomId: Long? = null,
|
||||||
|
var characterId: Long? = null
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "member_id", nullable = false)
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
@@ -58,6 +64,16 @@ data class UseCan(
|
|||||||
@JoinColumn(name = "audition_applicant_id", nullable = true)
|
@JoinColumn(name = "audition_applicant_id", nullable = true)
|
||||||
var auditionApplicant: AuditionApplicant? = null
|
var auditionApplicant: AuditionApplicant? = null
|
||||||
|
|
||||||
|
// 메시지를 통한 구매 연관 (옵션)
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "chat_message_id", nullable = true)
|
||||||
|
var chatMessage: ChatMessage? = null
|
||||||
|
|
||||||
|
// 캐릭터 이미지 연관 (메시지 구매/단독 구매 공통 사용)
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "character_image_id", nullable = true)
|
||||||
|
var characterImage: CharacterImage? = null
|
||||||
|
|
||||||
@OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL])
|
@OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL])
|
||||||
val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf()
|
val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf()
|
||||||
}
|
}
|
||||||
|
@@ -6,23 +6,56 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository
|
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository {
|
||||||
|
// 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외)
|
||||||
|
fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn(
|
||||||
|
memberId: Long,
|
||||||
|
imageId: Long,
|
||||||
|
usages: Collection<CanUsage>
|
||||||
|
): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface UseCanQueryRepository {
|
interface UseCanQueryRepository {
|
||||||
fun isExistOrdered(postId: Long, memberId: Long): Boolean
|
fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean
|
||||||
|
fun countPurchasedActiveImagesByCharacter(
|
||||||
|
memberId: Long,
|
||||||
|
characterId: Long,
|
||||||
|
usages: Collection<CanUsage>
|
||||||
|
): Long
|
||||||
}
|
}
|
||||||
|
|
||||||
class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository {
|
class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository {
|
||||||
override fun isExistOrdered(postId: Long, memberId: Long): Boolean {
|
override fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean {
|
||||||
val useCanId = queryFactory.select(useCan.id)
|
val useCanId = queryFactory.select(useCan.id)
|
||||||
.from(useCan)
|
.from(useCan)
|
||||||
.where(
|
.where(
|
||||||
useCan.member.id.eq(memberId)
|
useCan.member.id.eq(memberId)
|
||||||
.and(useCan.isRefund.isFalse)
|
.and(useCan.isRefund.isFalse)
|
||||||
.and(useCan.communityPost.id.eq(postId))
|
.and(useCan.communityPost.id.eq(postId))
|
||||||
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
)
|
)
|
||||||
.fetchFirst()
|
.fetchFirst()
|
||||||
|
|
||||||
return useCanId != null && useCanId > 0
|
return useCanId != null && useCanId > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun countPurchasedActiveImagesByCharacter(
|
||||||
|
memberId: Long,
|
||||||
|
characterId: Long,
|
||||||
|
usages: Collection<CanUsage>
|
||||||
|
): Long {
|
||||||
|
val count = queryFactory
|
||||||
|
.selectDistinct(useCan.characterImage.id)
|
||||||
|
.from(useCan)
|
||||||
|
.where(
|
||||||
|
useCan.member.id.eq(memberId)
|
||||||
|
.and(useCan.isRefund.isFalse)
|
||||||
|
.and(useCan.characterImage.chatCharacter.id.eq(characterId))
|
||||||
|
.and(useCan.characterImage.isActive.isTrue)
|
||||||
|
.and(useCan.canUsage.`in`(usages))
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
return count.toLong()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,163 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.CascadeType
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.EnumType
|
||||||
|
import javax.persistence.Enumerated
|
||||||
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.JoinColumn
|
||||||
|
import javax.persistence.ManyToOne
|
||||||
|
import javax.persistence.OneToMany
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class ChatCharacter(
|
||||||
|
val characterUUID: String,
|
||||||
|
|
||||||
|
// 캐릭터 이름 (API 키 내에서 유일해야 함)
|
||||||
|
var name: String,
|
||||||
|
|
||||||
|
// 캐릭터 한 줄 소개
|
||||||
|
var description: String,
|
||||||
|
|
||||||
|
// AI 시스템 프롬프트
|
||||||
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
|
var systemPrompt: String,
|
||||||
|
|
||||||
|
// 나이
|
||||||
|
var age: Int? = null,
|
||||||
|
|
||||||
|
// 성별
|
||||||
|
var gender: String? = null,
|
||||||
|
|
||||||
|
// mbti
|
||||||
|
var mbti: String? = null,
|
||||||
|
|
||||||
|
// 말투 패턴 설명
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
var speechPattern: String? = null,
|
||||||
|
|
||||||
|
// 대화 스타일
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
var speechStyle: String? = null,
|
||||||
|
|
||||||
|
// 외모 설명
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
var appearance: String? = null,
|
||||||
|
|
||||||
|
// 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
||||||
|
@Column(nullable = true)
|
||||||
|
var originalTitle: String? = null,
|
||||||
|
|
||||||
|
// 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
||||||
|
@Column(nullable = true)
|
||||||
|
var originalLink: String? = null,
|
||||||
|
|
||||||
|
// 연관 원작 (한 캐릭터는 하나의 원작에만 속함)
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "original_work_id")
|
||||||
|
var originalWork: OriginalWork? = null,
|
||||||
|
|
||||||
|
// 캐릭터 유형
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
var characterType: CharacterType = CharacterType.Character,
|
||||||
|
|
||||||
|
var isActive: Boolean = true
|
||||||
|
) : BaseEntity() {
|
||||||
|
var imagePath: String? = null
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||||
|
var memories: MutableList<ChatCharacterMemory> = mutableListOf()
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||||
|
var personalities: MutableList<ChatCharacterPersonality> = mutableListOf()
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||||
|
var backgrounds: MutableList<ChatCharacterBackground> = mutableListOf()
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||||
|
var relationships: MutableList<ChatCharacterRelationship> = mutableListOf()
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||||
|
var tagMappings: MutableList<ChatCharacterTagMapping> = mutableListOf()
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||||
|
var valueMappings: MutableList<ChatCharacterValueMapping> = mutableListOf()
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||||
|
var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf()
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||||
|
var goalMappings: MutableList<ChatCharacterGoalMapping> = mutableListOf()
|
||||||
|
|
||||||
|
// 태그 추가 헬퍼 메소드
|
||||||
|
fun addTag(tag: ChatCharacterTag) {
|
||||||
|
val mapping = ChatCharacterTagMapping(this, tag)
|
||||||
|
tagMappings.add(mapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 가치관 추가 헬퍼 메소드
|
||||||
|
fun addValue(value: ChatCharacterValue) {
|
||||||
|
val mapping = ChatCharacterValueMapping(this, value)
|
||||||
|
valueMappings.add(mapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 취미 추가 헬퍼 메소드
|
||||||
|
fun addHobby(hobby: ChatCharacterHobby) {
|
||||||
|
val mapping = ChatCharacterHobbyMapping(this, hobby)
|
||||||
|
hobbyMappings.add(mapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목표 추가 헬퍼 메소드
|
||||||
|
fun addGoal(goal: ChatCharacterGoal) {
|
||||||
|
val mapping = ChatCharacterGoalMapping(this, goal)
|
||||||
|
goalMappings.add(mapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기억 추가 헬퍼 메소드
|
||||||
|
fun addMemory(title: String, content: String, emotion: String) {
|
||||||
|
val memory = ChatCharacterMemory(title, content, emotion, this)
|
||||||
|
memories.add(memory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성격 추가 헬퍼 메소드
|
||||||
|
fun addPersonality(trait: String, description: String) {
|
||||||
|
val personality = ChatCharacterPersonality(trait, description, this)
|
||||||
|
personalities.add(personality)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배경 추가 헬퍼 메소드
|
||||||
|
fun addBackground(topic: String, description: String) {
|
||||||
|
val background = ChatCharacterBackground(topic, description, this)
|
||||||
|
backgrounds.add(background)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 추가 헬퍼 메소드
|
||||||
|
fun addRelationship(
|
||||||
|
personName: String,
|
||||||
|
relationshipName: String,
|
||||||
|
description: String,
|
||||||
|
importance: Int,
|
||||||
|
relationshipType: String,
|
||||||
|
currentStatus: String
|
||||||
|
) {
|
||||||
|
val relationship = ChatCharacterRelationship(
|
||||||
|
personName,
|
||||||
|
relationshipName,
|
||||||
|
description,
|
||||||
|
importance,
|
||||||
|
relationshipType,
|
||||||
|
currentStatus,
|
||||||
|
this
|
||||||
|
)
|
||||||
|
relationships.add(relationship)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CharacterType {
|
||||||
|
Clone,
|
||||||
|
Character
|
||||||
|
}
|
@@ -0,0 +1,26 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.JoinColumn
|
||||||
|
import javax.persistence.ManyToOne
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배경 정보
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class ChatCharacterBackground(
|
||||||
|
// 배경 주제
|
||||||
|
val topic: String,
|
||||||
|
|
||||||
|
// 배경 설명
|
||||||
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
|
var description: String,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "chat_character_id")
|
||||||
|
val chatCharacter: ChatCharacter
|
||||||
|
) : BaseEntity()
|
@@ -0,0 +1,29 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.JoinColumn
|
||||||
|
import javax.persistence.ManyToOne
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 배너 엔티티
|
||||||
|
* 이미지와 캐릭터 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다.
|
||||||
|
* 정렬 순서(sortOrder)를 통해 배너의 표시 순서를 결정합니다.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
class ChatCharacterBanner(
|
||||||
|
// 배너 이미지 경로
|
||||||
|
var imagePath: String? = null,
|
||||||
|
|
||||||
|
// 연관된 캐릭터
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "character_id")
|
||||||
|
var chatCharacter: ChatCharacter,
|
||||||
|
|
||||||
|
// 정렬 순서 (낮을수록 먼저 표시)
|
||||||
|
var sortOrder: Int = 0,
|
||||||
|
|
||||||
|
// 활성화 여부 (소프트 삭제용)
|
||||||
|
var isActive: Boolean = true
|
||||||
|
) : BaseEntity()
|
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.OneToMany
|
||||||
|
import javax.persistence.Table
|
||||||
|
import javax.persistence.UniqueConstraint
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 목표
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["goal"])])
|
||||||
|
class ChatCharacterGoal(
|
||||||
|
@Column(nullable = false)
|
||||||
|
val goal: String
|
||||||
|
) : BaseEntity() {
|
||||||
|
@OneToMany(mappedBy = "goal")
|
||||||
|
var goalMappings: MutableList<ChatCharacterGoalMapping> = mutableListOf()
|
||||||
|
}
|
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.JoinColumn
|
||||||
|
import javax.persistence.ManyToOne
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatCharacter와 ChatCharacterGoal 간의 매핑 엔티티
|
||||||
|
* ChatCharacterGoal의 중복을 방지하기 위한 매핑 테이블
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
class ChatCharacterGoalMapping(
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "chat_character_id")
|
||||||
|
val chatCharacter: ChatCharacter,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "goal_id")
|
||||||
|
val goal: ChatCharacterGoal
|
||||||
|
) : BaseEntity()
|
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.OneToMany
|
||||||
|
import javax.persistence.Table
|
||||||
|
import javax.persistence.UniqueConstraint
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 취미
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["hobby"])])
|
||||||
|
class ChatCharacterHobby(
|
||||||
|
@Column(nullable = false)
|
||||||
|
val hobby: String
|
||||||
|
) : BaseEntity() {
|
||||||
|
@OneToMany(mappedBy = "hobby")
|
||||||
|
var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf()
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user