Compare commits
298 Commits
test
...
83028f7817
Author | SHA1 | Date | |
---|---|---|---|
83028f7817 | |||
70d1795557 | |||
8c6c681424 | |||
50bc9f4ff3 | |||
f00ea03fad | |||
f22e7b9ad1 | |||
c7ec95f4bb | |||
229e7a8ccc | |||
3c616474ff | |||
56eb6b3ce3 | |||
545836d43c | |||
219f83dec0 | |||
a76a841238 | |||
c26680de84 | |||
8fffad9d3a | |||
f4f0f203a2 | |||
b7196f5a0c | |||
5d33a18890 | |||
96186a1a50 | |||
bc8bc479d1 | |||
47595b1291 | |||
01a88964df | |||
3a2b77379f | |||
dc4e5f75cd | |||
d0178d551c | |||
827333108d | |||
587b90bd27 | |||
4dc20c5e90 | |||
ac25782f2b | |||
20437d56e7 | |||
f0b412828a | |||
367faac5c3 | |||
84deaaa970 | |||
a2b39466c2 | |||
03586c4005 | |||
6ea69e1510 | |||
553c6dc539 | |||
6cc22f5b6d | |||
9103d67cc1 | |||
25083fb0e4 | |||
d2dc045255 | |||
b8621dfbb0 | |||
93633940dd | |||
b6f5325351 | |||
7c32c08f1f | |||
1d268da08d | |||
797666ae0d | |||
dcf470997e | |||
0974d1dbf8 | |||
12a35db6cd | |||
9abbb05ad8 | |||
1ecaf69b0b | |||
e334d1e5d9 | |||
b735e861d0 | |||
4eb433d372 | |||
2416ae61f3 | |||
01fb336985 | |||
b6af88a732 | |||
58a2a17d6d | |||
79f5a0f520 | |||
7f6c0f7f04 | |||
f658df4dca | |||
9d43b8e23a | |||
4270aef79b | |||
1c0dc82d44 | |||
c1e325aadf | |||
cec87da69d | |||
f68f24cb2c | |||
ed094347fc | |||
b8afdffbe1 | |||
f6ba79f31c | |||
5f3b1663d2 | |||
66e786b4bb | |||
f671114574 | |||
ce37060d94 | |||
7d19a4d184 | |||
22f28a2f8a | |||
ceef9ca979 | |||
efe8f4f939 | |||
ba692a1195 | |||
d732bad042 | |||
4c935c3bee | |||
c160dd791f | |||
23cd1b4601 | |||
031fc8ba1b | |||
c6853289ad | |||
2497bb69bc | |||
a58a67e0a2 | |||
4315fe12a5 | |||
42f10a8899 | |||
1e4b47f989 | |||
ff255dbfae | |||
dbe9b72feb | |||
95a714b391 | |||
28f58c7f56 | |||
8bd46d8f21 | |||
e1bb8e54ed | |||
1de705b063 | |||
f6926ad356 | |||
2cdbbb1b37 | |||
4dce8c8f03 | |||
97a5bace6f | |||
d4d51ec48f | |||
fb91398462 | |||
105dadd798 | |||
2abf2837d3 | |||
422aa67af6 | |||
7fffab6985 | |||
5a4be3d2c1 | |||
f39a7681db | |||
c60a7580ba | |||
97edb56edc | |||
6ebca8d22b | |||
95371ad934 | |||
2c176825fd | |||
fae7de48d3 | |||
b8230646a2 | |||
43279541dd | |||
b4791977c1 | |||
ef917ecc25 | |||
a93faad951 | |||
fd001d24d3 | |||
7aa5884797 | |||
5b237a1547 | |||
2e37990d87 | |||
dd07d724a8 | |||
03ce8618e7 | |||
db1a7a7fd6 | |||
36a82d7f53 | |||
3a34401113 | |||
9927268330 | |||
c45c97e29d | |||
c64a315226 | |||
a4cafca6ab | |||
46284a0660 | |||
05df86e15a | |||
8b433027e2 | |||
5bd4ff7610 | |||
d693c397ea | |||
1d8d1ec9a5 | |||
5e491f11ee | |||
7cedea06ac | |||
2e5f750e50 | |||
20289cad10 | |||
e0d64c31c7 | |||
8c1b95dc97 | |||
fb5641343e | |||
87765941eb | |||
1809862c16 | |||
300f784f7d | |||
67a045eae6 | |||
2a79903a28 | |||
d3222ce083 | |||
406a421742 | |||
10bf728faf | |||
607617747c | |||
f0a69eb1a2 | |||
6b307a6e17 | |||
08d08a934a | |||
c500c12668 | |||
62060adeba | |||
b2fc75edb8 | |||
a999dd2085 | |||
49f95ab100 | |||
1a84d5b30c | |||
3b65050632 | |||
d0df31674c | |||
1fe88402e2 | |||
67097696e6 | |||
8e7e77067a | |||
9899390b61 | |||
80c476a908 | |||
59da1d6e49 | |||
5aef7dac33 | |||
faf7aa06b6 | |||
38ef6e5583 | |||
c0b15b5d94 | |||
2cfc067ea1 | |||
a91db4f956 | |||
8a09780a02 | |||
45e8ec6505 | |||
4554b85914 | |||
8aa79c4a9c | |||
c8d3210b57 | |||
2282a49563 | |||
b82fdfb2c8 | |||
2d17eac199 | |||
e482bc3aad | |||
ec022b74d1 | |||
dc42c09ce3 | |||
046a34d2a4 | |||
9ff6ec1888 | |||
d2950106ec | |||
962f800d2e | |||
962107e507 | |||
039bd11963 | |||
5c250ea4ae | |||
e3405bcec6 | |||
0fd1c2235f | |||
b20c29b022 | |||
12d5dcd298 | |||
2c305dc6c6 | |||
62f76f7433 | |||
858ce524f9 | |||
3795fb4a40 | |||
0c01aeec50 | |||
892206744d | |||
9e2c1474db | |||
16328f73d9 | |||
e0d4f53cf4 | |||
e09a59c5b4 | |||
049e654535 | |||
c927dc4ecd | |||
fe4ecd0ad8 | |||
78d476fe80 | |||
a11c8465d5 | |||
366304a9b7 | |||
4356663688 | |||
26b55e6fcf | |||
0d743f7204 | |||
6cbe113b3e | |||
6409b69d6c | |||
c5164c76fc | |||
baade8e138 | |||
b848d6b4e0 | |||
d8139d2ab0 | |||
e96d8f7469 | |||
2acffd8afc | |||
3c8e72073c | |||
724d7a9d9b | |||
2da3b0db78 | |||
685ad7afaf | |||
264cf75964 | |||
c773dbc7b5 | |||
37cbc64f52 | |||
cb1dde17bb | |||
c29988acf4 | |||
eadbf56dae | |||
4b3b455135 | |||
e6ac177396 | |||
3d0e29003f | |||
78b9b00f77 | |||
0ee7faa551 | |||
e5fdced681 | |||
afb99fef64 | |||
7dfaa36024 | |||
0496f665aa | |||
0d19e1be74 | |||
4aff0111aa | |||
63b3ba2bb2 | |||
7444b41f60 | |||
8e90dbc8b6 | |||
9f70722521 | |||
52fae596fa | |||
ccb67957bc | |||
fb82538d0d | |||
72ee39612e | |||
51fd5408dc | |||
3fae40fbef | |||
0745890af0 | |||
4abe1730a7 | |||
626f0e6989 | |||
9f42d9d173 | |||
f90a93c4bc | |||
8000ad6c6a | |||
1f1f1bea1a | |||
d95460c7cd | |||
a3d93d4b08 | |||
07a92af982 | |||
f4618877d4 | |||
2b914fd222 | |||
109e42a5a3 | |||
fa515ad39c | |||
f09673a795 | |||
f71536c614 | |||
7bdddc7ae8 | |||
aa8926a624 | |||
be71e59be2 | |||
4d7753378f | |||
60257c4ef4 | |||
1e0b79bf62 | |||
6883434d0d | |||
eda2193e64 | |||
99bf829c88 | |||
5feafe1b48 | |||
c9292b7d04 | |||
ae7e1a91c1 | |||
3e1887e0d1 | |||
474646db47 | |||
56f7b6c449 | |||
76b2b5f7e3 | |||
e918d809eb | |||
7af059e543 | |||
897726e1ec | |||
8b98a2dd07 | |||
cca75420f0 | |||
86c627ed1d | |||
d55514e3a7 |
@@ -7,5 +7,5 @@ indent_size = 4
|
|||||||
indent_style = space
|
indent_style = space
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 130
|
max_line_length = 120
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -323,7 +323,4 @@ 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,14 +65,9 @@ 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,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -39,10 +38,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -55,10 +51,6 @@ 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(
|
||||||
@@ -70,7 +62,6 @@ 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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -78,10 +69,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -92,7 +80,6 @@ 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())
|
||||||
@@ -126,10 +113,6 @@ 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(
|
||||||
@@ -140,7 +123,6 @@ 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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -148,19 +130,9 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(order.isActive.isTrue)
|
.where(order.isActive.isTrue)
|
||||||
.groupBy(
|
.groupBy(member.id, audioContent.id, order.type, order.can)
|
||||||
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())
|
||||||
@@ -239,10 +211,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -263,10 +232,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -296,10 +262,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -319,10 +282,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -352,10 +312,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -375,10 +332,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -409,10 +363,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
|
@@ -22,15 +22,11 @@ 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 (totalPoint > 0) {
|
val orderTypeStr = if (orderType == OrderType.RENTAL) {
|
||||||
"포인트"
|
|
||||||
} else if (orderType == OrderType.RENTAL) {
|
|
||||||
"대여"
|
"대여"
|
||||||
} else {
|
} else {
|
||||||
"소장"
|
"소장"
|
||||||
|
@@ -21,15 +21,11 @@ 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 (totalPoint > 0) {
|
val orderTypeStr = if (orderType == OrderType.RENTAL) {
|
||||||
"포인트"
|
|
||||||
} else if (orderType == OrderType.RENTAL) {
|
|
||||||
"대여"
|
"대여"
|
||||||
} else {
|
} else {
|
||||||
"소장"
|
"소장"
|
||||||
|
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import java.time.LocalDateTime
|
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.FetchType
|
import javax.persistence.FetchType
|
||||||
import javax.persistence.JoinColumn
|
import javax.persistence.JoinColumn
|
||||||
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
|
|||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
data class CreatorSettlementRatio(
|
data class CreatorSettlementRatio(
|
||||||
var subsidy: Int,
|
val subsidy: Int,
|
||||||
var liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
var contentSettlementRatio: Int,
|
val contentSettlementRatio: Int,
|
||||||
var communitySettlementRatio: Int
|
val communitySettlementRatio: Int
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "member_id", nullable = false)
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
var member: Member? = null
|
var member: Member? = null
|
||||||
|
|
||||||
var deletedAt: LocalDateTime? = null
|
|
||||||
|
|
||||||
fun softDelete() {
|
|
||||||
this.deletedAt = LocalDateTime.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restore() {
|
|
||||||
this.deletedAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
|
|
||||||
this.subsidy = subsidy
|
|
||||||
this.liveSettlementRatio = live
|
|
||||||
this.contentSettlementRatio = content
|
|
||||||
this.communitySettlementRatio = community
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
@@ -28,14 +27,4 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
|
|||||||
limit = pageable.pageSize.toLong()
|
limit = pageable.pageSize.toLong()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@PostMapping("/update")
|
|
||||||
fun updateCreatorSettlementRatio(
|
|
||||||
@RequestBody request: CreateCreatorSettlementRatioRequest
|
|
||||||
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
|
|
||||||
|
|
||||||
@PostMapping("/delete/{memberId}")
|
|
||||||
fun deleteCreatorSettlementRatio(
|
|
||||||
@PathVariable memberId: Long
|
|
||||||
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
|
|
||||||
}
|
}
|
||||||
|
@@ -7,9 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
|
|
||||||
interface CreatorSettlementRatioRepository :
|
interface CreatorSettlementRatioRepository :
|
||||||
JpaRepository<CreatorSettlementRatio, Long>,
|
JpaRepository<CreatorSettlementRatio, Long>,
|
||||||
CreatorSettlementRatioQueryRepository {
|
CreatorSettlementRatioQueryRepository
|
||||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreatorSettlementRatioQueryRepository {
|
interface CreatorSettlementRatioQueryRepository {
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||||
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCreatorSettlementRatioItem(
|
QGetCreatorSettlementRatioItem(
|
||||||
member.id,
|
|
||||||
member.nickname,
|
member.nickname,
|
||||||
creatorSettlementRatio.subsidy,
|
creatorSettlementRatio.subsidy,
|
||||||
creatorSettlementRatio.liveSettlementRatio,
|
creatorSettlementRatio.liveSettlementRatio,
|
||||||
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
)
|
)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.innerJoin(creatorSettlementRatio.member, member)
|
.innerJoin(creatorSettlementRatio.member, member)
|
||||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
.orderBy(creatorSettlementRatio.id.asc())
|
.orderBy(creatorSettlementRatio.id.asc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(creatorSettlementRatio.id)
|
.select(creatorSettlementRatio.id)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
|
@@ -14,6 +14,8 @@ class CreatorSettlementRatioService(
|
|||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
|
val creatorSettlementRatio = request.toEntity()
|
||||||
|
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
|
||||||
@@ -21,52 +23,10 @@ class CreatorSettlementRatioService(
|
|||||||
throw SodaException("잘못된 크리에이터 입니다.")
|
throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
val existing = repository.findByMemberId(request.memberId)
|
|
||||||
if (existing != null) {
|
|
||||||
// revive if soft-deleted, then update values
|
|
||||||
existing.restore()
|
|
||||||
existing.updateValues(
|
|
||||||
request.subsidy,
|
|
||||||
request.liveSettlementRatio,
|
|
||||||
request.contentSettlementRatio,
|
|
||||||
request.communitySettlementRatio
|
|
||||||
)
|
|
||||||
repository.save(existing)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val creatorSettlementRatio = request.toEntity()
|
|
||||||
creatorSettlementRatio.member = creator
|
creatorSettlementRatio.member = creator
|
||||||
repository.save(creatorSettlementRatio)
|
repository.save(creatorSettlementRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
|
||||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
|
||||||
if (creator.role != MemberRole.CREATOR) {
|
|
||||||
throw SodaException("잘못된 크리에이터 입니다.")
|
|
||||||
}
|
|
||||||
val existing = repository.findByMemberId(request.memberId)
|
|
||||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
|
||||||
existing.restore()
|
|
||||||
existing.updateValues(
|
|
||||||
request.subsidy,
|
|
||||||
request.liveSettlementRatio,
|
|
||||||
request.contentSettlementRatio,
|
|
||||||
request.communitySettlementRatio
|
|
||||||
)
|
|
||||||
repository.save(existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun deleteCreatorSettlementRatio(memberId: Long) {
|
|
||||||
val existing = repository.findByMemberId(memberId)
|
|
||||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
|
||||||
existing.softDelete()
|
|
||||||
repository.save(existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||||
|
@@ -8,7 +8,6 @@ data class GetCreatorSettlementRatioResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||||
val memberId: Long,
|
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val subsidy: Int,
|
val subsidy: Int,
|
||||||
val liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
|
@@ -1,10 +1,8 @@
|
|||||||
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
|
||||||
@@ -15,11 +13,6 @@ 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,38 +1,6 @@
|
|||||||
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>, AdminCanQueryRepository
|
interface AdminCanRepository : JpaRepository<Can, Long>
|
||||||
|
|
||||||
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,13 +3,11 @@ 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: BigDecimal,
|
val price: Int
|
||||||
val currency: String
|
|
||||||
) {
|
) {
|
||||||
fun toEntity(): Can {
|
fun toEntity(): Can {
|
||||||
var title = "${can.moneyFormat()} 캔"
|
var title = "${can.moneyFormat()} 캔"
|
||||||
@@ -22,7 +20,6 @@ data class AdminCanRequest(
|
|||||||
can = can,
|
can = can,
|
||||||
rewardCan = rewardCan,
|
rewardCan = rewardCan,
|
||||||
price = price,
|
price = price,
|
||||||
currency = currency,
|
|
||||||
status = CanStatus.SALE
|
status = CanStatus.SALE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -21,10 +20,6 @@ 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,7 +21,6 @@ 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
|
||||||
@RequestParam(value = "currency", required = false) currency: String? = null
|
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
|
||||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
|
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -15,7 +14,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<GetChargeStatusResponse> {
|
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
|
||||||
val formattedDate = Expressions.stringTemplate(
|
val formattedDate = Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
Expressions.dateTimeTemplate(
|
Expressions.dateTimeTemplate(
|
||||||
@@ -27,16 +26,15 @@ 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(
|
||||||
QGetChargeStatusResponse(
|
QGetChargeStatusQueryDto(
|
||||||
formattedDate,
|
formattedDate,
|
||||||
payment.price.sum(),
|
payment.price.sum(),
|
||||||
|
can1.price.sum(),
|
||||||
payment.id.count(),
|
payment.id.count(),
|
||||||
payment.paymentGateway.stringValue(),
|
payment.paymentGateway
|
||||||
currency.coalesce("KRW")
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(payment)
|
.from(payment)
|
||||||
@@ -48,46 +46,15 @@ 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, currency.coalesce("KRW"))
|
.groupBy(formattedDate, payment.paymentGateway)
|
||||||
.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})",
|
||||||
@@ -100,20 +67,6 @@ 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(
|
||||||
@@ -122,7 +75,8 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
member.nickname,
|
member.nickname,
|
||||||
payment.method.coalesce(""),
|
payment.method.coalesce(""),
|
||||||
payment.price,
|
payment.price,
|
||||||
currencyExpr,
|
can1.price,
|
||||||
|
payment.locale.coalesce(""),
|
||||||
formattedDate
|
formattedDate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -130,7 +84,13 @@ 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(whereBuilder)
|
.where(
|
||||||
|
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,17 +20,48 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
|
var totalChargeAmount = 0
|
||||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
|
var totalChargeCount = 0L
|
||||||
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)
|
||||||
@@ -43,16 +74,18 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
|
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
|
||||||
|
.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.amount,
|
amount = it.appleChargeAmount.toInt(),
|
||||||
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 amount: BigDecimal,
|
val appleChargeAmount: Double,
|
||||||
|
val pgChargeAmount: Int,
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: String
|
val datetime: String
|
||||||
)
|
)
|
||||||
|
@@ -1,12 +1,10 @@
|
|||||||
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: BigDecimal,
|
val amount: Int,
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: String
|
val datetime: String
|
||||||
)
|
)
|
||||||
|
@@ -0,0 +1,12 @@
|
|||||||
|
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,12 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
data class GetChargeStatusResponse(
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class GetChargeStatusResponse @QueryProjection constructor(
|
|
||||||
val date: String,
|
val date: String,
|
||||||
val chargeAmount: BigDecimal,
|
val chargeAmount: Int,
|
||||||
val chargeCount: Long,
|
val chargeCount: Long,
|
||||||
val pg: String,
|
val pg: String
|
||||||
val currency: String
|
|
||||||
)
|
)
|
||||||
|
@@ -1,229 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat
|
|
||||||
|
|
||||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.dto.UpdateBannerOrdersRequest
|
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RequestPart
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.multipart.MultipartFile
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/admin/chat/banner")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
class AdminChatBannerController(
|
|
||||||
private val bannerService: ChatCharacterBannerService,
|
|
||||||
private val adminCharacterService: AdminChatCharacterService,
|
|
||||||
private val s3Uploader: S3Uploader,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
|
||||||
private val s3Bucket: String,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
|
||||||
private val imageHost: String
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* 활성화된 배너 목록 조회 API
|
|
||||||
*
|
|
||||||
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
|
||||||
* @param size 페이지 크기 (기본값 20)
|
|
||||||
* @return 페이징된 배너 목록
|
|
||||||
*/
|
|
||||||
@GetMapping("/list")
|
|
||||||
fun getBannerList(
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
|
||||||
val banners = bannerService.getActiveBanners(pageable)
|
|
||||||
val response = ChatCharacterBannerListPageResponse(
|
|
||||||
totalCount = banners.totalElements,
|
|
||||||
content = banners.content.map { ChatCharacterBannerResponse.from(it, imageHost) }
|
|
||||||
)
|
|
||||||
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배너 상세 조회 API
|
|
||||||
*
|
|
||||||
* @param bannerId 배너 ID
|
|
||||||
* @return 배너 상세 정보
|
|
||||||
*/
|
|
||||||
@GetMapping("/{bannerId}")
|
|
||||||
fun getBannerDetail(@PathVariable bannerId: Long) = run {
|
|
||||||
val banner = bannerService.getBannerById(bannerId)
|
|
||||||
val response = ChatCharacterBannerResponse.from(banner, imageHost)
|
|
||||||
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 검색 API (배너 등록을 위한)
|
|
||||||
*
|
|
||||||
* @param searchTerm 검색어 (이름, 설명, MBTI, 태그)
|
|
||||||
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
|
||||||
* @param size 페이지 크기 (기본값 20)
|
|
||||||
* @return 검색된 캐릭터 목록
|
|
||||||
*/
|
|
||||||
@GetMapping("/search-character")
|
|
||||||
fun searchCharacters(
|
|
||||||
@RequestParam searchTerm: String,
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
|
||||||
val pageResult = adminCharacterService.searchCharacters(searchTerm, pageable, imageHost)
|
|
||||||
val response = ChatCharacterSearchListPageResponse(
|
|
||||||
totalCount = pageResult.totalElements,
|
|
||||||
content = pageResult.content
|
|
||||||
)
|
|
||||||
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배너 등록 API
|
|
||||||
*
|
|
||||||
* @param image 배너 이미지
|
|
||||||
* @param requestString 배너 등록 요청 정보 (캐릭터 ID와 선택적으로 정렬 순서 포함)
|
|
||||||
* @return 등록된 배너 정보
|
|
||||||
*/
|
|
||||||
@PostMapping("/register")
|
|
||||||
fun registerBanner(
|
|
||||||
@RequestPart("image") image: MultipartFile,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(
|
|
||||||
requestString,
|
|
||||||
ChatCharacterBannerRegisterRequest::class.java
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함)
|
|
||||||
val banner = bannerService.registerBanner(
|
|
||||||
characterId = request.characterId,
|
|
||||||
imagePath = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
// 2. 배너 ID를 사용하여 이미지 업로드
|
|
||||||
val imagePath = saveImage(banner.id!!, image)
|
|
||||||
|
|
||||||
// 3. 이미지 경로로 배너 업데이트
|
|
||||||
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
|
|
||||||
|
|
||||||
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
|
|
||||||
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지를 S3에 업로드하고 경로를 반환
|
|
||||||
*
|
|
||||||
* @param bannerId 배너 ID (이미지 경로에 사용)
|
|
||||||
* @param image 업로드할 이미지 파일
|
|
||||||
* @return 업로드된 이미지 경로
|
|
||||||
*/
|
|
||||||
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
|
||||||
try {
|
|
||||||
val metadata = ObjectMetadata()
|
|
||||||
metadata.contentLength = image.size
|
|
||||||
|
|
||||||
val fileName = generateFileName("character-banner")
|
|
||||||
|
|
||||||
// S3에 이미지 업로드 (배너 ID를 경로에 사용)
|
|
||||||
return s3Uploader.upload(
|
|
||||||
inputStream = image.inputStream,
|
|
||||||
bucket = s3Bucket,
|
|
||||||
filePath = "characters/banners/$bannerId/$fileName",
|
|
||||||
metadata = metadata
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배너 수정 API
|
|
||||||
*
|
|
||||||
* @param image 배너 이미지
|
|
||||||
* @param requestString 배너 수정 요청 정보 (배너 ID와 선택적으로 캐릭터 ID 포함)
|
|
||||||
* @return 수정된 배너 정보
|
|
||||||
*/
|
|
||||||
@PutMapping("/update")
|
|
||||||
fun updateBanner(
|
|
||||||
@RequestPart("image") image: MultipartFile,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(
|
|
||||||
requestString,
|
|
||||||
ChatCharacterBannerUpdateRequest::class.java
|
|
||||||
)
|
|
||||||
// 배너 정보 조회
|
|
||||||
bannerService.getBannerById(request.bannerId)
|
|
||||||
|
|
||||||
// 배너 ID를 사용하여 이미지 업로드
|
|
||||||
val imagePath = saveImage(request.bannerId, image)
|
|
||||||
|
|
||||||
// 배너 수정 (이미지와 캐릭터 모두 수정 가능)
|
|
||||||
val updatedBanner = bannerService.updateBanner(
|
|
||||||
bannerId = request.bannerId,
|
|
||||||
imagePath = imagePath,
|
|
||||||
characterId = request.characterId
|
|
||||||
)
|
|
||||||
|
|
||||||
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
|
|
||||||
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배너 삭제 API (소프트 삭제)
|
|
||||||
*
|
|
||||||
* @param bannerId 배너 ID
|
|
||||||
* @return 성공 여부
|
|
||||||
*/
|
|
||||||
@DeleteMapping("/{bannerId}")
|
|
||||||
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
|
||||||
bannerService.deleteBanner(bannerId)
|
|
||||||
|
|
||||||
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배너 정렬 순서 일괄 변경 API
|
|
||||||
* ID 목록의 순서대로 정렬 순서를 1부터 순차적으로 설정합니다.
|
|
||||||
*
|
|
||||||
* @param request 정렬 순서 일괄 변경 요청 정보 (배너 ID 목록)
|
|
||||||
* @return 성공 메시지
|
|
||||||
*/
|
|
||||||
@PutMapping("/orders")
|
|
||||||
fun updateBannerOrders(
|
|
||||||
@RequestBody request: UpdateBannerOrdersRequest
|
|
||||||
) = run {
|
|
||||||
bannerService.updateBannerOrders(request.ids)
|
|
||||||
|
|
||||||
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,32 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import org.springframework.data.domain.Pageable
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@RequestMapping("/admin/chat/calculate")
|
|
||||||
class AdminChatCalculateController(
|
|
||||||
private val service: AdminChatCalculateService
|
|
||||||
) {
|
|
||||||
@GetMapping("/characters")
|
|
||||||
fun getCharacterCalculate(
|
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String,
|
|
||||||
@RequestParam(required = false, defaultValue = "TOTAL_SALES_DESC") sort: ChatCharacterCalculateSort,
|
|
||||||
pageable: Pageable
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
service.getCharacterCalculate(
|
|
||||||
startDateStr,
|
|
||||||
endDateStr,
|
|
||||||
sort,
|
|
||||||
pageable.offset,
|
|
||||||
pageable.pageSize
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,139 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
|
||||||
|
|
||||||
import com.querydsl.core.types.Projections
|
|
||||||
import com.querydsl.core.types.dsl.CaseBuilder
|
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
|
||||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
|
||||||
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
class AdminChatCalculateQueryRepository(
|
|
||||||
private val queryFactory: JPAQueryFactory,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
|
||||||
private val imageHost: String
|
|
||||||
) {
|
|
||||||
fun getCharacterCalculate(
|
|
||||||
startUtc: LocalDateTime,
|
|
||||||
endInclusiveUtc: LocalDateTime,
|
|
||||||
sort: ChatCharacterCalculateSort,
|
|
||||||
offset: Long,
|
|
||||||
limit: Long
|
|
||||||
): List<ChatCharacterCalculateQueryData> {
|
|
||||||
val imageCanExpr = CaseBuilder()
|
|
||||||
.`when`(useCan.canUsage.eq(CanUsage.CHARACTER_IMAGE_PURCHASE))
|
|
||||||
.then(useCan.can.add(useCan.rewardCan))
|
|
||||||
.otherwise(0)
|
|
||||||
|
|
||||||
val messageCanExpr = CaseBuilder()
|
|
||||||
.`when`(useCan.canUsage.eq(CanUsage.CHAT_MESSAGE_PURCHASE))
|
|
||||||
.then(useCan.can.add(useCan.rewardCan))
|
|
||||||
.otherwise(0)
|
|
||||||
|
|
||||||
val quotaCanExpr = CaseBuilder()
|
|
||||||
.`when`(useCan.canUsage.eq(CanUsage.CHAT_QUOTA_PURCHASE))
|
|
||||||
.then(useCan.can.add(useCan.rewardCan))
|
|
||||||
.otherwise(0)
|
|
||||||
|
|
||||||
val imageSum = imageCanExpr.sum()
|
|
||||||
val messageSum = messageCanExpr.sum()
|
|
||||||
val quotaSum = quotaCanExpr.sum()
|
|
||||||
val totalSum = imageSum.add(messageSum).add(quotaSum)
|
|
||||||
|
|
||||||
// 캐릭터 조인: 이미지 경로를 통한 캐릭터(c1) + characterId 직접 지정(c2)
|
|
||||||
val c1 = QChatCharacter("c1")
|
|
||||||
val c2 = QChatCharacter("c2")
|
|
||||||
|
|
||||||
val characterIdExpr = c1.id.coalesce(c2.id)
|
|
||||||
val characterNameAgg = Expressions.stringTemplate(
|
|
||||||
"coalesce(max({0}), max({1}), '')",
|
|
||||||
c1.name,
|
|
||||||
c2.name
|
|
||||||
)
|
|
||||||
val characterImagePathAgg = Expressions.stringTemplate(
|
|
||||||
"coalesce(max({0}), max({1}))",
|
|
||||||
c1.imagePath,
|
|
||||||
c2.imagePath
|
|
||||||
)
|
|
||||||
|
|
||||||
val query = queryFactory
|
|
||||||
.select(
|
|
||||||
Projections.constructor(
|
|
||||||
ChatCharacterCalculateQueryData::class.java,
|
|
||||||
characterIdExpr,
|
|
||||||
characterNameAgg,
|
|
||||||
characterImagePathAgg.prepend("/").prepend(imageHost),
|
|
||||||
imageSum,
|
|
||||||
messageSum,
|
|
||||||
quotaSum
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.from(useCan)
|
|
||||||
.leftJoin(useCan.characterImage, characterImage)
|
|
||||||
.leftJoin(characterImage.chatCharacter, c1)
|
|
||||||
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
|
||||||
.where(
|
|
||||||
useCan.isRefund.isFalse
|
|
||||||
.and(
|
|
||||||
useCan.canUsage.`in`(
|
|
||||||
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
|
||||||
CanUsage.CHAT_MESSAGE_PURCHASE,
|
|
||||||
CanUsage.CHAT_QUOTA_PURCHASE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.and(useCan.createdAt.goe(startUtc))
|
|
||||||
.and(useCan.createdAt.loe(endInclusiveUtc))
|
|
||||||
)
|
|
||||||
.groupBy(characterIdExpr)
|
|
||||||
|
|
||||||
when (sort) {
|
|
||||||
ChatCharacterCalculateSort.TOTAL_SALES_DESC ->
|
|
||||||
query.orderBy(totalSum.desc(), characterIdExpr.desc())
|
|
||||||
|
|
||||||
ChatCharacterCalculateSort.LATEST_DESC ->
|
|
||||||
query.orderBy(characterIdExpr.desc(), totalSum.desc())
|
|
||||||
}
|
|
||||||
|
|
||||||
return query
|
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
.fetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCharacterCalculateTotalCount(
|
|
||||||
startUtc: LocalDateTime,
|
|
||||||
endInclusiveUtc: LocalDateTime
|
|
||||||
): Int {
|
|
||||||
val c1 = QChatCharacter("c1")
|
|
||||||
val c2 = QChatCharacter("c2")
|
|
||||||
val characterIdExpr = c1.id.coalesce(c2.id)
|
|
||||||
|
|
||||||
return queryFactory
|
|
||||||
.select(characterIdExpr)
|
|
||||||
.from(useCan)
|
|
||||||
.leftJoin(useCan.characterImage, characterImage)
|
|
||||||
.leftJoin(characterImage.chatCharacter, c1)
|
|
||||||
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
|
||||||
.where(
|
|
||||||
useCan.isRefund.isFalse
|
|
||||||
.and(
|
|
||||||
useCan.canUsage.`in`(
|
|
||||||
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
|
||||||
CanUsage.CHAT_MESSAGE_PURCHASE,
|
|
||||||
CanUsage.CHAT_QUOTA_PURCHASE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.and(useCan.createdAt.goe(startUtc))
|
|
||||||
.and(useCan.createdAt.loe(endInclusiveUtc))
|
|
||||||
)
|
|
||||||
.groupBy(characterIdExpr)
|
|
||||||
.fetch()
|
|
||||||
.size
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,49 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class AdminChatCalculateService(
|
|
||||||
private val repository: AdminChatCalculateQueryRepository
|
|
||||||
) {
|
|
||||||
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
|
||||||
private val kstZone: ZoneId = ZoneId.of("Asia/Seoul")
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getCharacterCalculate(
|
|
||||||
startDateStr: String,
|
|
||||||
endDateStr: String,
|
|
||||||
sort: ChatCharacterCalculateSort,
|
|
||||||
offset: Long,
|
|
||||||
pageSize: Int
|
|
||||||
): ChatCharacterCalculateResponse {
|
|
||||||
// 날짜 유효성 검증 (KST 기준)
|
|
||||||
val startDate = LocalDate.parse(startDateStr, dateFormatter)
|
|
||||||
val endDate = LocalDate.parse(endDateStr, dateFormatter)
|
|
||||||
val todayKst = LocalDate.now(kstZone)
|
|
||||||
|
|
||||||
if (endDate.isAfter(todayKst)) {
|
|
||||||
throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.")
|
|
||||||
}
|
|
||||||
if (startDate.isAfter(endDate)) {
|
|
||||||
throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.")
|
|
||||||
}
|
|
||||||
if (endDate.isAfter(startDate.plusMonths(6))) {
|
|
||||||
throw SodaException("조회 가능 기간은 최대 6개월입니다.")
|
|
||||||
}
|
|
||||||
|
|
||||||
val startUtc = startDateStr.convertLocalDateTime()
|
|
||||||
val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
|
||||||
|
|
||||||
val totalCount = repository.getCharacterCalculateTotalCount(startUtc, endInclusiveUtc)
|
|
||||||
val rows = repository.getCharacterCalculate(startUtc, endInclusiveUtc, sort, offset, pageSize.toLong())
|
|
||||||
val items = rows.map { it.toItem() }
|
|
||||||
return ChatCharacterCalculateResponse(totalCount, items)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,62 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
|
||||||
import java.math.BigDecimal
|
|
||||||
import java.math.RoundingMode
|
|
||||||
|
|
||||||
// 정렬 옵션
|
|
||||||
enum class ChatCharacterCalculateSort {
|
|
||||||
TOTAL_SALES_DESC,
|
|
||||||
LATEST_DESC
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryDSL 프로젝션용 DTO
|
|
||||||
data class ChatCharacterCalculateQueryData @QueryProjection constructor(
|
|
||||||
val characterId: Long,
|
|
||||||
val characterName: String,
|
|
||||||
val characterImagePath: String?,
|
|
||||||
val imagePurchaseCan: Int?,
|
|
||||||
val messagePurchaseCan: Int?,
|
|
||||||
val quotaPurchaseCan: Int?
|
|
||||||
)
|
|
||||||
|
|
||||||
// 응답 DTO (아이템)
|
|
||||||
data class ChatCharacterCalculateItem(
|
|
||||||
@JsonProperty("characterId") val characterId: Long,
|
|
||||||
@JsonProperty("characterImage") val characterImage: String?,
|
|
||||||
@JsonProperty("name") val name: String,
|
|
||||||
@JsonProperty("imagePurchaseCan") val imagePurchaseCan: Int,
|
|
||||||
@JsonProperty("messagePurchaseCan") val messagePurchaseCan: Int,
|
|
||||||
@JsonProperty("quotaPurchaseCan") val quotaPurchaseCan: Int,
|
|
||||||
@JsonProperty("totalCan") val totalCan: Int,
|
|
||||||
@JsonProperty("totalKrw") val totalKrw: Int,
|
|
||||||
@JsonProperty("settlementKrw") val settlementKrw: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
// 응답 DTO (전체)
|
|
||||||
data class ChatCharacterCalculateResponse(
|
|
||||||
@JsonProperty("totalCount") val totalCount: Int,
|
|
||||||
@JsonProperty("items") val items: List<ChatCharacterCalculateItem>
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem {
|
|
||||||
val image = imagePurchaseCan ?: 0
|
|
||||||
val message = messagePurchaseCan ?: 0
|
|
||||||
val quota = quotaPurchaseCan ?: 0
|
|
||||||
val total = image + message + quota
|
|
||||||
val totalKrw = BigDecimal(total).multiply(BigDecimal(100))
|
|
||||||
val settlement = totalKrw.multiply(BigDecimal("0.10")).setScale(0, RoundingMode.HALF_UP)
|
|
||||||
|
|
||||||
return ChatCharacterCalculateItem(
|
|
||||||
characterId = characterId,
|
|
||||||
characterImage = characterImagePath,
|
|
||||||
name = characterName,
|
|
||||||
imagePurchaseCan = image,
|
|
||||||
messagePurchaseCan = message,
|
|
||||||
quotaPurchaseCan = quota,
|
|
||||||
totalCan = total,
|
|
||||||
totalKrw = totalKrw.toInt(),
|
|
||||||
settlementKrw = settlement.toInt()
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,423 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character
|
|
||||||
|
|
||||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.http.HttpEntity
|
|
||||||
import org.springframework.http.HttpHeaders
|
|
||||||
import org.springframework.http.HttpMethod
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.http.client.SimpleClientHttpRequestFactory
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RequestPart
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
import org.springframework.web.multipart.MultipartFile
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/admin/chat/character")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
class AdminChatCharacterController(
|
|
||||||
private val service: ChatCharacterService,
|
|
||||||
private val adminService: AdminChatCharacterService,
|
|
||||||
private val s3Uploader: S3Uploader,
|
|
||||||
private val originalWorkService: AdminOriginalWorkService,
|
|
||||||
|
|
||||||
@Value("\${weraser.api-key}")
|
|
||||||
private val apiKey: String,
|
|
||||||
|
|
||||||
@Value("\${weraser.api-url}")
|
|
||||||
private val apiUrl: String,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
|
||||||
private val s3Bucket: String,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
|
||||||
private val imageHost: String
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* 활성화된 캐릭터 목록 조회 API
|
|
||||||
*
|
|
||||||
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
|
||||||
* @param size 페이지 크기 (기본값 20)
|
|
||||||
* @return 페이징된 캐릭터 목록
|
|
||||||
*/
|
|
||||||
@GetMapping("/list")
|
|
||||||
fun getCharacterList(
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageable = adminService.createDefaultPageRequest(page, size)
|
|
||||||
val response = adminService.getActiveChatCharacters(pageable, imageHost)
|
|
||||||
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 검색(관리자)
|
|
||||||
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
|
|
||||||
* - 페이징 지원: page, size 파라미터 사용
|
|
||||||
*/
|
|
||||||
@GetMapping("/search")
|
|
||||||
fun searchCharacters(
|
|
||||||
@RequestParam("searchTerm") searchTerm: String,
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageable = adminService.createDefaultPageRequest(page, size)
|
|
||||||
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
|
|
||||||
val response = ChatCharacterSearchListPageResponse(
|
|
||||||
totalCount = resultPage.totalElements,
|
|
||||||
content = resultPage.content
|
|
||||||
)
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 상세 정보 조회 API
|
|
||||||
*
|
|
||||||
* @param characterId 캐릭터 ID
|
|
||||||
* @return 캐릭터 상세 정보
|
|
||||||
*/
|
|
||||||
@GetMapping("/{characterId}")
|
|
||||||
fun getCharacterDetail(
|
|
||||||
@PathVariable characterId: Long
|
|
||||||
) = run {
|
|
||||||
val response = adminService.getChatCharacterDetail(characterId, imageHost)
|
|
||||||
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/register")
|
|
||||||
fun registerCharacter(
|
|
||||||
@RequestPart("image") image: MultipartFile,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
// JSON 문자열을 ChatCharacterRegisterRequest 객체로 변환
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(requestString, ChatCharacterRegisterRequest::class.java)
|
|
||||||
|
|
||||||
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
|
|
||||||
val existingCharacter = service.findByName(request.name)
|
|
||||||
if (existingCharacter != null) {
|
|
||||||
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 외부 API 호출
|
|
||||||
val characterUUID = callExternalApi(request)
|
|
||||||
|
|
||||||
// 2. ChatCharacter 저장
|
|
||||||
val chatCharacter = service.createChatCharacterWithDetails(
|
|
||||||
characterUUID = characterUUID,
|
|
||||||
name = request.name,
|
|
||||||
description = request.description,
|
|
||||||
systemPrompt = request.systemPrompt,
|
|
||||||
age = request.age?.toIntOrNull(),
|
|
||||||
gender = request.gender,
|
|
||||||
mbti = request.mbti,
|
|
||||||
speechPattern = request.speechPattern,
|
|
||||||
speechStyle = request.speechStyle,
|
|
||||||
appearance = request.appearance,
|
|
||||||
originalTitle = request.originalTitle,
|
|
||||||
originalLink = request.originalLink,
|
|
||||||
characterType = request.characterType?.let {
|
|
||||||
runCatching { CharacterType.valueOf(it) }
|
|
||||||
.getOrDefault(CharacterType.Character)
|
|
||||||
} ?: CharacterType.Character,
|
|
||||||
tags = request.tags,
|
|
||||||
values = request.values,
|
|
||||||
hobbies = request.hobbies,
|
|
||||||
goals = request.goals,
|
|
||||||
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
|
|
||||||
personalities = request.personalities.map { Pair(it.trait, it.description) },
|
|
||||||
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
|
|
||||||
relationships = request.relationships
|
|
||||||
)
|
|
||||||
|
|
||||||
// 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정
|
|
||||||
val imagePath = saveImage(
|
|
||||||
characterId = chatCharacter.id!!,
|
|
||||||
image = image
|
|
||||||
)
|
|
||||||
chatCharacter.imagePath = imagePath
|
|
||||||
service.saveChatCharacter(chatCharacter)
|
|
||||||
|
|
||||||
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
|
||||||
if (request.originalWorkId != null) {
|
|
||||||
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun callExternalApi(request: ChatCharacterRegisterRequest): String {
|
|
||||||
try {
|
|
||||||
val factory = SimpleClientHttpRequestFactory()
|
|
||||||
factory.setConnectTimeout(20000) // 20초
|
|
||||||
factory.setReadTimeout(20000) // 20초
|
|
||||||
|
|
||||||
val restTemplate = RestTemplate(factory)
|
|
||||||
|
|
||||||
val headers = HttpHeaders()
|
|
||||||
headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요
|
|
||||||
headers.contentType = MediaType.APPLICATION_JSON
|
|
||||||
|
|
||||||
// 외부 API에 전달하지 않을 필드(originalTitle, originalLink, characterType)를 제외하고 바디 구성
|
|
||||||
val body = mutableMapOf<String, Any>()
|
|
||||||
body["name"] = request.name
|
|
||||||
body["systemPrompt"] = request.systemPrompt
|
|
||||||
body["description"] = request.description
|
|
||||||
request.age?.let { body["age"] = it }
|
|
||||||
request.gender?.let { body["gender"] = it }
|
|
||||||
request.mbti?.let { body["mbti"] = it }
|
|
||||||
request.speechPattern?.let { body["speechPattern"] = it }
|
|
||||||
request.speechStyle?.let { body["speechStyle"] = it }
|
|
||||||
request.appearance?.let { body["appearance"] = it }
|
|
||||||
if (request.tags.isNotEmpty()) body["tags"] = request.tags
|
|
||||||
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
|
|
||||||
if (request.values.isNotEmpty()) body["values"] = request.values
|
|
||||||
if (request.goals.isNotEmpty()) body["goals"] = request.goals
|
|
||||||
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
|
|
||||||
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
|
|
||||||
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
|
|
||||||
if (request.memories.isNotEmpty()) body["memories"] = request.memories
|
|
||||||
|
|
||||||
val httpEntity = HttpEntity(body, headers)
|
|
||||||
|
|
||||||
val response = restTemplate.exchange(
|
|
||||||
"$apiUrl/api/characters",
|
|
||||||
HttpMethod.POST,
|
|
||||||
httpEntity,
|
|
||||||
String::class.java
|
|
||||||
)
|
|
||||||
|
|
||||||
// 응답 파싱
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
|
||||||
|
|
||||||
// success가 false이면 throw
|
|
||||||
if (!apiResponse.success) {
|
|
||||||
throw SodaException(apiResponse.message ?: "등록에 실패했습니다. 다시 시도해 주세요.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// success가 true이면 data.id 반환
|
|
||||||
return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveImage(characterId: Long, image: MultipartFile): String {
|
|
||||||
try {
|
|
||||||
val metadata = ObjectMetadata()
|
|
||||||
metadata.contentLength = image.size
|
|
||||||
|
|
||||||
// S3에 이미지 업로드
|
|
||||||
return s3Uploader.upload(
|
|
||||||
inputStream = image.inputStream,
|
|
||||||
bucket = s3Bucket,
|
|
||||||
filePath = "characters/$characterId/${generateFileName(prefix = "character")}",
|
|
||||||
metadata = metadata
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 수정 API
|
|
||||||
* 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
|
||||||
* 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
|
||||||
* 3. 이미지 있는지 확인
|
|
||||||
* 4. 2, 3번 중 하나라도 해당 하면 계속 진행
|
|
||||||
* 5. 2, 3번에 데이터 없으면 throw SodaException('변경된 데이터가 없습니다.')
|
|
||||||
*
|
|
||||||
* @param image 캐릭터 이미지 (선택적)
|
|
||||||
* @param requestString ChatCharacterUpdateRequest 객체를 JSON 문자열로 변환한 값
|
|
||||||
* @return ApiResponse 객체
|
|
||||||
* @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우
|
|
||||||
*/
|
|
||||||
@PutMapping("/update")
|
|
||||||
fun updateCharacter(
|
|
||||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
// 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java)
|
|
||||||
|
|
||||||
// 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
|
||||||
val hasChangedData = hasChanges(request) // 외부 API 대상으로의 변경 여부(3가지 필드 제외)
|
|
||||||
|
|
||||||
// 3. 이미지 있는지 확인
|
|
||||||
val hasImage = image != null && !image.isEmpty
|
|
||||||
|
|
||||||
// 3가지만 변경된 경우(외부 API 변경은 없지만 DB 변경은 있는 경우)를 허용하기 위해 별도 플래그 계산
|
|
||||||
val hasDbOnlyChanges =
|
|
||||||
request.originalTitle != null ||
|
|
||||||
request.originalLink != null ||
|
|
||||||
request.characterType != null ||
|
|
||||||
request.originalWorkId != null
|
|
||||||
|
|
||||||
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
|
||||||
throw SodaException("변경된 데이터가 없습니다.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
|
|
||||||
if (hasChangedData) {
|
|
||||||
val chatCharacter = service.findById(request.id)
|
|
||||||
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")
|
|
||||||
|
|
||||||
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
|
|
||||||
if (request.name != null && request.name != chatCharacter.name) {
|
|
||||||
val existingCharacter = service.findByName(request.name)
|
|
||||||
if (existingCharacter != null) {
|
|
||||||
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
callExternalApiForUpdate(chatCharacter.characterUUID, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미지 경로 변수 초기화
|
|
||||||
// 이미지가 있으면 이미지 저장
|
|
||||||
val imagePath = if (hasImage) {
|
|
||||||
saveImage(
|
|
||||||
characterId = request.id,
|
|
||||||
image = image!!
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 엔티티 수정
|
|
||||||
service.updateChatCharacterWithDetails(
|
|
||||||
imagePath = imagePath,
|
|
||||||
request = request
|
|
||||||
)
|
|
||||||
|
|
||||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
|
||||||
if (request.originalWorkId != null) {
|
|
||||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
|
||||||
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 요청에 변경된 데이터가 있는지 확인
|
|
||||||
* id를 제외한 모든 필드가 null이면 변경된 데이터가 없는 것으로 판단
|
|
||||||
*
|
|
||||||
* @param request 수정 요청 데이터
|
|
||||||
* @return 변경된 데이터가 있으면 true, 없으면 false
|
|
||||||
*/
|
|
||||||
private fun hasChanges(request: ChatCharacterUpdateRequest): Boolean {
|
|
||||||
return request.systemPrompt != null ||
|
|
||||||
request.description != null ||
|
|
||||||
request.age != null ||
|
|
||||||
request.gender != null ||
|
|
||||||
request.mbti != null ||
|
|
||||||
request.speechPattern != null ||
|
|
||||||
request.speechStyle != null ||
|
|
||||||
request.appearance != null ||
|
|
||||||
request.isActive != null ||
|
|
||||||
request.tags != null ||
|
|
||||||
request.hobbies != null ||
|
|
||||||
request.values != null ||
|
|
||||||
request.goals != null ||
|
|
||||||
request.relationships != null ||
|
|
||||||
request.personalities != null ||
|
|
||||||
request.backgrounds != null ||
|
|
||||||
request.memories != null ||
|
|
||||||
request.name != null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 외부 API 호출 - 수정 API
|
|
||||||
* 변경된 데이터만 요청에 포함
|
|
||||||
*
|
|
||||||
* @param characterUUID 캐릭터 UUID
|
|
||||||
* @param request 수정 요청 데이터
|
|
||||||
*/
|
|
||||||
private fun callExternalApiForUpdate(characterUUID: String, request: ChatCharacterUpdateRequest) {
|
|
||||||
try {
|
|
||||||
val factory = SimpleClientHttpRequestFactory()
|
|
||||||
factory.setConnectTimeout(20000) // 20초
|
|
||||||
factory.setReadTimeout(20000) // 20초
|
|
||||||
|
|
||||||
val restTemplate = RestTemplate(factory)
|
|
||||||
|
|
||||||
val headers = HttpHeaders()
|
|
||||||
headers.set("x-api-key", apiKey)
|
|
||||||
headers.contentType = MediaType.APPLICATION_JSON
|
|
||||||
|
|
||||||
// 변경된 데이터만 포함하는 맵 생성
|
|
||||||
val updateData = mutableMapOf<String, Any>()
|
|
||||||
|
|
||||||
// isActive = false인 경우 처리
|
|
||||||
if (request.isActive != null && !request.isActive) {
|
|
||||||
val inactiveName = "inactive_${request.name}"
|
|
||||||
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
|
|
||||||
updateData["name"] = inactiveName + randomSuffix
|
|
||||||
} else {
|
|
||||||
request.name?.let { updateData["name"] = it }
|
|
||||||
request.systemPrompt?.let { updateData["systemPrompt"] = it }
|
|
||||||
request.description?.let { updateData["description"] = it }
|
|
||||||
request.age?.let { updateData["age"] = it }
|
|
||||||
request.gender?.let { updateData["gender"] = it }
|
|
||||||
request.mbti?.let { updateData["mbti"] = it }
|
|
||||||
request.speechPattern?.let { updateData["speechPattern"] = it }
|
|
||||||
request.speechStyle?.let { updateData["speechStyle"] = it }
|
|
||||||
request.appearance?.let { updateData["appearance"] = it }
|
|
||||||
request.tags?.let { updateData["tags"] = it }
|
|
||||||
request.hobbies?.let { updateData["hobbies"] = it }
|
|
||||||
request.values?.let { updateData["values"] = it }
|
|
||||||
request.goals?.let { updateData["goals"] = it }
|
|
||||||
request.relationships?.let { updateData["relationships"] = it }
|
|
||||||
request.personalities?.let { updateData["personalities"] = it }
|
|
||||||
request.backgrounds?.let { updateData["backgrounds"] = it }
|
|
||||||
request.memories?.let { updateData["memories"] = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
val httpEntity = HttpEntity(updateData, headers)
|
|
||||||
val response = restTemplate.exchange(
|
|
||||||
"$apiUrl/api/characters/$characterUUID",
|
|
||||||
HttpMethod.PUT,
|
|
||||||
httpEntity,
|
|
||||||
String::class.java
|
|
||||||
)
|
|
||||||
|
|
||||||
// 응답 파싱
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
|
||||||
|
|
||||||
// success가 false이면 throw
|
|
||||||
if (!apiResponse.success) {
|
|
||||||
throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,82 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/admin/chat/character/curation")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
class CharacterCurationAdminController(
|
|
||||||
private val service: CharacterCurationAdminService,
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
|
||||||
private val imageHost: String
|
|
||||||
) {
|
|
||||||
@GetMapping("/list")
|
|
||||||
fun listAll(): ApiResponse<List<CharacterCurationListItemResponse>> =
|
|
||||||
ApiResponse.ok(service.listAll())
|
|
||||||
|
|
||||||
@GetMapping("/{curationId}/characters")
|
|
||||||
fun listCharacters(
|
|
||||||
@PathVariable curationId: Long
|
|
||||||
): ApiResponse<List<CharacterCurationCharacterItemResponse>> {
|
|
||||||
val characters = service.listCharacters(curationId)
|
|
||||||
val items = characters.map {
|
|
||||||
CharacterCurationCharacterItemResponse(
|
|
||||||
id = it.id!!,
|
|
||||||
name = it.name,
|
|
||||||
description = it.description,
|
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ApiResponse.ok(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/register")
|
|
||||||
fun register(@RequestBody request: CharacterCurationRegisterRequest) =
|
|
||||||
ApiResponse.ok(service.register(request).id)
|
|
||||||
|
|
||||||
@PutMapping("/update")
|
|
||||||
fun update(@RequestBody request: CharacterCurationUpdateRequest) =
|
|
||||||
ApiResponse.ok(service.update(request).id)
|
|
||||||
|
|
||||||
@DeleteMapping("/{curationId}")
|
|
||||||
fun delete(@PathVariable curationId: Long) =
|
|
||||||
ApiResponse.ok(service.softDelete(curationId))
|
|
||||||
|
|
||||||
@PutMapping("/reorder")
|
|
||||||
fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) =
|
|
||||||
ApiResponse.ok(service.reorder(request.ids))
|
|
||||||
|
|
||||||
@PostMapping("/{curationId}/characters")
|
|
||||||
fun addCharacter(
|
|
||||||
@PathVariable curationId: Long,
|
|
||||||
@RequestBody request: CharacterCurationAddCharacterRequest
|
|
||||||
): ApiResponse<Boolean> {
|
|
||||||
val ids = request.characterIds.filter { it > 0 }.distinct()
|
|
||||||
if (ids.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
|
||||||
service.addCharacters(curationId, ids)
|
|
||||||
return ApiResponse.ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{curationId}/characters/{characterId}")
|
|
||||||
fun removeCharacter(
|
|
||||||
@PathVariable curationId: Long,
|
|
||||||
@PathVariable characterId: Long
|
|
||||||
) = ApiResponse.ok(service.removeCharacter(curationId, characterId))
|
|
||||||
|
|
||||||
@PutMapping("/{curationId}/characters/reorder")
|
|
||||||
fun reorderCharacters(
|
|
||||||
@PathVariable curationId: Long,
|
|
||||||
@RequestBody request: CharacterCurationReorderCharactersRequest
|
|
||||||
) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds))
|
|
||||||
}
|
|
@@ -1,45 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
|
||||||
|
|
||||||
data class CharacterCurationRegisterRequest(
|
|
||||||
val title: String,
|
|
||||||
val isAdult: Boolean = false,
|
|
||||||
val isActive: Boolean = true
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CharacterCurationUpdateRequest(
|
|
||||||
val id: Long,
|
|
||||||
val title: String? = null,
|
|
||||||
val isAdult: Boolean? = null,
|
|
||||||
val isActive: Boolean? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CharacterCurationOrderUpdateRequest(
|
|
||||||
val ids: List<Long>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CharacterCurationAddCharacterRequest(
|
|
||||||
val characterIds: List<Long>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CharacterCurationReorderCharactersRequest(
|
|
||||||
val characterIds: List<Long>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CharacterCurationListItemResponse(
|
|
||||||
val id: Long,
|
|
||||||
val title: String,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val isActive: Boolean,
|
|
||||||
val characterCount: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
// 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO
|
|
||||||
// id, name, description, 이미지 URL
|
|
||||||
// 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성
|
|
||||||
|
|
||||||
data class CharacterCurationCharacterItemResponse(
|
|
||||||
val id: Long,
|
|
||||||
val name: String,
|
|
||||||
val description: String,
|
|
||||||
val imageUrl: String
|
|
||||||
)
|
|
@@ -1,153 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class CharacterCurationAdminService(
|
|
||||||
private val curationRepository: CharacterCurationRepository,
|
|
||||||
private val mappingRepository: CharacterCurationMappingRepository,
|
|
||||||
private val characterRepository: ChatCharacterRepository
|
|
||||||
) {
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun register(request: CharacterCurationRegisterRequest): CharacterCuration {
|
|
||||||
val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1
|
|
||||||
val curation = CharacterCuration(
|
|
||||||
title = request.title,
|
|
||||||
isAdult = request.isAdult,
|
|
||||||
isActive = request.isActive,
|
|
||||||
sortOrder = sortOrder
|
|
||||||
)
|
|
||||||
return curationRepository.save(curation)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
|
|
||||||
val curation = curationRepository.findById(request.id)
|
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") }
|
|
||||||
|
|
||||||
request.title?.let { curation.title = it }
|
|
||||||
request.isAdult?.let { curation.isAdult = it }
|
|
||||||
request.isActive?.let { curation.isActive = it }
|
|
||||||
|
|
||||||
return curationRepository.save(curation)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun softDelete(curationId: Long) {
|
|
||||||
val curation = curationRepository.findById(curationId)
|
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
|
||||||
curation.isActive = false
|
|
||||||
curationRepository.save(curation)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun reorder(ids: List<Long>) {
|
|
||||||
ids.forEachIndexed { index, id ->
|
|
||||||
val curation = curationRepository.findById(id)
|
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
|
|
||||||
curation.sortOrder = index + 1
|
|
||||||
curationRepository.save(curation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun addCharacters(curationId: Long, characterIds: List<Long>) {
|
|
||||||
if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
|
||||||
|
|
||||||
val curation = curationRepository.findById(curationId)
|
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
|
||||||
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
|
|
||||||
|
|
||||||
val uniqueIds = characterIds.filter { it > 0 }.distinct()
|
|
||||||
if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다")
|
|
||||||
|
|
||||||
// 활성 캐릭터만 조회 (조회 단계에서 검증 포함)
|
|
||||||
val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds)
|
|
||||||
val characterMap = characters.associateBy { it.id!! }
|
|
||||||
|
|
||||||
// 조회 결과에 존재하는 캐릭터만 유효
|
|
||||||
val validIds = uniqueIds.filter { id -> characterMap.containsKey(id) }
|
|
||||||
|
|
||||||
val existingMappings = mappingRepository.findByCuration(curation)
|
|
||||||
val existingCharacterIds = existingMappings.mapNotNull { it.chatCharacter.id }.toSet()
|
|
||||||
var nextOrder = (existingMappings.maxOfOrNull { it.sortOrder } ?: 0) + 1
|
|
||||||
|
|
||||||
val toSave = mutableListOf<CharacterCurationMapping>()
|
|
||||||
validIds.forEach { id ->
|
|
||||||
if (!existingCharacterIds.contains(id)) {
|
|
||||||
val character = characterMap[id] ?: return@forEach
|
|
||||||
toSave += CharacterCurationMapping(
|
|
||||||
curation = curation,
|
|
||||||
chatCharacter = character,
|
|
||||||
sortOrder = nextOrder++
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toSave.isNotEmpty()) {
|
|
||||||
mappingRepository.saveAll(toSave)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun removeCharacter(curationId: Long, characterId: Long) {
|
|
||||||
val curation = curationRepository.findById(curationId)
|
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
|
||||||
val mappings = mappingRepository.findByCuration(curation)
|
|
||||||
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
|
|
||||||
?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId")
|
|
||||||
mappingRepository.delete(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
|
|
||||||
val curation = curationRepository.findById(curationId)
|
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
|
||||||
val mappings = mappingRepository.findByCuration(curation)
|
|
||||||
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
|
|
||||||
|
|
||||||
characterIds.forEachIndexed { index, cid ->
|
|
||||||
val mapping = mappingByCharacterId[cid]
|
|
||||||
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
|
|
||||||
mapping.sortOrder = index + 1
|
|
||||||
mappingRepository.save(mapping)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun listAll(): List<CharacterCurationListItemResponse> {
|
|
||||||
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
|
|
||||||
if (curations.isEmpty()) return emptyList()
|
|
||||||
|
|
||||||
// DB 집계로 활성 캐릭터 수 카운트
|
|
||||||
val counts = mappingRepository.countActiveCharactersByCurations(curations)
|
|
||||||
val countByCurationId: Map<Long, Int> = counts.associate { it.curationId to it.count.toInt() }
|
|
||||||
|
|
||||||
return curations.map { curation ->
|
|
||||||
CharacterCurationListItemResponse(
|
|
||||||
id = curation.id!!,
|
|
||||||
title = curation.title,
|
|
||||||
isAdult = curation.isAdult,
|
|
||||||
isActive = curation.isActive,
|
|
||||||
characterCount = countByCurationId[curation.id!!] ?: 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun listCharacters(curationId: Long): List<ChatCharacter> {
|
|
||||||
val curation = curationRepository.findById(curationId)
|
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
|
||||||
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
|
|
||||||
return mappings.map { it.chatCharacter }
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,132 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 캐릭터 상세 응답 DTO
|
|
||||||
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
|
|
||||||
*/
|
|
||||||
data class ChatCharacterDetailResponse(
|
|
||||||
val id: Long,
|
|
||||||
val characterUUID: String,
|
|
||||||
val name: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val description: String,
|
|
||||||
val systemPrompt: String,
|
|
||||||
val characterType: String,
|
|
||||||
val age: Int?,
|
|
||||||
val gender: String?,
|
|
||||||
val mbti: String?,
|
|
||||||
val speechPattern: String?,
|
|
||||||
val speechStyle: String?,
|
|
||||||
val appearance: String?,
|
|
||||||
val isActive: Boolean,
|
|
||||||
val tags: List<String>,
|
|
||||||
val hobbies: List<String>,
|
|
||||||
val values: List<String>,
|
|
||||||
val goals: List<String>,
|
|
||||||
val relationships: List<RelationshipResponse>,
|
|
||||||
val personalities: List<PersonalityResponse>,
|
|
||||||
val backgrounds: List<BackgroundResponse>,
|
|
||||||
val memories: List<MemoryResponse>,
|
|
||||||
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
|
||||||
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
|
||||||
"$imageHost/${chatCharacter.imagePath}"
|
|
||||||
} else {
|
|
||||||
chatCharacter.imagePath ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val ow = chatCharacter.originalWork
|
|
||||||
val originalWorkBrief = ow?.let {
|
|
||||||
val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) {
|
|
||||||
"$imageHost/${it.imagePath}"
|
|
||||||
} else {
|
|
||||||
it.imagePath
|
|
||||||
}
|
|
||||||
OriginalWorkBriefResponse(
|
|
||||||
id = it.id!!,
|
|
||||||
imageUrl = owImage,
|
|
||||||
title = it.title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChatCharacterDetailResponse(
|
|
||||||
id = chatCharacter.id!!,
|
|
||||||
characterUUID = chatCharacter.characterUUID,
|
|
||||||
name = chatCharacter.name,
|
|
||||||
imageUrl = fullImagePath,
|
|
||||||
description = chatCharacter.description,
|
|
||||||
systemPrompt = chatCharacter.systemPrompt,
|
|
||||||
characterType = chatCharacter.characterType.name,
|
|
||||||
age = chatCharacter.age,
|
|
||||||
gender = chatCharacter.gender,
|
|
||||||
mbti = chatCharacter.mbti,
|
|
||||||
speechPattern = chatCharacter.speechPattern,
|
|
||||||
speechStyle = chatCharacter.speechStyle,
|
|
||||||
appearance = chatCharacter.appearance,
|
|
||||||
isActive = chatCharacter.isActive,
|
|
||||||
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
|
||||||
hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby },
|
|
||||||
values = chatCharacter.valueMappings.map { it.value.value },
|
|
||||||
goals = chatCharacter.goalMappings.map { it.goal.goal },
|
|
||||||
relationships = chatCharacter.relationships.map {
|
|
||||||
RelationshipResponse(
|
|
||||||
personName = it.personName,
|
|
||||||
relationshipName = it.relationshipName,
|
|
||||||
description = it.description,
|
|
||||||
importance = it.importance,
|
|
||||||
relationshipType = it.relationshipType,
|
|
||||||
currentStatus = it.currentStatus
|
|
||||||
)
|
|
||||||
},
|
|
||||||
personalities = chatCharacter.personalities.map {
|
|
||||||
PersonalityResponse(it.trait, it.description)
|
|
||||||
},
|
|
||||||
backgrounds = chatCharacter.backgrounds.map {
|
|
||||||
BackgroundResponse(it.topic, it.description)
|
|
||||||
},
|
|
||||||
memories = chatCharacter.memories.map {
|
|
||||||
MemoryResponse(it.title, it.content, it.emotion)
|
|
||||||
},
|
|
||||||
originalWork = originalWorkBrief
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class PersonalityResponse(
|
|
||||||
val trait: String,
|
|
||||||
val description: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BackgroundResponse(
|
|
||||||
val topic: String,
|
|
||||||
val description: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MemoryResponse(
|
|
||||||
val title: String,
|
|
||||||
val content: String,
|
|
||||||
val emotion: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class RelationshipResponse(
|
|
||||||
val personName: String,
|
|
||||||
val relationshipName: String,
|
|
||||||
val description: String,
|
|
||||||
val importance: Int,
|
|
||||||
val relationshipType: String,
|
|
||||||
val currentStatus: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
|
|
||||||
*/
|
|
||||||
data class OriginalWorkBriefResponse(
|
|
||||||
val id: Long,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val title: String
|
|
||||||
)
|
|
@@ -1,90 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
|
|
||||||
data class ChatCharacterPersonalityRequest(
|
|
||||||
@JsonProperty("trait") val trait: String,
|
|
||||||
@JsonProperty("description") val description: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChatCharacterBackgroundRequest(
|
|
||||||
@JsonProperty("topic") val topic: String,
|
|
||||||
@JsonProperty("description") val description: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChatCharacterMemoryRequest(
|
|
||||||
@JsonProperty("title") val title: String,
|
|
||||||
@JsonProperty("content") val content: String,
|
|
||||||
@JsonProperty("emotion") val emotion: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChatCharacterRelationshipRequest(
|
|
||||||
@JsonProperty("personName") val personName: String,
|
|
||||||
@JsonProperty("relationshipName") val relationshipName: String,
|
|
||||||
@JsonProperty("description") val description: String,
|
|
||||||
@JsonProperty("importance") val importance: Int,
|
|
||||||
@JsonProperty("relationshipType") val relationshipType: String,
|
|
||||||
@JsonProperty("currentStatus") val currentStatus: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChatCharacterRegisterRequest(
|
|
||||||
@JsonProperty("name") val name: String,
|
|
||||||
@JsonProperty("systemPrompt") val systemPrompt: String,
|
|
||||||
@JsonProperty("description") val description: String,
|
|
||||||
@JsonProperty("age") val age: String?,
|
|
||||||
@JsonProperty("gender") val gender: String?,
|
|
||||||
@JsonProperty("mbti") val mbti: String?,
|
|
||||||
@JsonProperty("speechPattern") val speechPattern: String?,
|
|
||||||
@JsonProperty("speechStyle") val speechStyle: String?,
|
|
||||||
@JsonProperty("appearance") val appearance: String?,
|
|
||||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
|
||||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
|
||||||
@JsonProperty("characterType") val characterType: String? = null,
|
|
||||||
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
|
||||||
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
|
||||||
@JsonProperty("values") val values: List<String> = emptyList(),
|
|
||||||
@JsonProperty("goals") val goals: List<String> = emptyList(),
|
|
||||||
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest> = emptyList(),
|
|
||||||
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest> = emptyList(),
|
|
||||||
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest> = emptyList(),
|
|
||||||
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ExternalApiResponse(
|
|
||||||
@JsonProperty("success") val success: Boolean,
|
|
||||||
@JsonProperty("data") val data: ExternalApiData? = null,
|
|
||||||
@JsonProperty("message") val message: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class ExternalApiData(
|
|
||||||
@JsonProperty("id") val id: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChatCharacterUpdateRequest(
|
|
||||||
@JsonProperty("id") val id: Long,
|
|
||||||
@JsonProperty("name") val name: String? = null,
|
|
||||||
@JsonProperty("systemPrompt") val systemPrompt: String? = null,
|
|
||||||
@JsonProperty("description") val description: String? = null,
|
|
||||||
@JsonProperty("age") val age: String? = null,
|
|
||||||
@JsonProperty("gender") val gender: String? = null,
|
|
||||||
@JsonProperty("mbti") val mbti: String? = null,
|
|
||||||
@JsonProperty("speechPattern") val speechPattern: String? = null,
|
|
||||||
@JsonProperty("speechStyle") val speechStyle: String? = null,
|
|
||||||
@JsonProperty("appearance") val appearance: String? = null,
|
|
||||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
|
||||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
|
||||||
@JsonProperty("characterType") val characterType: String? = null,
|
|
||||||
@JsonProperty("isActive") val isActive: Boolean? = null,
|
|
||||||
@JsonProperty("tags") val tags: List<String>? = null,
|
|
||||||
@JsonProperty("hobbies") val hobbies: List<String>? = null,
|
|
||||||
@JsonProperty("values") val values: List<String>? = null,
|
|
||||||
@JsonProperty("goals") val goals: List<String>? = null,
|
|
||||||
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest>? = null,
|
|
||||||
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest>? = null,
|
|
||||||
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
|
|
||||||
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest>? = null
|
|
||||||
)
|
|
@@ -1,62 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
data class ChatCharacterListResponse(
|
|
||||||
val id: Long,
|
|
||||||
val name: String,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val description: String,
|
|
||||||
val gender: String?,
|
|
||||||
val age: Int?,
|
|
||||||
val mbti: String?,
|
|
||||||
val speechStyle: String?,
|
|
||||||
val speechPattern: String?,
|
|
||||||
val tags: List<String>,
|
|
||||||
val createdAt: String?,
|
|
||||||
val updatedAt: String?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
|
||||||
private val seoulZoneId = ZoneId.of("Asia/Seoul")
|
|
||||||
|
|
||||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterListResponse {
|
|
||||||
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
|
||||||
"$imageHost/${chatCharacter.imagePath}"
|
|
||||||
} else {
|
|
||||||
chatCharacter.imagePath
|
|
||||||
}
|
|
||||||
|
|
||||||
// UTC에서 Asia/Seoul로 시간대 변환 및 문자열 포맷팅
|
|
||||||
val createdAtStr = chatCharacter.createdAt?.atZone(ZoneId.of("UTC"))
|
|
||||||
?.withZoneSameInstant(seoulZoneId)
|
|
||||||
?.format(formatter)
|
|
||||||
|
|
||||||
val updatedAtStr = chatCharacter.updatedAt?.atZone(ZoneId.of("UTC"))
|
|
||||||
?.withZoneSameInstant(seoulZoneId)
|
|
||||||
?.format(formatter)
|
|
||||||
|
|
||||||
return ChatCharacterListResponse(
|
|
||||||
id = chatCharacter.id!!,
|
|
||||||
name = chatCharacter.name,
|
|
||||||
imageUrl = fullImagePath,
|
|
||||||
description = chatCharacter.description,
|
|
||||||
gender = chatCharacter.gender,
|
|
||||||
age = chatCharacter.age,
|
|
||||||
mbti = chatCharacter.mbti,
|
|
||||||
speechStyle = chatCharacter.speechStyle,
|
|
||||||
speechPattern = chatCharacter.speechPattern,
|
|
||||||
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
|
||||||
createdAt = createdAtStr,
|
|
||||||
updatedAt = updatedAtStr
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ChatCharacterListPageResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<ChatCharacterListResponse>
|
|
||||||
)
|
|
@@ -1,9 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 검색 결과 페이지 응답 DTO
|
|
||||||
*/
|
|
||||||
data class ChatCharacterSearchListPageResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<ChatCharacterListResponse>
|
|
||||||
)
|
|
@@ -1,30 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 연결된 캐릭터 결과 응답 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkChatCharacterResponse(
|
|
||||||
val id: Long,
|
|
||||||
val name: String,
|
|
||||||
val imagePath: String?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
|
|
||||||
return OriginalWorkChatCharacterResponse(
|
|
||||||
id = character.id!!,
|
|
||||||
name = character.name,
|
|
||||||
imagePath = character.imagePath?.let { "$imageHost/$it" }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 연결된 캐릭터 결과 페이지 응답 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkChatCharacterListPageResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<OriginalWorkChatCharacterResponse>
|
|
||||||
)
|
|
@@ -1,170 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.image
|
|
||||||
|
|
||||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest
|
|
||||||
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
|
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.utils.ImageBlurUtil
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RequestPart
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.multipart.MultipartFile
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/admin/chat/character/image")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
class AdminCharacterImageController(
|
|
||||||
private val imageService: CharacterImageService,
|
|
||||||
private val s3Uploader: S3Uploader,
|
|
||||||
private val imageCloudFront: ImageContentCloudFront,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.content-bucket}")
|
|
||||||
private val s3Bucket: String,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
|
||||||
private val freeBucket: String
|
|
||||||
) {
|
|
||||||
|
|
||||||
@GetMapping("/list")
|
|
||||||
fun list(@RequestParam characterId: Long) = run {
|
|
||||||
val expiration = 5L * 60L * 1000L // 5분
|
|
||||||
val list = imageService.listActiveByCharacter(characterId)
|
|
||||||
.map { img ->
|
|
||||||
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
|
||||||
AdminCharacterImageResponse.fromWithUrl(img, signedUrl)
|
|
||||||
}
|
|
||||||
ApiResponse.ok(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{imageId}")
|
|
||||||
fun detail(@PathVariable imageId: Long) = run {
|
|
||||||
val img = imageService.getById(imageId)
|
|
||||||
val expiration = 5L * 60L * 1000L // 5분
|
|
||||||
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
|
||||||
ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl))
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/register")
|
|
||||||
fun register(
|
|
||||||
@RequestPart("image") image: MultipartFile,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java)
|
|
||||||
|
|
||||||
// 업로드 키 생성
|
|
||||||
val s3Key = buildS3Key(characterId = request.characterId)
|
|
||||||
|
|
||||||
// 원본 저장 (content-bucket)
|
|
||||||
val imagePath = saveImageToBucket(s3Key, image, s3Bucket)
|
|
||||||
|
|
||||||
// 블러 생성 및 저장 (무료 이미지 버킷)
|
|
||||||
val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket)
|
|
||||||
|
|
||||||
imageService.registerImage(
|
|
||||||
characterId = request.characterId,
|
|
||||||
imagePath = imagePath,
|
|
||||||
blurImagePath = blurImagePath,
|
|
||||||
imagePriceCan = request.imagePriceCan,
|
|
||||||
messagePriceCan = request.messagePriceCan,
|
|
||||||
isAdult = request.isAdult,
|
|
||||||
triggers = request.triggers ?: emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/{imageId}/triggers")
|
|
||||||
fun updateTriggers(
|
|
||||||
@PathVariable imageId: Long,
|
|
||||||
@RequestBody request: UpdateCharacterImageTriggersRequest
|
|
||||||
) = run {
|
|
||||||
if (!request.triggers.isNullOrEmpty()) {
|
|
||||||
imageService.updateTriggers(imageId, request.triggers)
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{imageId}")
|
|
||||||
fun delete(@PathVariable imageId: Long) = run {
|
|
||||||
imageService.deleteImage(imageId)
|
|
||||||
ApiResponse.ok(null, "이미지가 삭제되었습니다.")
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/orders")
|
|
||||||
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
|
|
||||||
if (request.characterId == null) throw SodaException("characterId는 필수입니다")
|
|
||||||
imageService.updateOrders(request.characterId, request.ids)
|
|
||||||
ApiResponse.ok(null, "정렬 순서가 변경되었습니다.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildS3Key(characterId: Long): String {
|
|
||||||
val fileName = generateFileName("character-image")
|
|
||||||
return "characters/$characterId/images/$fileName"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
|
||||||
try {
|
|
||||||
val metadata = ObjectMetadata()
|
|
||||||
metadata.contentLength = image.size
|
|
||||||
return s3Uploader.upload(
|
|
||||||
inputStream = image.inputStream,
|
|
||||||
bucket = bucket,
|
|
||||||
filePath = filePath,
|
|
||||||
metadata = metadata
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
|
||||||
try {
|
|
||||||
// 멀티파트를 BufferedImage로 읽기
|
|
||||||
val bytes = image.bytes
|
|
||||||
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
|
||||||
?: throw SodaException("이미지 포맷을 인식할 수 없습니다.")
|
|
||||||
val blurred = ImageBlurUtil.blurFast(bimg)
|
|
||||||
|
|
||||||
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
|
||||||
val baos = java.io.ByteArrayOutputStream()
|
|
||||||
val format = when (image.contentType?.lowercase()) {
|
|
||||||
"image/png" -> "png"
|
|
||||||
else -> "jpg"
|
|
||||||
}
|
|
||||||
javax.imageio.ImageIO.write(blurred, format, baos)
|
|
||||||
val inputStream = java.io.ByteArrayInputStream(baos.toByteArray())
|
|
||||||
|
|
||||||
val metadata = ObjectMetadata()
|
|
||||||
metadata.contentLength = baos.size().toLong()
|
|
||||||
metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg"
|
|
||||||
|
|
||||||
return s3Uploader.upload(
|
|
||||||
inputStream = inputStream,
|
|
||||||
bucket = bucket,
|
|
||||||
filePath = filePath,
|
|
||||||
metadata = metadata
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,53 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.image.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
|
||||||
|
|
||||||
// 요청 DTOs
|
|
||||||
|
|
||||||
data class RegisterCharacterImageRequest(
|
|
||||||
@JsonProperty("characterId") val characterId: Long,
|
|
||||||
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
|
|
||||||
@JsonProperty("messagePriceCan") val messagePriceCan: Long,
|
|
||||||
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
|
||||||
@JsonProperty("triggers") val triggers: List<String>? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UpdateCharacterImageTriggersRequest(
|
|
||||||
@JsonProperty("triggers") val triggers: List<String>? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UpdateCharacterImageOrdersRequest(
|
|
||||||
@JsonProperty("characterId") val characterId: Long?,
|
|
||||||
@JsonProperty("ids") val ids: List<Long>
|
|
||||||
)
|
|
||||||
|
|
||||||
// 응답 DTOs
|
|
||||||
|
|
||||||
data class AdminCharacterImageResponse(
|
|
||||||
val id: Long,
|
|
||||||
val characterId: Long,
|
|
||||||
val imagePriceCan: Long,
|
|
||||||
val messagePriceCan: Long,
|
|
||||||
val imageUrl: String,
|
|
||||||
val triggers: List<String>,
|
|
||||||
val isAdult: Boolean
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse {
|
|
||||||
return base(entity, signedUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse {
|
|
||||||
return AdminCharacterImageResponse(
|
|
||||||
id = entity.id!!,
|
|
||||||
characterId = entity.chatCharacter.id!!,
|
|
||||||
imagePriceCan = entity.imagePriceCan,
|
|
||||||
messagePriceCan = entity.messagePriceCan,
|
|
||||||
imageUrl = url,
|
|
||||||
triggers = entity.triggerMappings.map { it.tag.word },
|
|
||||||
isAdult = entity.isAdult
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,78 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.service
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import org.springframework.data.domain.Page
|
|
||||||
import org.springframework.data.domain.PageRequest
|
|
||||||
import org.springframework.data.domain.Pageable
|
|
||||||
import org.springframework.data.domain.Sort
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class AdminChatCharacterService(
|
|
||||||
private val chatCharacterRepository: ChatCharacterRepository
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* 활성화된 캐릭터 목록을 페이징하여 조회
|
|
||||||
*
|
|
||||||
* @param pageable 페이징 정보
|
|
||||||
* @return 페이징된 캐릭터 목록
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse {
|
|
||||||
// isActive가 true인 캐릭터만 조회
|
|
||||||
val page = chatCharacterRepository.findByIsActiveTrue(pageable)
|
|
||||||
|
|
||||||
// 페이지 정보 생성
|
|
||||||
val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) }
|
|
||||||
|
|
||||||
return ChatCharacterListPageResponse(
|
|
||||||
totalCount = page.totalElements,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기본 페이지 요청 생성
|
|
||||||
*
|
|
||||||
* @param page 페이지 번호 (0부터 시작)
|
|
||||||
* @param size 페이지 크기
|
|
||||||
* @return 페이지 요청 객체
|
|
||||||
*/
|
|
||||||
fun createDefaultPageRequest(page: Int = 0, size: Int = 20): PageRequest {
|
|
||||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 상세 정보 조회
|
|
||||||
*
|
|
||||||
* @param characterId 캐릭터 ID
|
|
||||||
* @param imageHost 이미지 호스트 URL
|
|
||||||
* @return 캐릭터 상세 정보
|
|
||||||
* @throws SodaException 캐릭터를 찾을 수 없는 경우
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse {
|
|
||||||
val chatCharacter = chatCharacterRepository.findById(characterId)
|
|
||||||
.orElseThrow { SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") }
|
|
||||||
|
|
||||||
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun searchCharacters(
|
|
||||||
searchTerm: String,
|
|
||||||
pageable: Pageable,
|
|
||||||
imageHost: String = ""
|
|
||||||
): Page<ChatCharacterListResponse> {
|
|
||||||
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
|
|
||||||
return characters.map { ChatCharacterListResponse.from(it, imageHost) }
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,30 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 배너 등록 요청 DTO
|
|
||||||
*/
|
|
||||||
data class ChatCharacterBannerRegisterRequest(
|
|
||||||
// 캐릭터 ID
|
|
||||||
@JsonProperty("characterId") val characterId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 배너 수정 요청 DTO
|
|
||||||
*/
|
|
||||||
data class ChatCharacterBannerUpdateRequest(
|
|
||||||
// 배너 ID
|
|
||||||
@JsonProperty("bannerId") val bannerId: Long,
|
|
||||||
|
|
||||||
// 캐릭터 ID (변경할 캐릭터)
|
|
||||||
@JsonProperty("characterId") val characterId: Long? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 배너 정렬 순서 일괄 변경 요청 DTO
|
|
||||||
*/
|
|
||||||
data class UpdateBannerOrdersRequest(
|
|
||||||
// 배너 ID 목록 (순서대로 정렬됨)
|
|
||||||
@JsonProperty("ids") val ids: List<Long>
|
|
||||||
)
|
|
@@ -1,32 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.dto
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 배너 응답 DTO
|
|
||||||
*/
|
|
||||||
data class ChatCharacterBannerResponse(
|
|
||||||
val id: Long,
|
|
||||||
val imagePath: String,
|
|
||||||
val characterId: Long,
|
|
||||||
val characterName: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse {
|
|
||||||
return ChatCharacterBannerResponse(
|
|
||||||
id = banner.id!!,
|
|
||||||
imagePath = "$imageHost/${banner.imagePath}",
|
|
||||||
characterId = banner.chatCharacter.id!!,
|
|
||||||
characterName = banner.chatCharacter.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 배너 목록 페이지 응답 DTO
|
|
||||||
*/
|
|
||||||
data class ChatCharacterBannerListPageResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<ChatCharacterBannerResponse>
|
|
||||||
)
|
|
@@ -1,199 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.original
|
|
||||||
|
|
||||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterListPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RequestPart
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.multipart.MultipartFile
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작(오리지널 작품) 관리자 API
|
|
||||||
* - 원작 등록/수정/삭제
|
|
||||||
* - 원작과 캐릭터 연결(배정) 및 해제
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/admin/chat/original")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
class AdminOriginalWorkController(
|
|
||||||
private val originalWorkService: AdminOriginalWorkService,
|
|
||||||
private val s3Uploader: S3Uploader,
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
|
||||||
private val s3Bucket: String,
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
|
||||||
private val imageHost: String
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 등록
|
|
||||||
* - 이미지 파일과 JSON 요청을 멀티파트로 받는다.
|
|
||||||
*/
|
|
||||||
@PostMapping("/register")
|
|
||||||
fun register(
|
|
||||||
@RequestPart("image") image: MultipartFile,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java)
|
|
||||||
|
|
||||||
// 서비스 계층을 통해 원작을 생성
|
|
||||||
val saved = originalWorkService.createOriginalWork(request)
|
|
||||||
|
|
||||||
// 이미지 업로드 후 이미지 경로 업데이트
|
|
||||||
val imagePath = uploadImage(saved.id!!, image)
|
|
||||||
originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath)
|
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 수정
|
|
||||||
* - 이미지가 있으면 교체, 없으면 유지
|
|
||||||
*/
|
|
||||||
@PutMapping("/update")
|
|
||||||
fun update(
|
|
||||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java)
|
|
||||||
|
|
||||||
// 이미지가 전달된 경우 먼저 업로드하여 경로를 생성
|
|
||||||
val imagePath = if (image != null && !image.isEmpty) {
|
|
||||||
uploadImage(request.id, image)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
originalWorkService.updateOriginalWork(request, imagePath)
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 삭제
|
|
||||||
*/
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
fun delete(@PathVariable id: Long) = run {
|
|
||||||
originalWorkService.deleteOriginalWork(id)
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 목록(페이징)
|
|
||||||
*/
|
|
||||||
@GetMapping("/list")
|
|
||||||
fun list(
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageRes = originalWorkService.getOriginalWorkPage(page, size)
|
|
||||||
val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) }
|
|
||||||
ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 검색(관리자)
|
|
||||||
* - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외
|
|
||||||
* - 페이징 제거: 전체 목록 반환
|
|
||||||
*/
|
|
||||||
@GetMapping("/search")
|
|
||||||
fun search(
|
|
||||||
@RequestParam("searchTerm") searchTerm: String
|
|
||||||
) = run {
|
|
||||||
val list = originalWorkService.searchOriginalWorksAll(searchTerm)
|
|
||||||
val content = list.map { OriginalWorkResponse.from(it, imageHost) }
|
|
||||||
ApiResponse.ok(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 상세
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
fun detail(@PathVariable id: Long) = run {
|
|
||||||
ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작에 기존 캐릭터들을 배정
|
|
||||||
* - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 이 원작으로 설정
|
|
||||||
*/
|
|
||||||
@PostMapping("/{id}/assign-characters")
|
|
||||||
fun assignCharacters(
|
|
||||||
@PathVariable id: Long,
|
|
||||||
@RequestBody body: OriginalWorkAssignCharactersRequest
|
|
||||||
) = run {
|
|
||||||
originalWorkService.assignCharacters(id, body.characterIds)
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작에서 캐릭터들 해제
|
|
||||||
* - 캐릭터들의 originalWork를 null로 설정
|
|
||||||
*/
|
|
||||||
@PostMapping("/{id}/unassign-characters")
|
|
||||||
fun unassignCharacters(
|
|
||||||
@PathVariable id: Long,
|
|
||||||
@RequestBody body: OriginalWorkAssignCharactersRequest
|
|
||||||
) = run {
|
|
||||||
originalWorkService.unassignCharacters(id, body.characterIds)
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회
|
|
||||||
* - 활성 캐릭터만 포함
|
|
||||||
* - 응답 항목: 캐릭터 이미지(URL), 이름
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}/characters")
|
|
||||||
fun listCharactersOfOriginal(
|
|
||||||
@PathVariable id: Long,
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
|
|
||||||
val content = pageRes.content.map { OriginalWorkChatCharacterResponse.from(it, imageHost) }
|
|
||||||
ApiResponse.ok(
|
|
||||||
OriginalWorkChatCharacterListPageResponse(
|
|
||||||
totalCount = pageRes.totalElements,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 이미지 업로드 공통 처리 */
|
|
||||||
private fun uploadImage(originalWorkId: Long, image: MultipartFile): String {
|
|
||||||
try {
|
|
||||||
val metadata = ObjectMetadata()
|
|
||||||
metadata.contentLength = image.size
|
|
||||||
return s3Uploader.upload(
|
|
||||||
inputStream = image.inputStream,
|
|
||||||
bucket = s3Bucket,
|
|
||||||
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
|
|
||||||
metadata = metadata
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,95 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.original.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 등록 요청 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkRegisterRequest(
|
|
||||||
@JsonProperty("title") val title: String,
|
|
||||||
@JsonProperty("contentType") val contentType: String,
|
|
||||||
@JsonProperty("category") val category: String,
|
|
||||||
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
|
||||||
@JsonProperty("description") val description: String = "",
|
|
||||||
@JsonProperty("originalWork") val originalWork: String? = null,
|
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
|
||||||
@JsonProperty("writer") val writer: String? = null,
|
|
||||||
@JsonProperty("studio") val studio: String? = null,
|
|
||||||
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
|
||||||
@JsonProperty("tags") val tags: List<String>? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 수정 요청 DTO (부분 수정 가능)
|
|
||||||
*/
|
|
||||||
data class OriginalWorkUpdateRequest(
|
|
||||||
@JsonProperty("id") val id: Long,
|
|
||||||
@JsonProperty("title") val title: String? = null,
|
|
||||||
@JsonProperty("contentType") val contentType: String? = null,
|
|
||||||
@JsonProperty("category") val category: String? = null,
|
|
||||||
@JsonProperty("isAdult") val isAdult: Boolean? = null,
|
|
||||||
@JsonProperty("description") val description: String? = null,
|
|
||||||
@JsonProperty("originalWork") val originalWork: String? = null,
|
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
|
||||||
@JsonProperty("writer") val writer: String? = null,
|
|
||||||
@JsonProperty("studio") val studio: String? = null,
|
|
||||||
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
|
||||||
@JsonProperty("tags") val tags: List<String>? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 상세/목록 응답 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkResponse(
|
|
||||||
val id: Long,
|
|
||||||
val title: String,
|
|
||||||
val contentType: String,
|
|
||||||
val category: String,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val description: String,
|
|
||||||
val originalWork: String?,
|
|
||||||
val originalLink: String?,
|
|
||||||
val writer: String?,
|
|
||||||
val studio: String?,
|
|
||||||
val originalLinks: List<String>,
|
|
||||||
val tags: List<String>,
|
|
||||||
val imageUrl: String?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkResponse {
|
|
||||||
val fullImagePath = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
|
||||||
"$imageHost/${entity.imagePath}"
|
|
||||||
} else {
|
|
||||||
entity.imagePath
|
|
||||||
}
|
|
||||||
return OriginalWorkResponse(
|
|
||||||
id = entity.id!!,
|
|
||||||
title = entity.title,
|
|
||||||
contentType = entity.contentType,
|
|
||||||
category = entity.category,
|
|
||||||
isAdult = entity.isAdult,
|
|
||||||
description = entity.description,
|
|
||||||
originalWork = entity.originalWork,
|
|
||||||
originalLink = entity.originalLink,
|
|
||||||
writer = entity.writer,
|
|
||||||
studio = entity.studio,
|
|
||||||
originalLinks = entity.originalLinks.map { it.url },
|
|
||||||
tags = entity.tagMappings.map { it.tag.tag },
|
|
||||||
imageUrl = fullImagePath
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class OriginalWorkPageResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<OriginalWorkResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작-캐릭터 연결/해제 요청 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkAssignCharactersRequest(
|
|
||||||
@JsonProperty("characterIds") val characterIds: List<Long>
|
|
||||||
)
|
|
@@ -1,213 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.original.service
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import org.springframework.data.domain.Page
|
|
||||||
import org.springframework.data.domain.PageRequest
|
|
||||||
import org.springframework.data.domain.Sort
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작(오리지널 작품) 관련 관리자 서비스
|
|
||||||
* - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
class AdminOriginalWorkService(
|
|
||||||
private val originalWorkRepository: OriginalWorkRepository,
|
|
||||||
private val chatCharacterRepository: ChatCharacterRepository,
|
|
||||||
private val originalWorkTagRepository: OriginalWorkTagRepository
|
|
||||||
) {
|
|
||||||
|
|
||||||
/** 원작 등록 (중복 제목 방지 포함) */
|
|
||||||
@Transactional
|
|
||||||
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
|
|
||||||
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
|
|
||||||
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
|
|
||||||
}
|
|
||||||
val entity = OriginalWork(
|
|
||||||
title = request.title,
|
|
||||||
contentType = request.contentType,
|
|
||||||
category = request.category,
|
|
||||||
isAdult = request.isAdult,
|
|
||||||
description = request.description,
|
|
||||||
originalWork = request.originalWork,
|
|
||||||
originalLink = request.originalLink,
|
|
||||||
writer = request.writer,
|
|
||||||
studio = request.studio
|
|
||||||
)
|
|
||||||
// 링크 리스트 생성
|
|
||||||
request.originalLinks?.filter { it.isNotBlank() }?.forEach { link ->
|
|
||||||
entity.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = entity))
|
|
||||||
}
|
|
||||||
// 태그 매핑 생성 (기존 태그 재사용)
|
|
||||||
request.tags?.let { tags ->
|
|
||||||
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
|
||||||
normalized.forEach { t ->
|
|
||||||
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
|
||||||
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return originalWorkRepository.save(entity)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
|
||||||
@Transactional
|
|
||||||
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
|
|
||||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
|
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
|
||||||
|
|
||||||
request.title?.let { ow.title = it }
|
|
||||||
request.contentType?.let { ow.contentType = it }
|
|
||||||
request.category?.let { ow.category = it }
|
|
||||||
request.isAdult?.let { ow.isAdult = it }
|
|
||||||
request.description?.let { ow.description = it }
|
|
||||||
request.originalWork?.let { ow.originalWork = it }
|
|
||||||
request.originalLink?.let { ow.originalLink = it }
|
|
||||||
request.writer?.let { ow.writer = it }
|
|
||||||
request.studio?.let { ow.studio = it }
|
|
||||||
// 링크 리스트가 전달되면 기존 것을 교체
|
|
||||||
request.originalLinks?.let { links ->
|
|
||||||
ow.originalLinks.clear()
|
|
||||||
links.filter { it.isNotBlank() }.forEach { link ->
|
|
||||||
ow.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = ow))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 태그 변경사항만 반영 (요청이 null이면 변경 없음)
|
|
||||||
request.tags?.let { tags ->
|
|
||||||
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
|
||||||
val current = ow.tagMappings.map { it.tag.tag }.toSet()
|
|
||||||
val toAdd = normalized.minus(current)
|
|
||||||
val toRemove = current.minus(normalized)
|
|
||||||
|
|
||||||
if (toRemove.isNotEmpty()) {
|
|
||||||
val itr = ow.tagMappings.iterator()
|
|
||||||
while (itr.hasNext()) {
|
|
||||||
val m = itr.next()
|
|
||||||
if (toRemove.contains(m.tag.tag)) {
|
|
||||||
itr.remove() // orphanRemoval=true로 매핑 삭제
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (toAdd.isNotEmpty()) {
|
|
||||||
toAdd.forEach { t ->
|
|
||||||
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
|
||||||
ow.tagMappings.add(OriginalWorkTagMapping(originalWork = ow, tag = tagEntity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (imagePath != null) {
|
|
||||||
ow.imagePath = imagePath
|
|
||||||
}
|
|
||||||
return originalWorkRepository.save(ow)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 원작 이미지 경로만 별도 갱신 */
|
|
||||||
@Transactional
|
|
||||||
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
|
|
||||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
|
||||||
ow.imagePath = imagePath
|
|
||||||
return originalWorkRepository.save(ow)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 원작 삭제 (소프트 삭제) */
|
|
||||||
@Transactional
|
|
||||||
fun deleteOriginalWork(id: Long) {
|
|
||||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
|
|
||||||
ow.isDeleted = true
|
|
||||||
originalWorkRepository.save(ow)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 원작 상세 조회 (소프트 삭제 제외) */
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getOriginalWork(id: Long): OriginalWork {
|
|
||||||
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 원작 페이징 조회 */
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> {
|
|
||||||
val safePage = if (page < 0) 0 else page
|
|
||||||
val safeSize = when {
|
|
||||||
size <= 0 -> 20
|
|
||||||
size > 100 -> 100
|
|
||||||
else -> size
|
|
||||||
}
|
|
||||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
|
||||||
return originalWorkRepository.findByIsDeletedFalse(pageable)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
|
|
||||||
// 원작 존재 및 소프트 삭제 여부 확인
|
|
||||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
|
||||||
|
|
||||||
val safePage = if (page < 0) 0 else page
|
|
||||||
val safeSize = when {
|
|
||||||
size <= 0 -> 20
|
|
||||||
size > 100 -> 100
|
|
||||||
else -> size
|
|
||||||
}
|
|
||||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
|
||||||
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
|
|
||||||
return originalWorkRepository.searchNoPaging(searchTerm)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 원작에 기존 캐릭터들을 배정 */
|
|
||||||
@Transactional
|
|
||||||
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
|
||||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
|
||||||
if (characterIds.isEmpty()) return
|
|
||||||
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
|
||||||
characters.forEach { it.originalWork = ow }
|
|
||||||
chatCharacterRepository.saveAll(characters)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 원작에서 캐릭터들 해제 */
|
|
||||||
@Transactional
|
|
||||||
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
|
||||||
// 원작 존재 확인 (소프트 삭제 제외)
|
|
||||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
|
||||||
if (characterIds.isEmpty()) return
|
|
||||||
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
|
||||||
characters.forEach { it.originalWork = null }
|
|
||||||
chatCharacterRepository.saveAll(characters)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 단일 캐릭터를 지정 원작에 배정 */
|
|
||||||
@Transactional
|
|
||||||
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
|
|
||||||
val character = chatCharacterRepository.findById(characterId)
|
|
||||||
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
|
|
||||||
|
|
||||||
if (originalWorkId == 0L) {
|
|
||||||
character.originalWork = null
|
|
||||||
} else {
|
|
||||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
|
||||||
character.originalWork = ow
|
|
||||||
}
|
|
||||||
|
|
||||||
chatCharacterRepository.save(character)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -140,7 +140,6 @@ 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,7 +38,6 @@ 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 (event.link != link) {
|
if (!link.isNullOrBlank() && event.link != link) {
|
||||||
event.link = if (link.isNullOrBlank()) null else link
|
event.link = link
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!title.isNullOrBlank() && event.title != title) {
|
if (!title.isNullOrBlank() && event.title != title) {
|
||||||
|
@@ -2,7 +2,6 @@ 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
|
||||||
@@ -99,13 +98,6 @@ 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"))
|
||||||
@@ -130,7 +122,6 @@ 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,7 +11,6 @@ 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,
|
||||||
|
@@ -1,45 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
@@ -1,8 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.point
|
|
||||||
|
|
||||||
data class ModifyPointRewardPolicyRequest(
|
|
||||||
val title: String?,
|
|
||||||
val startDate: String?,
|
|
||||||
val endDate: String?,
|
|
||||||
val isActive: Boolean?
|
|
||||||
)
|
|
@@ -1,36 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
@@ -1,59 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,55 +0,0 @@
|
|||||||
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,6 +3,7 @@ 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
|
||||||
@@ -45,12 +46,6 @@ 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)
|
||||||
@@ -66,7 +61,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(0.toBigDecimal())
|
.otherwise(Expressions.constant(0.0))
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
val repeatPaymentCount = CaseBuilder()
|
val repeatPaymentCount = CaseBuilder()
|
||||||
@@ -78,7 +73,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(0.toBigDecimal())
|
.otherwise(Expressions.constant(0.0))
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
val allPaymentCount = CaseBuilder()
|
val allPaymentCount = CaseBuilder()
|
||||||
@@ -96,7 +91,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(0.toBigDecimal())
|
.otherwise(Expressions.constant(0.0))
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
@@ -106,15 +101,14 @@ 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,
|
||||||
firstPaymentTotalAmount,
|
roundedValueDecimalPlaces2(firstPaymentTotalAmount),
|
||||||
repeatPaymentCount,
|
repeatPaymentCount,
|
||||||
repeatPaymentTotalAmount,
|
roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
|
||||||
allPaymentCount,
|
allPaymentCount,
|
||||||
allPaymentTotalAmount
|
roundedValueDecimalPlaces2(allPaymentTotalAmount)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(adTrackingHistory)
|
.from(adTrackingHistory)
|
||||||
@@ -147,4 +141,13 @@ 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,7 +1,6 @@
|
|||||||
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,
|
||||||
@@ -13,13 +12,12 @@ 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: BigDecimal,
|
val firstPaymentTotalAmount: Double,
|
||||||
val repeatPaymentCount: Int,
|
val repeatPaymentCount: Int,
|
||||||
val repeatPaymentTotalAmount: BigDecimal,
|
val repeatPaymentTotalAmount: Double,
|
||||||
val allPaymentCount: Int,
|
val allPaymentCount: Int,
|
||||||
val allPaymentTotalAmount: BigDecimal
|
val allPaymentTotalAmount: Double
|
||||||
)
|
)
|
||||||
|
@@ -8,10 +8,8 @@ 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
|
||||||
|
|
||||||
@@ -29,57 +27,6 @@ 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)
|
||||||
@@ -132,81 +79,6 @@ 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,19 +46,6 @@ 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)
|
||||||
|
|
||||||
@@ -77,26 +64,6 @@ 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
|
||||||
@@ -116,11 +83,7 @@ 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
|
||||||
)
|
)
|
||||||
@@ -129,11 +92,7 @@ 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,11 +2,7 @@ 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>
|
||||||
@@ -14,11 +10,7 @@ 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
|
||||||
)
|
)
|
||||||
|
@@ -1,28 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
@@ -1,66 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,265 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,33 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,94 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,18 +0,0 @@
|
|||||||
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,7 +12,6 @@ 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(
|
||||||
@@ -95,27 +94,4 @@ 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,8 +28,4 @@ class AuditionService(
|
|||||||
roleList = roleList
|
roleList = roleList
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
|
|
||||||
return repository.getInProgressAuditionList(isAdult)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -49,12 +49,10 @@ 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
|
||||||
@@ -89,8 +87,7 @@ 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)
|
||||||
|
@@ -1,48 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.aws.cloudfront
|
|
||||||
|
|
||||||
import com.amazonaws.services.cloudfront.CloudFrontUrlSigner
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.nio.file.Paths
|
|
||||||
import java.security.KeyFactory
|
|
||||||
import java.security.PrivateKey
|
|
||||||
import java.security.spec.PKCS8EncodedKeySpec
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지(CloudFront) 서명 URL 생성기
|
|
||||||
* - cloud.aws.cloud-front.* 설정을 사용
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
class ImageContentCloudFront(
|
|
||||||
@Value("\${cloud.aws.content-cloud-front.host}")
|
|
||||||
private val cloudfrontDomain: String,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.content-cloud-front.private-key-file-path}")
|
|
||||||
private val privateKeyFilePath: String,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.content-cloud-front.key-pair-id}")
|
|
||||||
private val keyPairId: String
|
|
||||||
) {
|
|
||||||
fun generateSignedURL(
|
|
||||||
resourcePath: String,
|
|
||||||
expirationTimeMillis: Long
|
|
||||||
): String {
|
|
||||||
val privateKey = loadPrivateKey(privateKeyFilePath)
|
|
||||||
return CloudFrontUrlSigner.getSignedURLWithCannedPolicy(
|
|
||||||
"$cloudfrontDomain/$resourcePath",
|
|
||||||
keyPairId,
|
|
||||||
privateKey,
|
|
||||||
Date(System.currentTimeMillis() + expirationTimeMillis)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadPrivateKey(resourceName: String): PrivateKey {
|
|
||||||
val path = Paths.get(resourceName)
|
|
||||||
val bytes = Files.readAllBytes(path)
|
|
||||||
val keySpec = PKCS8EncodedKeySpec(bytes)
|
|
||||||
val keyFactory = KeyFactory.getInstance("RSA")
|
|
||||||
return keyFactory.generatePrivate(keySpec)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,8 +1,6 @@
|
|||||||
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
|
||||||
@@ -12,10 +10,7 @@ data class Can(
|
|||||||
var title: String,
|
var title: String,
|
||||||
var can: Int,
|
var can: Int,
|
||||||
var rewardCan: Int,
|
var rewardCan: Int,
|
||||||
@Column(precision = 10, scale = 4, nullable = false)
|
var price: Int,
|
||||||
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,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -10,15 +9,13 @@ 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(request: HttpServletRequest): ApiResponse<List<CanResponse>> {
|
fun getCans(): ApiResponse<List<CanResponse>> {
|
||||||
val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER
|
return ApiResponse.ok(service.getCans())
|
||||||
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 findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse>
|
fun findAllByStatus(status: CanStatus): 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 findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> {
|
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QCanResponse(
|
QCanResponse(
|
||||||
@@ -40,16 +40,11 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
|
|||||||
can1.title,
|
can1.title,
|
||||||
can1.can,
|
can1.can,
|
||||||
can1.rewardCan,
|
can1.rewardCan,
|
||||||
can1.price.intValue(),
|
can1.price
|
||||||
can1.currency,
|
|
||||||
can1.price.stringValue()
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(can1)
|
.from(can1)
|
||||||
.where(
|
.where(can1.status.eq(status))
|
||||||
can1.status.eq(status),
|
|
||||||
can1.currency.eq(currency)
|
|
||||||
)
|
|
||||||
.orderBy(can1.can.asc())
|
.orderBy(can1.can.asc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
@@ -69,13 +64,11 @@ 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,7 +7,5 @@ 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,7 +3,6 @@ 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
|
||||||
@@ -12,12 +11,8 @@ import java.time.format.DateTimeFormatter
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class CanService(private val repository: CanRepository) {
|
class CanService(private val repository: CanRepository) {
|
||||||
fun getCans(geoCountry: GeoCountry): List<CanResponse> {
|
fun getCans(): List<CanResponse> {
|
||||||
val currency = when (geoCountry) {
|
return repository.findAllByStatus(status = CanStatus.SALE)
|
||||||
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 {
|
||||||
@@ -77,10 +72,6 @@ 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,9 +1,7 @@
|
|||||||
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: BigDecimal,
|
val price: Double,
|
||||||
val currencyCode: String,
|
val currencyCode: String,
|
||||||
val isFirstCharged: Boolean
|
val isFirstCharged: Boolean
|
||||||
)
|
)
|
||||||
|
@@ -6,77 +6,20 @@ 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,
|
||||||
@@ -168,7 +111,8 @@ 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,7 +2,6 @@ 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)
|
||||||
|
|
||||||
@@ -21,14 +20,14 @@ data class VerifyResult(
|
|||||||
val method: String,
|
val method: String,
|
||||||
val pg: String,
|
val pg: String,
|
||||||
val status: Int,
|
val status: Int,
|
||||||
val price: BigDecimal
|
val price: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
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: BigDecimal? = null,
|
var price: Double? = null,
|
||||||
var locale: String? = null
|
var locale: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,53 +38,9 @@ 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: BigDecimal,
|
val price: Double,
|
||||||
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,7 +5,6 @@ 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
|
||||||
@@ -13,16 +12,10 @@ 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
|
||||||
@@ -34,8 +27,6 @@ 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)
|
||||||
@@ -45,9 +36,6 @@ 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,
|
||||||
@@ -65,341 +53,34 @@ 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 payverseWebhook(request: PayverseWebhookRequest): Boolean {
|
fun chargeByCoupon(couponNumber: String, member: Member) {
|
||||||
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
|
||||||
|
|
||||||
when (coupon.couponType) {
|
val payment = Payment(
|
||||||
CouponType.CAN -> {
|
status = PaymentStatus.COMPLETE,
|
||||||
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
|
paymentGateway = PaymentGateway.PG
|
||||||
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()
|
|
||||||
)
|
)
|
||||||
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
payment.method = coupon.couponName
|
||||||
val sign = DigestUtils.sha512Hex(
|
couponCharge.payment = payment
|
||||||
String.format(
|
chargeRepository.save(couponCharge)
|
||||||
"||%s||%s||%s||%s||%s||",
|
|
||||||
secretKey,
|
|
||||||
mid,
|
|
||||||
chargeId,
|
|
||||||
amount,
|
|
||||||
reqDate
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val customerId = "${serverEnv}_user_${member.id!!}"
|
|
||||||
|
|
||||||
val payload = linkedMapOf(
|
member.charge(0, coupon.can, "pg")
|
||||||
"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
|
||||||
@@ -413,7 +94,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
|
payment.price = can.price.toDouble()
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -452,14 +133,14 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = charge.payment!!.price,
|
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||||
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 (_: Exception) {
|
} catch (e: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -484,7 +165,7 @@ class ChargeService(
|
|||||||
VerifyResult::class.java
|
VerifyResult::class.java
|
||||||
)
|
)
|
||||||
|
|
||||||
if (verifyResult.status == 1) {
|
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
|
||||||
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}"
|
||||||
@@ -502,14 +183,14 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = charge.payment!!.price,
|
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||||
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 (_: Exception) {
|
} catch (e: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -527,7 +208,7 @@ class ChargeService(
|
|||||||
payment.price = if (request.price != null) {
|
payment.price = if (request.price != null) {
|
||||||
request.price!!
|
request.price!!
|
||||||
} else {
|
} else {
|
||||||
0.toBigDecimal()
|
0.toDouble()
|
||||||
}
|
}
|
||||||
|
|
||||||
payment.locale = request.locale
|
payment.locale = request.locale
|
||||||
@@ -562,7 +243,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = charge.payment!!.price,
|
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -579,7 +260,7 @@ class ChargeService(
|
|||||||
member: Member,
|
member: Member,
|
||||||
title: String,
|
title: String,
|
||||||
chargeCan: Int,
|
chargeCan: Int,
|
||||||
price: BigDecimal,
|
price: Double,
|
||||||
currencyCode: String,
|
currencyCode: String,
|
||||||
productId: String,
|
productId: String,
|
||||||
purchaseToken: String,
|
purchaseToken: String,
|
||||||
@@ -607,7 +288,8 @@ 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("결제정보에 오류가 있습니다.")
|
||||||
@@ -629,7 +311,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = charge.payment!!.price,
|
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -711,13 +393,4 @@ 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,10 +1,9 @@
|
|||||||
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: BigDecimal,
|
val price: Int,
|
||||||
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
|
payment.price = request.price.toDouble()
|
||||||
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) {
|
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) {
|
||||||
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 (_: Exception) {
|
} catch (e: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@@ -3,21 +3,13 @@ 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("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
val completeMessage = service.useCanCoupon(
|
ApiResponse.ok(
|
||||||
couponNumber = request.couponNumber,
|
service.useCanCoupon(
|
||||||
memberId = member.id!!
|
couponNumber = request.couponNumber,
|
||||||
|
memberId = member.id!!
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
ApiResponse.ok(Unit, completeMessage)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -79,7 +79,6 @@ 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,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -31,9 +30,6 @@ 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,12 +68,15 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -121,7 +124,7 @@ class CanCouponService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun useCanCoupon(couponNumber: String, memberId: Long): String {
|
fun useCanCoupon(couponNumber: String, memberId: Long) {
|
||||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
?: throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@ class CanCouponService(
|
|||||||
|
|
||||||
issueService.validateAvailableUseCoupon(couponNumber, memberId)
|
issueService.validateAvailableUseCoupon(couponNumber, memberId)
|
||||||
|
|
||||||
return chargeService.chargeByCoupon(couponNumber, member)
|
chargeService.chargeByCoupon(couponNumber, member)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun insertHyphens(input: String): String {
|
private fun insertHyphens(input: String): String {
|
||||||
|
@@ -4,7 +4,6 @@ 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,7 +10,6 @@ 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,7 +13,6 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
|||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
import kr.co.vividnext.sodalive.can.use.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
|
||||||
@@ -38,8 +37,6 @@ 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,
|
||||||
@@ -112,14 +109,6 @@ 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("잘못된 요청입니다.")
|
||||||
}
|
}
|
||||||
@@ -338,98 +327,4 @@ 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,7 +2,6 @@ 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
|
||||||
@@ -26,8 +25,7 @@ data class Payment(
|
|||||||
var receiptId: String? = null
|
var receiptId: String? = null
|
||||||
var method: String? = null
|
var method: String? = null
|
||||||
|
|
||||||
@Column(precision = 10, scale = 4, nullable = false)
|
var price: Double = 0.toDouble()
|
||||||
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, PAYVERSE, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
|
PG, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
|
||||||
}
|
}
|
||||||
|
@@ -9,9 +9,5 @@ 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,8 +1,6 @@
|
|||||||
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
|
||||||
@@ -30,11 +28,7 @@ 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)
|
||||||
@@ -64,16 +58,6 @@ 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,56 +6,23 @@ 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 isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean
|
fun isExistOrdered(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 isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean {
|
override fun isExistOrdered(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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,163 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.CascadeType
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.EnumType
|
|
||||||
import javax.persistence.Enumerated
|
|
||||||
import javax.persistence.FetchType
|
|
||||||
import javax.persistence.JoinColumn
|
|
||||||
import javax.persistence.ManyToOne
|
|
||||||
import javax.persistence.OneToMany
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
class ChatCharacter(
|
|
||||||
val characterUUID: String,
|
|
||||||
|
|
||||||
// 캐릭터 이름 (API 키 내에서 유일해야 함)
|
|
||||||
var name: String,
|
|
||||||
|
|
||||||
// 캐릭터 한 줄 소개
|
|
||||||
var description: String,
|
|
||||||
|
|
||||||
// AI 시스템 프롬프트
|
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
|
||||||
var systemPrompt: String,
|
|
||||||
|
|
||||||
// 나이
|
|
||||||
var age: Int? = null,
|
|
||||||
|
|
||||||
// 성별
|
|
||||||
var gender: String? = null,
|
|
||||||
|
|
||||||
// mbti
|
|
||||||
var mbti: String? = null,
|
|
||||||
|
|
||||||
// 말투 패턴 설명
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
var speechPattern: String? = null,
|
|
||||||
|
|
||||||
// 대화 스타일
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
var speechStyle: String? = null,
|
|
||||||
|
|
||||||
// 외모 설명
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
var appearance: String? = null,
|
|
||||||
|
|
||||||
// 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
|
||||||
@Column(nullable = true)
|
|
||||||
var originalTitle: String? = null,
|
|
||||||
|
|
||||||
// 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
|
||||||
@Column(nullable = true)
|
|
||||||
var originalLink: String? = null,
|
|
||||||
|
|
||||||
// 연관 원작 (한 캐릭터는 하나의 원작에만 속함)
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "original_work_id")
|
|
||||||
var originalWork: OriginalWork? = null,
|
|
||||||
|
|
||||||
// 캐릭터 유형
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false)
|
|
||||||
var characterType: CharacterType = CharacterType.Character,
|
|
||||||
|
|
||||||
var isActive: Boolean = true
|
|
||||||
) : BaseEntity() {
|
|
||||||
var imagePath: String? = null
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
|
||||||
var memories: MutableList<ChatCharacterMemory> = mutableListOf()
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
|
||||||
var personalities: MutableList<ChatCharacterPersonality> = mutableListOf()
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
|
||||||
var backgrounds: MutableList<ChatCharacterBackground> = mutableListOf()
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
|
||||||
var relationships: MutableList<ChatCharacterRelationship> = mutableListOf()
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
|
||||||
var tagMappings: MutableList<ChatCharacterTagMapping> = mutableListOf()
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
|
||||||
var valueMappings: MutableList<ChatCharacterValueMapping> = mutableListOf()
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
|
||||||
var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf()
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
|
||||||
var goalMappings: MutableList<ChatCharacterGoalMapping> = mutableListOf()
|
|
||||||
|
|
||||||
// 태그 추가 헬퍼 메소드
|
|
||||||
fun addTag(tag: ChatCharacterTag) {
|
|
||||||
val mapping = ChatCharacterTagMapping(this, tag)
|
|
||||||
tagMappings.add(mapping)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 가치관 추가 헬퍼 메소드
|
|
||||||
fun addValue(value: ChatCharacterValue) {
|
|
||||||
val mapping = ChatCharacterValueMapping(this, value)
|
|
||||||
valueMappings.add(mapping)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 취미 추가 헬퍼 메소드
|
|
||||||
fun addHobby(hobby: ChatCharacterHobby) {
|
|
||||||
val mapping = ChatCharacterHobbyMapping(this, hobby)
|
|
||||||
hobbyMappings.add(mapping)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 목표 추가 헬퍼 메소드
|
|
||||||
fun addGoal(goal: ChatCharacterGoal) {
|
|
||||||
val mapping = ChatCharacterGoalMapping(this, goal)
|
|
||||||
goalMappings.add(mapping)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기억 추가 헬퍼 메소드
|
|
||||||
fun addMemory(title: String, content: String, emotion: String) {
|
|
||||||
val memory = ChatCharacterMemory(title, content, emotion, this)
|
|
||||||
memories.add(memory)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 성격 추가 헬퍼 메소드
|
|
||||||
fun addPersonality(trait: String, description: String) {
|
|
||||||
val personality = ChatCharacterPersonality(trait, description, this)
|
|
||||||
personalities.add(personality)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 배경 추가 헬퍼 메소드
|
|
||||||
fun addBackground(topic: String, description: String) {
|
|
||||||
val background = ChatCharacterBackground(topic, description, this)
|
|
||||||
backgrounds.add(background)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 관계 추가 헬퍼 메소드
|
|
||||||
fun addRelationship(
|
|
||||||
personName: String,
|
|
||||||
relationshipName: String,
|
|
||||||
description: String,
|
|
||||||
importance: Int,
|
|
||||||
relationshipType: String,
|
|
||||||
currentStatus: String
|
|
||||||
) {
|
|
||||||
val relationship = ChatCharacterRelationship(
|
|
||||||
personName,
|
|
||||||
relationshipName,
|
|
||||||
description,
|
|
||||||
importance,
|
|
||||||
relationshipType,
|
|
||||||
currentStatus,
|
|
||||||
this
|
|
||||||
)
|
|
||||||
relationships.add(relationship)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class CharacterType {
|
|
||||||
Clone,
|
|
||||||
Character
|
|
||||||
}
|
|
@@ -1,26 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.FetchType
|
|
||||||
import javax.persistence.JoinColumn
|
|
||||||
import javax.persistence.ManyToOne
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 배경 정보
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
class ChatCharacterBackground(
|
|
||||||
// 배경 주제
|
|
||||||
val topic: String,
|
|
||||||
|
|
||||||
// 배경 설명
|
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
|
||||||
var description: String,
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "chat_character_id")
|
|
||||||
val chatCharacter: ChatCharacter
|
|
||||||
) : BaseEntity()
|
|
@@ -1,29 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.FetchType
|
|
||||||
import javax.persistence.JoinColumn
|
|
||||||
import javax.persistence.ManyToOne
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 배너 엔티티
|
|
||||||
* 이미지와 캐릭터 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다.
|
|
||||||
* 정렬 순서(sortOrder)를 통해 배너의 표시 순서를 결정합니다.
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
class ChatCharacterBanner(
|
|
||||||
// 배너 이미지 경로
|
|
||||||
var imagePath: String? = null,
|
|
||||||
|
|
||||||
// 연관된 캐릭터
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "character_id")
|
|
||||||
var chatCharacter: ChatCharacter,
|
|
||||||
|
|
||||||
// 정렬 순서 (낮을수록 먼저 표시)
|
|
||||||
var sortOrder: Int = 0,
|
|
||||||
|
|
||||||
// 활성화 여부 (소프트 삭제용)
|
|
||||||
var isActive: Boolean = true
|
|
||||||
) : BaseEntity()
|
|
@@ -1,22 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.OneToMany
|
|
||||||
import javax.persistence.Table
|
|
||||||
import javax.persistence.UniqueConstraint
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 목표
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["goal"])])
|
|
||||||
class ChatCharacterGoal(
|
|
||||||
@Column(nullable = false)
|
|
||||||
val goal: String
|
|
||||||
) : BaseEntity() {
|
|
||||||
@OneToMany(mappedBy = "goal")
|
|
||||||
var goalMappings: MutableList<ChatCharacterGoalMapping> = mutableListOf()
|
|
||||||
}
|
|
@@ -1,22 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.FetchType
|
|
||||||
import javax.persistence.JoinColumn
|
|
||||||
import javax.persistence.ManyToOne
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ChatCharacter와 ChatCharacterGoal 간의 매핑 엔티티
|
|
||||||
* ChatCharacterGoal의 중복을 방지하기 위한 매핑 테이블
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
class ChatCharacterGoalMapping(
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "chat_character_id")
|
|
||||||
val chatCharacter: ChatCharacter,
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "goal_id")
|
|
||||||
val goal: ChatCharacterGoal
|
|
||||||
) : BaseEntity()
|
|
@@ -1,22 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.OneToMany
|
|
||||||
import javax.persistence.Table
|
|
||||||
import javax.persistence.UniqueConstraint
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 취미
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["hobby"])])
|
|
||||||
class ChatCharacterHobby(
|
|
||||||
@Column(nullable = false)
|
|
||||||
val hobby: String
|
|
||||||
) : BaseEntity() {
|
|
||||||
@OneToMany(mappedBy = "hobby")
|
|
||||||
var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf()
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user