Compare commits
267 Commits
test
...
367faac5c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 367faac5c3 | |||
| 84deaaa970 | |||
| a2b39466c2 | |||
| 03586c4005 | |||
| 6ea69e1510 | |||
| 553c6dc539 | |||
| 6cc22f5b6d | |||
| 9103d67cc1 | |||
| 25083fb0e4 | |||
| d2dc045255 | |||
| b8621dfbb0 | |||
| 93633940dd | |||
| b6f5325351 | |||
| 7c32c08f1f | |||
| 1d268da08d | |||
| 797666ae0d | |||
| dcf470997e | |||
| 0974d1dbf8 | |||
| 12a35db6cd | |||
| 9abbb05ad8 | |||
| 1ecaf69b0b | |||
| e334d1e5d9 | |||
| b735e861d0 | |||
| 4eb433d372 | |||
| 2416ae61f3 | |||
| 01fb336985 | |||
| b6af88a732 | |||
| 58a2a17d6d | |||
| 79f5a0f520 | |||
| 7f6c0f7f04 | |||
| f658df4dca | |||
| 9d43b8e23a | |||
| 4270aef79b | |||
| 1c0dc82d44 | |||
| c1e325aadf | |||
| cec87da69d | |||
| f68f24cb2c | |||
| ed094347fc | |||
| b8afdffbe1 | |||
| f6ba79f31c | |||
| 5f3b1663d2 | |||
| 66e786b4bb | |||
| f671114574 | |||
| ce37060d94 | |||
| 7d19a4d184 | |||
| 22f28a2f8a | |||
| ceef9ca979 | |||
| efe8f4f939 | |||
| ba692a1195 | |||
| d732bad042 | |||
| 4c935c3bee | |||
| c160dd791f | |||
| 23cd1b4601 | |||
| 031fc8ba1b | |||
| c6853289ad | |||
| 2497bb69bc | |||
| a58a67e0a2 | |||
| 4315fe12a5 | |||
| 42f10a8899 | |||
| 1e4b47f989 | |||
| ff255dbfae | |||
| dbe9b72feb | |||
| 95a714b391 | |||
| 28f58c7f56 | |||
| 8bd46d8f21 | |||
| e1bb8e54ed | |||
| 1de705b063 | |||
| f6926ad356 | |||
| 2cdbbb1b37 | |||
| 4dce8c8f03 | |||
| 97a5bace6f | |||
| d4d51ec48f | |||
| fb91398462 | |||
| 105dadd798 | |||
| 2abf2837d3 | |||
| 422aa67af6 | |||
| 7fffab6985 | |||
| 5a4be3d2c1 | |||
| f39a7681db | |||
| c60a7580ba | |||
| 97edb56edc | |||
| 6ebca8d22b | |||
| 95371ad934 | |||
| 2c176825fd | |||
| fae7de48d3 | |||
| b8230646a2 | |||
| 43279541dd | |||
| b4791977c1 | |||
| ef917ecc25 | |||
| a93faad951 | |||
| fd001d24d3 | |||
| 7aa5884797 | |||
| 5b237a1547 | |||
| 2e37990d87 | |||
| dd07d724a8 | |||
| 03ce8618e7 | |||
| db1a7a7fd6 | |||
| 36a82d7f53 | |||
| 3a34401113 | |||
| 9927268330 | |||
| c45c97e29d | |||
| c64a315226 | |||
| a4cafca6ab | |||
| 46284a0660 | |||
| 05df86e15a | |||
| 8b433027e2 | |||
| 5bd4ff7610 | |||
| d693c397ea | |||
| 1d8d1ec9a5 | |||
| 5e491f11ee | |||
| 7cedea06ac | |||
| 2e5f750e50 | |||
| 20289cad10 | |||
| e0d64c31c7 | |||
| 8c1b95dc97 | |||
| fb5641343e | |||
| 87765941eb | |||
| 1809862c16 | |||
| 300f784f7d | |||
| 67a045eae6 | |||
| 2a79903a28 | |||
| d3222ce083 | |||
| 406a421742 | |||
| 10bf728faf | |||
| 607617747c | |||
| f0a69eb1a2 | |||
| 6b307a6e17 | |||
| 08d08a934a | |||
| c500c12668 | |||
| 62060adeba | |||
| b2fc75edb8 | |||
| a999dd2085 | |||
| 49f95ab100 | |||
| 1a84d5b30c | |||
| 3b65050632 | |||
| d0df31674c | |||
| 1fe88402e2 | |||
| 67097696e6 | |||
| 8e7e77067a | |||
| 9899390b61 | |||
| 80c476a908 | |||
| 59da1d6e49 | |||
| 5aef7dac33 | |||
| faf7aa06b6 | |||
| 38ef6e5583 | |||
| c0b15b5d94 | |||
| 2cfc067ea1 | |||
| a91db4f956 | |||
| 8a09780a02 | |||
| 45e8ec6505 | |||
| 4554b85914 | |||
| 8aa79c4a9c | |||
| c8d3210b57 | |||
| 2282a49563 | |||
| b82fdfb2c8 | |||
| 2d17eac199 | |||
| e482bc3aad | |||
| ec022b74d1 | |||
| dc42c09ce3 | |||
| 046a34d2a4 | |||
| 9ff6ec1888 | |||
| d2950106ec | |||
| 962f800d2e | |||
| 962107e507 | |||
| 039bd11963 | |||
| 5c250ea4ae | |||
| e3405bcec6 | |||
| 0fd1c2235f | |||
| b20c29b022 | |||
| 12d5dcd298 | |||
| 2c305dc6c6 | |||
| 62f76f7433 | |||
| 858ce524f9 | |||
| 3795fb4a40 | |||
| 0c01aeec50 | |||
| 892206744d | |||
| 9e2c1474db | |||
| 16328f73d9 | |||
| e0d4f53cf4 | |||
| e09a59c5b4 | |||
| 049e654535 | |||
| c927dc4ecd | |||
| fe4ecd0ad8 | |||
| 78d476fe80 | |||
| a11c8465d5 | |||
| 366304a9b7 | |||
| 4356663688 | |||
| 26b55e6fcf | |||
| 0d743f7204 | |||
| 6cbe113b3e | |||
| 6409b69d6c | |||
| c5164c76fc | |||
| baade8e138 | |||
| b848d6b4e0 | |||
| d8139d2ab0 | |||
| e96d8f7469 | |||
| 2acffd8afc | |||
| 3c8e72073c | |||
| 724d7a9d9b | |||
| 2da3b0db78 | |||
| 685ad7afaf | |||
| 264cf75964 | |||
| c773dbc7b5 | |||
| 37cbc64f52 | |||
| cb1dde17bb | |||
| c29988acf4 | |||
| eadbf56dae | |||
| 4b3b455135 | |||
| e6ac177396 | |||
| 3d0e29003f | |||
| 78b9b00f77 | |||
| 0ee7faa551 | |||
| e5fdced681 | |||
| afb99fef64 | |||
| 7dfaa36024 | |||
| 0496f665aa | |||
| 0d19e1be74 | |||
| 4aff0111aa | |||
| 63b3ba2bb2 | |||
| 7444b41f60 | |||
| 8e90dbc8b6 | |||
| 9f70722521 | |||
| 52fae596fa | |||
| ccb67957bc | |||
| fb82538d0d | |||
| 72ee39612e | |||
| 51fd5408dc | |||
| 3fae40fbef | |||
| 0745890af0 | |||
| 4abe1730a7 | |||
| 626f0e6989 | |||
| 9f42d9d173 | |||
| f90a93c4bc | |||
| 8000ad6c6a | |||
| 1f1f1bea1a | |||
| d95460c7cd | |||
| a3d93d4b08 | |||
| 07a92af982 | |||
| f4618877d4 | |||
| 2b914fd222 | |||
| 109e42a5a3 | |||
| fa515ad39c | |||
| f09673a795 | |||
| f71536c614 | |||
| 7bdddc7ae8 | |||
| aa8926a624 | |||
| be71e59be2 | |||
| 4d7753378f | |||
| 60257c4ef4 | |||
| 1e0b79bf62 | |||
| 6883434d0d | |||
| eda2193e64 | |||
| 99bf829c88 | |||
| 5feafe1b48 | |||
| c9292b7d04 | |||
| ae7e1a91c1 | |||
| 3e1887e0d1 | |||
| 474646db47 | |||
| 56f7b6c449 | |||
| 76b2b5f7e3 | |||
| e918d809eb | |||
| 7af059e543 | |||
| 897726e1ec | |||
| 8b98a2dd07 | |||
| cca75420f0 | |||
| 86c627ed1d | |||
| d55514e3a7 |
@@ -7,5 +7,5 @@ indent_size = 4
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 130
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -323,7 +323,4 @@ gradle-app.setting
|
||||
### Gradle Patch ###
|
||||
**/build/
|
||||
|
||||
.kiro/
|
||||
.junie
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
||||
|
||||
@@ -65,13 +65,7 @@ dependencies {
|
||||
// android publisher
|
||||
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.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")
|
||||
runtimeOnly("com.h2database:h2")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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.Expressions
|
||||
import com.querydsl.core.types.dsl.StringTemplate
|
||||
@@ -39,10 +38,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -55,10 +51,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
|
||||
fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> {
|
||||
val orderFormattedDate = getFormattedDate(order.createdAt)
|
||||
val pointGroup = CaseBuilder()
|
||||
.`when`(order.point.loe(0)).then(0)
|
||||
.otherwise(1)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCalculateContentQueryData(
|
||||
@@ -70,7 +62,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
order.can,
|
||||
order.id.count(),
|
||||
order.can.sum(),
|
||||
order.point.sum(),
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
)
|
||||
@@ -78,10 +69,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -92,7 +80,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
order.type,
|
||||
orderFormattedDate,
|
||||
order.can,
|
||||
pointGroup,
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
.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> {
|
||||
val pointGroup = CaseBuilder()
|
||||
.`when`(order.point.loe(0)).then(0)
|
||||
.otherwise(1)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCumulativeSalesByContentQueryData(
|
||||
@@ -140,7 +123,6 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
order.can,
|
||||
order.id.count(),
|
||||
order.can.sum(),
|
||||
order.point.sum(),
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
)
|
||||
@@ -148,19 +130,9 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(order.isActive.isTrue)
|
||||
.groupBy(
|
||||
member.id,
|
||||
audioContent.id,
|
||||
order.type,
|
||||
order.can,
|
||||
pointGroup,
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
.groupBy(member.id, audioContent.id, order.type, order.can)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.orderBy(member.id.desc(), audioContent.id.desc())
|
||||
@@ -239,10 +211,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
@@ -263,10 +232,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -296,10 +262,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -319,10 +282,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -352,10 +312,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -375,10 +332,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
@@ -409,10 +363,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
|
||||
@@ -22,15 +22,11 @@ data class GetCalculateContentQueryData @QueryProjection constructor(
|
||||
val numberOfPeople: Long,
|
||||
// 합계
|
||||
val totalCan: Int,
|
||||
// 포인트
|
||||
val totalPoint: Int,
|
||||
// 정산비율
|
||||
val settlementRatio: Int?
|
||||
) {
|
||||
fun toGetCalculateContentResponse(): GetCalculateContentResponse {
|
||||
val orderTypeStr = if (totalPoint > 0) {
|
||||
"포인트"
|
||||
} else if (orderType == OrderType.RENTAL) {
|
||||
val orderTypeStr = if (orderType == OrderType.RENTAL) {
|
||||
"대여"
|
||||
} else {
|
||||
"소장"
|
||||
|
||||
@@ -21,15 +21,11 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor(
|
||||
val numberOfPeople: Long,
|
||||
// 합계
|
||||
val totalCan: Int,
|
||||
// 포인트
|
||||
val totalPoint: Int,
|
||||
// 정산비율
|
||||
val settlementRatio: Int?
|
||||
) {
|
||||
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
|
||||
val orderTypeStr = if (totalPoint > 0) {
|
||||
"포인트"
|
||||
} else if (orderType == OrderType.RENTAL) {
|
||||
val orderTypeStr = if (orderType == OrderType.RENTAL) {
|
||||
"대여"
|
||||
} 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.member.Member
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
|
||||
|
||||
@Entity
|
||||
data class CreatorSettlementRatio(
|
||||
var subsidy: Int,
|
||||
var liveSettlementRatio: Int,
|
||||
var contentSettlementRatio: Int,
|
||||
var communitySettlementRatio: Int
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
val contentSettlementRatio: Int,
|
||||
val communitySettlementRatio: Int
|
||||
) : BaseEntity() {
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
var member: Member? = null
|
||||
|
||||
var deletedAt: LocalDateTime? = null
|
||||
|
||||
fun softDelete() {
|
||||
this.deletedAt = LocalDateTime.now()
|
||||
}
|
||||
|
||||
fun restore() {
|
||||
this.deletedAt = null
|
||||
}
|
||||
|
||||
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
|
||||
this.subsidy = subsidy
|
||||
this.liveSettlementRatio = live
|
||||
this.contentSettlementRatio = content
|
||||
this.communitySettlementRatio = community
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
@@ -28,14 +27,4 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
|
||||
@PostMapping("/update")
|
||||
fun updateCreatorSettlementRatio(
|
||||
@RequestBody request: CreateCreatorSettlementRatioRequest
|
||||
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
|
||||
|
||||
@PostMapping("/delete/{memberId}")
|
||||
fun deleteCreatorSettlementRatio(
|
||||
@PathVariable memberId: Long
|
||||
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CreatorSettlementRatioRepository :
|
||||
JpaRepository<CreatorSettlementRatio, Long>,
|
||||
CreatorSettlementRatioQueryRepository {
|
||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
||||
}
|
||||
CreatorSettlementRatioQueryRepository
|
||||
|
||||
interface CreatorSettlementRatioQueryRepository {
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCreatorSettlementRatioItem(
|
||||
member.id,
|
||||
member.nickname,
|
||||
creatorSettlementRatio.subsidy,
|
||||
creatorSettlementRatio.liveSettlementRatio,
|
||||
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
)
|
||||
.from(creatorSettlementRatio)
|
||||
.innerJoin(creatorSettlementRatio.member, member)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.orderBy(creatorSettlementRatio.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(creatorSettlementRatio.id)
|
||||
.from(creatorSettlementRatio)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class CreatorSettlementRatioService(
|
||||
) {
|
||||
@Transactional
|
||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||
val creatorSettlementRatio = request.toEntity()
|
||||
|
||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||
|
||||
@@ -21,52 +23,10 @@ class CreatorSettlementRatioService(
|
||||
throw SodaException("잘못된 크리에이터 입니다.")
|
||||
}
|
||||
|
||||
val existing = repository.findByMemberId(request.memberId)
|
||||
if (existing != null) {
|
||||
// revive if soft-deleted, then update values
|
||||
existing.restore()
|
||||
existing.updateValues(
|
||||
request.subsidy,
|
||||
request.liveSettlementRatio,
|
||||
request.contentSettlementRatio,
|
||||
request.communitySettlementRatio
|
||||
)
|
||||
repository.save(existing)
|
||||
return
|
||||
}
|
||||
|
||||
val creatorSettlementRatio = request.toEntity()
|
||||
creatorSettlementRatio.member = creator
|
||||
repository.save(creatorSettlementRatio)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||
if (creator.role != MemberRole.CREATOR) {
|
||||
throw SodaException("잘못된 크리에이터 입니다.")
|
||||
}
|
||||
val existing = repository.findByMemberId(request.memberId)
|
||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
||||
existing.restore()
|
||||
existing.updateValues(
|
||||
request.subsidy,
|
||||
request.liveSettlementRatio,
|
||||
request.contentSettlementRatio,
|
||||
request.communitySettlementRatio
|
||||
)
|
||||
repository.save(existing)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteCreatorSettlementRatio(memberId: Long) {
|
||||
val existing = repository.findByMemberId(memberId)
|
||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
||||
existing.softDelete()
|
||||
repository.save(existing)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||
|
||||
@@ -8,7 +8,6 @@ data class GetCreatorSettlementRatioResponse(
|
||||
)
|
||||
|
||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
data class AdminCanChargeRequest(
|
||||
val memberIds: List<Long>,
|
||||
val memberId: Long,
|
||||
val method: String,
|
||||
val can: Int
|
||||
)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
import kr.co.vividnext.sodalive.can.CanResponse
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
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.RequestBody
|
||||
@@ -15,11 +13,6 @@ import org.springframework.web.bind.annotation.RestController
|
||||
@RequestMapping("/admin/can")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminCanController(private val service: AdminCanService) {
|
||||
@GetMapping
|
||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
||||
return ApiResponse.ok(service.getCans())
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
||||
|
||||
|
||||
@@ -1,38 +1,6 @@
|
||||
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.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.stereotype.Repository
|
||||
|
||||
interface AdminCanRepository : JpaRepository<Can, Long>, AdminCanQueryRepository
|
||||
|
||||
interface AdminCanQueryRepository {
|
||||
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
||||
}
|
||||
|
||||
@Repository
|
||||
class AdminCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminCanQueryRepository {
|
||||
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QCanResponse(
|
||||
can1.id,
|
||||
can1.title,
|
||||
can1.can,
|
||||
can1.rewardCan,
|
||||
can1.price.intValue(),
|
||||
can1.currency,
|
||||
can1.price.stringValue()
|
||||
)
|
||||
)
|
||||
.from(can1)
|
||||
.where(can1.status.eq(status))
|
||||
.orderBy(can1.currency.asc(), can1.price.asc())
|
||||
.fetch()
|
||||
}
|
||||
}
|
||||
interface AdminCanRepository : JpaRepository<Can, Long>
|
||||
|
||||
@@ -3,13 +3,11 @@ package kr.co.vividnext.sodalive.admin.can
|
||||
import kr.co.vividnext.sodalive.can.Can
|
||||
import kr.co.vividnext.sodalive.can.CanStatus
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class AdminCanRequest(
|
||||
val can: Int,
|
||||
val rewardCan: Int,
|
||||
val price: BigDecimal,
|
||||
val currency: String
|
||||
val price: Int
|
||||
) {
|
||||
fun toEntity(): Can {
|
||||
var title = "${can.moneyFormat()} 캔"
|
||||
@@ -22,7 +20,6 @@ data class AdminCanRequest(
|
||||
can = can,
|
||||
rewardCan = rewardCan,
|
||||
price = price,
|
||||
currency = currency,
|
||||
status = CanStatus.SALE
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
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.charge.Charge
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||
@@ -21,10 +20,6 @@ class AdminCanService(
|
||||
private val chargeRepository: ChargeRepository,
|
||||
private val memberRepository: AdminMemberRepository
|
||||
) {
|
||||
fun getCans(): List<CanResponse> {
|
||||
return repository.findAllByStatus(status = CanStatus.SALE)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun saveCan(request: AdminCanRequest) {
|
||||
repository.save(request.toEntity())
|
||||
@@ -40,27 +35,22 @@ class AdminCanService(
|
||||
|
||||
@Transactional
|
||||
fun charge(request: AdminCanChargeRequest) {
|
||||
val member = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 회원번호 입니다.")
|
||||
|
||||
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
|
||||
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
|
||||
|
||||
val ids = request.memberIds.distinct()
|
||||
if (ids.isEmpty()) throw SodaException("회원번호를 입력하세요.")
|
||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||
charge.title = "${request.can.moneyFormat()} 캔"
|
||||
charge.member = member
|
||||
|
||||
val members = memberRepository.findAllById(ids).toList()
|
||||
if (members.size != ids.size) throw SodaException("잘못된 회원번호 입니다.")
|
||||
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
payment.method = request.method
|
||||
charge.payment = payment
|
||||
|
||||
members.forEach { member ->
|
||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||
charge.title = "${request.can.moneyFormat()} 캔"
|
||||
charge.member = member
|
||||
chargeRepository.save(charge)
|
||||
|
||||
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
payment.method = request.method
|
||||
charge.payment = payment
|
||||
|
||||
chargeRepository.save(charge)
|
||||
|
||||
member.pgRewardCan += charge.rewardCan
|
||||
}
|
||||
member.pgRewardCan += charge.rewardCan
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
|
||||
@GetMapping("/detail")
|
||||
fun getChargeStatusDetail(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam paymentGateway: PaymentGateway,
|
||||
@RequestParam(value = "currency", required = false) currency: String? = null
|
||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
|
||||
@RequestParam paymentGateway: PaymentGateway
|
||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.BooleanBuilder
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||
@@ -15,7 +14,7 @@ import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
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(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
Expressions.dateTimeTemplate(
|
||||
@@ -27,16 +26,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
),
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetChargeStatusResponse(
|
||||
QGetChargeStatusQueryDto(
|
||||
formattedDate,
|
||||
payment.price.sum(),
|
||||
can1.price.sum(),
|
||||
payment.id.count(),
|
||||
payment.paymentGateway.stringValue(),
|
||||
currency.coalesce("KRW")
|
||||
payment.paymentGateway
|
||||
)
|
||||
)
|
||||
.from(payment)
|
||||
@@ -48,46 +46,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
)
|
||||
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
|
||||
.groupBy(formattedDate, payment.paymentGateway)
|
||||
.orderBy(formattedDate.desc())
|
||||
.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(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
paymentGateway: PaymentGateway,
|
||||
currency: String? = null
|
||||
paymentGateway: PaymentGateway
|
||||
): List<GetChargeStatusDetailQueryDto> {
|
||||
val formattedDate = Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
@@ -100,20 +67,6 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
),
|
||||
"%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
|
||||
.select(
|
||||
@@ -122,7 +75,8 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
member.nickname,
|
||||
payment.method.coalesce(""),
|
||||
payment.price,
|
||||
currencyExpr,
|
||||
can1.price,
|
||||
payment.locale.coalesce(""),
|
||||
formattedDate
|
||||
)
|
||||
)
|
||||
@@ -130,7 +84,13 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
.innerJoin(charge.member, member)
|
||||
.innerJoin(charge.payment, payment)
|
||||
.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())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -20,17 +20,48 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
|
||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
|
||||
chargeStatusList.addAll(0, summaryRows)
|
||||
var totalChargeAmount = 0
|
||||
var totalChargeCount = 0L
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun getChargeStatusDetail(
|
||||
startDateStr: String,
|
||||
paymentGateway: PaymentGateway,
|
||||
currency: String? = null
|
||||
paymentGateway: PaymentGateway
|
||||
): List<GetChargeStatusDetailResponse> {
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||
@@ -43,16 +74,18 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
|
||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
|
||||
.asSequence()
|
||||
.map {
|
||||
GetChargeStatusDetailResponse(
|
||||
memberId = it.memberId,
|
||||
nickname = it.nickname,
|
||||
method = it.method,
|
||||
amount = it.amount,
|
||||
amount = it.appleChargeAmount.toInt(),
|
||||
locale = it.locale,
|
||||
datetime = it.datetime
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val method: String,
|
||||
val amount: BigDecimal,
|
||||
val appleChargeAmount: Double,
|
||||
val pgChargeAmount: Int,
|
||||
val locale: String,
|
||||
val datetime: String
|
||||
)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusDetailResponse(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val method: String,
|
||||
val amount: BigDecimal,
|
||||
val amount: Int,
|
||||
val locale: 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
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusResponse @QueryProjection constructor(
|
||||
data class GetChargeStatusResponse(
|
||||
val date: String,
|
||||
val chargeAmount: BigDecimal,
|
||||
val chargeAmount: Int,
|
||||
val chargeCount: Long,
|
||||
val pg: String,
|
||||
val currency: String
|
||||
val pg: 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,448 +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.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
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,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@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!!)
|
||||
}
|
||||
|
||||
// 5. 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
// 언어 감지에 사용할 내용은 chatCharacter.description 만 사용한다.
|
||||
if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = chatCharacter.id!!,
|
||||
query = chatCharacter.description,
|
||||
targetType = LanguageDetectTargetType.CHARACTER
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = request.id,
|
||||
targetType = LanguageTranslationTargetType.CHARACTER
|
||||
)
|
||||
)
|
||||
|
||||
// 원작 연결: 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
|
||||
.and(audioContent.member.isNotNull)
|
||||
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
|
||||
.and(audioContentHashTag.isActive.isTrue)
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -66,10 +66,7 @@ class AdminHashTagCurationQueryRepositoryImpl(
|
||||
.and(audioContent.member.isNotNull)
|
||||
.and(audioContent.isActive.isTrue)
|
||||
.and(audioContent.title.contains(searchWord))
|
||||
.and(
|
||||
contentHashTagCurationItem.id.isNull
|
||||
.or(contentHashTagCurationItem.isActive.isFalse)
|
||||
)
|
||||
.and(contentHashTagCurationItem.id.isNull)
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.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.RestController
|
||||
@@ -21,9 +19,4 @@ class AdminContentSeriesController(private val service: AdminContentSeriesServic
|
||||
fun searchSeriesList(
|
||||
@RequestParam(value = "search_word") searchWord: String
|
||||
) = ApiResponse.ok(service.searchSeriesList(searchWord))
|
||||
|
||||
@PutMapping
|
||||
fun modifySeries(
|
||||
@RequestBody request: AdminModifySeriesRequest
|
||||
) = ApiResponse.ok(service.modifySeries(request), "시리즈가 수정되었습니다.")
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AdminContentSeriesService(
|
||||
private val repository: AdminContentSeriesRepository,
|
||||
private val genreRepository: AdminContentSeriesGenreRepository
|
||||
) {
|
||||
class AdminContentSeriesService(private val repository: AdminContentSeriesRepository) {
|
||||
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
|
||||
val totalCount = repository.getSeriesTotalCount()
|
||||
val items = repository.getSeriesList(
|
||||
@@ -19,53 +12,10 @@ class AdminContentSeriesService(
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
|
||||
if (items.isNotEmpty()) {
|
||||
val ids = items.map { it.id }
|
||||
val seriesList = repository.findAllById(ids)
|
||||
val seriesMap = seriesList.associateBy { it.id }
|
||||
|
||||
items.forEach { item ->
|
||||
val s = seriesMap[item.id]
|
||||
if (s != null) {
|
||||
item.publishedDaysOfWeek = s.publishedDaysOfWeek.toList().sortedBy { it.ordinal }
|
||||
item.isOriginal = s.isOriginal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GetAdminSeriesListResponse(totalCount, items)
|
||||
}
|
||||
|
||||
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
|
||||
return repository.searchSeriesList(searchWord)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun modifySeries(request: AdminModifySeriesRequest) {
|
||||
val series = repository.findByIdAndActiveTrue(request.seriesId)
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (request.publishedDaysOfWeek != null) {
|
||||
val days = request.publishedDaysOfWeek
|
||||
if (days.contains(SeriesPublishedDaysOfWeek.RANDOM) && days.size > 1) {
|
||||
throw SodaException("랜덤과 연재요일 동시에 선택할 수 없습니다.")
|
||||
}
|
||||
series.publishedDaysOfWeek.clear()
|
||||
series.publishedDaysOfWeek.addAll(days)
|
||||
}
|
||||
|
||||
if (request.genreId != null) {
|
||||
val genre = genreRepository.findActiveSeriesGenreById(request.genreId)
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
series.genre = genre
|
||||
}
|
||||
|
||||
if (request.isOriginal != null) {
|
||||
series.isOriginal = request.isOriginal
|
||||
}
|
||||
|
||||
if (request.isAdult != null) {
|
||||
series.isAdult = request.isAdult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series
|
||||
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
|
||||
data class AdminModifySeriesRequest(
|
||||
val seriesId: Long,
|
||||
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>?,
|
||||
val genreId: Long?,
|
||||
val isOriginal: Boolean?,
|
||||
val isAdult: Boolean?
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
|
||||
data class GetAdminSeriesListResponse(
|
||||
val totalCount: Int,
|
||||
@@ -18,10 +17,7 @@ data class GetAdminSeriesListItem @QueryProjection constructor(
|
||||
val numberOfWorks: Long,
|
||||
val state: String,
|
||||
val isAdult: Boolean
|
||||
) {
|
||||
var publishedDaysOfWeek: List<SeriesPublishedDaysOfWeek> = emptyList()
|
||||
var isOriginal: Boolean = false
|
||||
}
|
||||
)
|
||||
|
||||
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
|
||||
val id: Long,
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series.banner
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.content.banner.UpdateBannerOrdersRequest
|
||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
|
||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpdateRequest
|
||||
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.content.series.main.banner.ContentSeriesBannerService
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.PageRequest
|
||||
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/audio-content/series/banner")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminContentSeriesBannerController(
|
||||
private val bannerService: ContentSeriesBannerService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val s3Bucket: String,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
/**
|
||||
* 활성화된 배너 목록 조회 API
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
fun getBannerList(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = PageRequest.of(page, size)
|
||||
val banners = bannerService.getActiveBanners(pageable)
|
||||
val response = SeriesBannerListPageResponse(
|
||||
totalCount = banners.totalElements,
|
||||
content = banners.content.map { SeriesBannerResponse.from(it, imageHost) }
|
||||
)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 상세 조회 API
|
||||
*/
|
||||
@GetMapping("/{bannerId}")
|
||||
fun getBannerDetail(@PathVariable bannerId: Long) = run {
|
||||
val banner = bannerService.getBannerById(bannerId)
|
||||
val response = SeriesBannerResponse.from(banner, imageHost)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 등록 API
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
fun registerBanner(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java)
|
||||
|
||||
val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "")
|
||||
val imagePath = saveImage(banner.id!!, image)
|
||||
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
|
||||
val response = SeriesBannerResponse.from(updatedBanner, imageHost)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 수정 API
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
fun updateBanner(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, SeriesBannerUpdateRequest::class.java)
|
||||
// 배너 존재 확인
|
||||
bannerService.getBannerById(request.bannerId)
|
||||
val imagePath = saveImage(request.bannerId, image)
|
||||
val updated = bannerService.updateBanner(
|
||||
bannerId = request.bannerId,
|
||||
imagePath = imagePath,
|
||||
seriesId = request.seriesId
|
||||
)
|
||||
val response = SeriesBannerResponse.from(updated, imageHost)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 삭제 API (소프트 삭제)
|
||||
*/
|
||||
@DeleteMapping("/{bannerId}")
|
||||
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
||||
bannerService.deleteBanner(bannerId)
|
||||
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 정렬 순서 일괄 변경 API
|
||||
*/
|
||||
@PutMapping("/orders")
|
||||
fun updateBannerOrders(
|
||||
@RequestBody request: UpdateBannerOrdersRequest
|
||||
) = run {
|
||||
bannerService.updateBannerOrders(request.ids)
|
||||
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
|
||||
}
|
||||
|
||||
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
val fileName = generateFileName("series-banner")
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = s3Bucket,
|
||||
filePath = "series_banner/$bannerId/$fileName",
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series.banner.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
|
||||
|
||||
// 시리즈 배너 등록 요청 DTO
|
||||
data class SeriesBannerRegisterRequest(
|
||||
@JsonProperty("seriesId") val seriesId: Long
|
||||
)
|
||||
|
||||
// 시리즈 배너 수정 요청 DTO
|
||||
data class SeriesBannerUpdateRequest(
|
||||
@JsonProperty("bannerId") val bannerId: Long,
|
||||
@JsonProperty("seriesId") val seriesId: Long? = null
|
||||
)
|
||||
|
||||
// 시리즈 배너 응답 DTO
|
||||
data class SeriesBannerResponse(
|
||||
val id: Long,
|
||||
val imagePath: String,
|
||||
val seriesId: Long,
|
||||
val seriesTitle: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(banner: SeriesBanner, imageHost: String): SeriesBannerResponse {
|
||||
return SeriesBannerResponse(
|
||||
id = banner.id!!,
|
||||
imagePath = "$imageHost/${banner.imagePath}",
|
||||
seriesId = banner.series.id!!,
|
||||
seriesTitle = banner.series.title
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 시리즈 배너 목록 페이지 응답 DTO
|
||||
data class SeriesBannerListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<SeriesBannerResponse>
|
||||
)
|
||||
@@ -8,7 +8,6 @@ interface AdminContentSeriesGenreRepository : JpaRepository<SeriesGenre, Long>,
|
||||
|
||||
interface AdminContentSeriesGenreQueryRepository {
|
||||
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
|
||||
fun findActiveSeriesGenreById(id: Long): SeriesGenre?
|
||||
}
|
||||
|
||||
class AdminContentSeriesGenreQueryRepositoryImpl(
|
||||
@@ -22,14 +21,4 @@ class AdminContentSeriesGenreQueryRepositoryImpl(
|
||||
.orderBy(seriesGenre.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun findActiveSeriesGenreById(id: Long): SeriesGenre? {
|
||||
return queryFactory
|
||||
.selectFrom(seriesGenre)
|
||||
.where(
|
||||
seriesGenre.id.eq(id)
|
||||
.and(seriesGenre.isActive.isTrue)
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ class AdminRecommendSeriesQueryRepositoryImpl(
|
||||
.and(series.isActive.isTrue)
|
||||
.and(recommendSeries.isFree.eq(isFree))
|
||||
)
|
||||
.orderBy(recommendSeries.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
@@ -21,8 +18,6 @@ class AdminContentThemeService(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val repository: AdminContentThemeRepository,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String
|
||||
) {
|
||||
@@ -42,14 +37,7 @@ class AdminContentThemeService(
|
||||
}
|
||||
|
||||
fun createTheme(theme: String, imagePath: String) {
|
||||
val savedTheme = repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = savedTheme.id!!,
|
||||
targetType = LanguageTranslationTargetType.CONTENT_THEME
|
||||
)
|
||||
)
|
||||
repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||
}
|
||||
|
||||
fun themeExistCheck(request: CreateContentThemeRequest) {
|
||||
|
||||
@@ -2,13 +2,10 @@ package kr.co.vividnext.sodalive.admin.event.banner
|
||||
|
||||
import com.querydsl.core.types.dsl.DateTimePath
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.core.types.dsl.StringExpression
|
||||
import com.querydsl.core.types.dsl.StringPath
|
||||
import com.querydsl.core.types.dsl.StringTemplate
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.event.Event
|
||||
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@@ -19,13 +16,12 @@ interface AdminEventBannerQueryRepository {
|
||||
}
|
||||
|
||||
class AdminEventBannerQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : AdminEventBannerQueryRepository {
|
||||
override fun getEventList(): List<GetAdminEventResponse> {
|
||||
val now = LocalDateTime.now()
|
||||
val where = event.isActive.isTrue
|
||||
.and(event.startDate.loe(now))
|
||||
.and(event.endDate.goe(now))
|
||||
|
||||
return queryFactory
|
||||
@@ -33,9 +29,9 @@ class AdminEventBannerQueryRepositoryImpl(
|
||||
QGetAdminEventResponse(
|
||||
event.id,
|
||||
event.title,
|
||||
getProcessedUrlExpression(event.thumbnailImage),
|
||||
getProcessedUrlExpression(event.detailImage),
|
||||
getProcessedUrlExpression(event.popupImage),
|
||||
event.thumbnailImage,
|
||||
event.detailImage,
|
||||
event.popupImage,
|
||||
getFormattedDate(event.startDate),
|
||||
getFormattedDate(event.endDate),
|
||||
event.link,
|
||||
@@ -59,14 +55,7 @@ class AdminEventBannerQueryRepositoryImpl(
|
||||
"UTC",
|
||||
"Asia/Seoul"
|
||||
),
|
||||
"%Y-%m-%d %H:%i"
|
||||
)
|
||||
}
|
||||
|
||||
private fun getProcessedUrlExpression(path: StringPath): StringExpression {
|
||||
return Expressions.stringTemplate(
|
||||
"CASE WHEN {0} LIKE '$cloudFrontHost%' THEN {0} ELSE CONCAT('$cloudFrontHost/', {0}) END",
|
||||
path
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@@ -20,7 +20,9 @@ class AdminEventBannerService(
|
||||
private val s3Uploader: S3Uploader,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String
|
||||
private val bucket: String,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
@Transactional
|
||||
fun save(
|
||||
@@ -36,13 +38,13 @@ class AdminEventBannerService(
|
||||
): Long {
|
||||
if (detail == null && link.isNullOrBlank()) throw SodaException("상세이미지 혹은 링크를 등록하세요")
|
||||
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val startDate = LocalDate.parse(startDateString, dateTimeFormatter).atTime(0, 0)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter).withSecond(59)
|
||||
val endDate = LocalDate.parse(endDateString, dateTimeFormatter).atTime(23, 59, 59)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
@@ -156,8 +158,8 @@ class AdminEventBannerService(
|
||||
)
|
||||
}
|
||||
|
||||
if (event.link != link) {
|
||||
event.link = if (link.isNullOrBlank()) null else link
|
||||
if (!link.isNullOrBlank() && event.link != link) {
|
||||
event.link = link
|
||||
}
|
||||
|
||||
if (!title.isNullOrBlank() && event.title != title) {
|
||||
@@ -172,16 +174,16 @@ class AdminEventBannerService(
|
||||
event.isAdult = isAdult
|
||||
}
|
||||
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
if (startDateString != null) {
|
||||
event.startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
||||
event.startDate = LocalDate.parse(startDateString, dateTimeFormatter).atTime(0, 0)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
}
|
||||
|
||||
if (endDateString != null) {
|
||||
event.endDate = LocalDateTime.parse(endDateString, dateTimeFormatter).withSecond(59)
|
||||
event.endDate = LocalDate.parse(endDateString, dateTimeFormatter).atTime(23, 59, 59)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
@@ -199,5 +201,22 @@ class AdminEventBannerService(
|
||||
|
||||
fun getEventList(): List<GetAdminEventResponse> {
|
||||
return repository.getEventList()
|
||||
.asSequence()
|
||||
.map {
|
||||
if (!it.thumbnailImageUrl.startsWith("https://")) {
|
||||
it.thumbnailImageUrl = "$cloudFrontHost/${it.thumbnailImageUrl}"
|
||||
}
|
||||
|
||||
if (it.detailImageUrl != null && !it.detailImageUrl!!.startsWith("https://")) {
|
||||
it.detailImageUrl = "$cloudFrontHost/${it.detailImageUrl}"
|
||||
}
|
||||
|
||||
if (it.popupImageUrl != null && !it.popupImageUrl!!.startsWith("https://")) {
|
||||
it.popupImageUrl = "$cloudFrontHost/${it.popupImageUrl}"
|
||||
}
|
||||
|
||||
it
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import com.querydsl.core.annotations.QueryProjection
|
||||
data class GetAdminEventResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String? = null,
|
||||
val thumbnailImageUrl: String,
|
||||
val detailImageUrl: String? = null,
|
||||
val popupImageUrl: String? = null,
|
||||
val startDate: String,
|
||||
val endDate: String,
|
||||
var thumbnailImageUrl: String,
|
||||
var detailImageUrl: String? = null,
|
||||
var popupImageUrl: String? = null,
|
||||
var startDate: String,
|
||||
var endDate: String,
|
||||
val link: String? = null,
|
||||
val isAdult: Boolean? = null,
|
||||
val isPopup: Boolean
|
||||
|
||||
@@ -1,51 +1,22 @@
|
||||
package kr.co.vividnext.sodalive.admin.event.charge
|
||||
|
||||
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.admin.event.charge.QChargeEvent.chargeEvent
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
interface AdminChargeEventRepository : JpaRepository<ChargeEvent, Long>, AdminChargeEventQueryRepository
|
||||
|
||||
interface AdminChargeEventQueryRepository {
|
||||
fun getChargeEventList(): List<GetChargeEventListResponse>
|
||||
fun getChargeEventList(): List<ChargeEvent>
|
||||
}
|
||||
|
||||
class AdminChargeEventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminChargeEventQueryRepository {
|
||||
override fun getChargeEventList(): List<GetChargeEventListResponse> {
|
||||
override fun getChargeEventList(): List<ChargeEvent> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetChargeEventListResponse(
|
||||
chargeEvent.id,
|
||||
chargeEvent.title,
|
||||
getFormattedDate(chargeEvent.startDate),
|
||||
getFormattedDate(chargeEvent.endDate),
|
||||
chargeEvent.availableCount,
|
||||
chargeEvent.addPercent.multiply(100).castToNum(Int::class.java),
|
||||
chargeEvent.isActive
|
||||
)
|
||||
)
|
||||
.from(chargeEvent)
|
||||
.selectFrom(chargeEvent)
|
||||
.orderBy(chargeEvent.createdAt.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
||||
return Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
Expressions.dateTimeTemplate(
|
||||
LocalDateTime::class.java,
|
||||
"CONVERT_TZ({0},{1},{2})",
|
||||
dateTimePath,
|
||||
"UTC",
|
||||
"Asia/Seoul"
|
||||
),
|
||||
"%Y-%m-%d %H:%i"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@@ -13,13 +13,13 @@ import java.time.format.DateTimeFormatter
|
||||
class AdminChargeEventService(private val repository: AdminChargeEventRepository) {
|
||||
@Transactional
|
||||
fun createChargeEvent(request: CreateChargeEventRequest): Long {
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
val startDate = LocalDateTime.parse(request.startDateString, dateTimeFormatter)
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val startDate = LocalDate.parse(request.startDateString, dateTimeFormatter).atTime(0, 0)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
val endDate = LocalDateTime.parse(request.endDateString, dateTimeFormatter).withSecond(59)
|
||||
val endDate = LocalDate.parse(request.endDateString, dateTimeFormatter).atTime(23, 59, 59)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
@@ -44,16 +44,16 @@ class AdminChargeEventService(private val repository: AdminChargeEventRepository
|
||||
chargeEvent.title = request.title
|
||||
}
|
||||
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
if (request.startDateString != null) {
|
||||
chargeEvent.startDate = LocalDateTime.parse(request.startDateString, dateTimeFormatter)
|
||||
chargeEvent.startDate = LocalDate.parse(request.startDateString, dateTimeFormatter).atTime(0, 0)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
}
|
||||
|
||||
if (request.endDateString != null) {
|
||||
chargeEvent.endDate = LocalDateTime.parse(request.endDateString, dateTimeFormatter).withSecond(59)
|
||||
chargeEvent.endDate = LocalDate.parse(request.endDateString, dateTimeFormatter).atTime(23, 59, 59)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
@@ -74,5 +74,27 @@ class AdminChargeEventService(private val repository: AdminChargeEventRepository
|
||||
|
||||
fun getChargeEventList(): List<GetChargeEventListResponse> {
|
||||
return repository.getChargeEventList()
|
||||
.map {
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val startDate = it.startDate
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||
.format(dateTimeFormatter)
|
||||
|
||||
val endDate = it.endDate
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||
.format(dateTimeFormatter)
|
||||
|
||||
GetChargeEventListResponse(
|
||||
id = it.id!!,
|
||||
title = it.title,
|
||||
startDate = startDate,
|
||||
endDate = endDate,
|
||||
availableCount = it.availableCount,
|
||||
addPercent = (it.addPercent * 100).toInt(),
|
||||
isActive = it.isActive
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.event.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class GetChargeEventListResponse @QueryProjection constructor(
|
||||
data class GetChargeEventListResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val startDate: String,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.marketing
|
||||
|
||||
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.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
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequestMapping("/admin/marketing/media-partner")
|
||||
class AdminAdMediaPartnerController(private val service: AdminAdMediaPartnerService) {
|
||||
@PostMapping
|
||||
fun createMediaPartner(
|
||||
@RequestBody request: CreateAdMediaPartnerRequest
|
||||
) = ApiResponse.ok(service.createMediaPartner(request))
|
||||
|
||||
@PutMapping
|
||||
fun updateMediaPartner(
|
||||
@RequestBody request: UpdateAdMediaPartnerRequest
|
||||
) = ApiResponse.ok(service.updateMediaPartner(request))
|
||||
|
||||
@GetMapping
|
||||
fun getMediaPartnerList(pageable: Pageable) = ApiResponse.ok(
|
||||
service.getMediaPartnerList(
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.marketing
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.marketing.AdMediaPartnerRepository
|
||||
import kr.co.vividnext.sodalive.marketing.AdMediaPartnerType
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@Service
|
||||
class AdminAdMediaPartnerService(private val repository: AdMediaPartnerRepository) {
|
||||
@Transactional
|
||||
fun createMediaPartner(request: CreateAdMediaPartnerRequest) {
|
||||
val mediaPartner = request.toEntity()
|
||||
repository.save(mediaPartner)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateMediaPartner(request: UpdateAdMediaPartnerRequest) {
|
||||
val entity = repository.findByIdOrNull(request.id)
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
if (request.mediaGroup != null) {
|
||||
entity.mediaGroup = request.mediaGroup
|
||||
}
|
||||
|
||||
if (request.pid != null) {
|
||||
entity.pid = request.pid
|
||||
}
|
||||
|
||||
if (request.pidName != null) {
|
||||
entity.pidName = request.pidName
|
||||
}
|
||||
|
||||
if (request.type != null) {
|
||||
entity.type = request.type
|
||||
}
|
||||
|
||||
if (request.utmSource != null) {
|
||||
entity.utmSource = request.utmSource
|
||||
}
|
||||
|
||||
if (request.utmMedium != null) {
|
||||
entity.utmMedium = request.utmMedium
|
||||
}
|
||||
|
||||
if (request.isActive != null) {
|
||||
entity.isActive = request.isActive
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaPartnerList(offset: Long, limit: Long): GetAdminAdMediaPartnerResponse {
|
||||
val totalCount = repository.getMediaPartnerListTotalCount()
|
||||
val items = repository.getMediaPartnerList(offset, limit)
|
||||
.map {
|
||||
val deepLinkValue = when (it.type) {
|
||||
AdMediaPartnerType.SERIES -> "series"
|
||||
AdMediaPartnerType.CONTENT -> "content"
|
||||
AdMediaPartnerType.LIVE -> "live"
|
||||
AdMediaPartnerType.CHANNEL -> "channel"
|
||||
AdMediaPartnerType.MAIN -> "main"
|
||||
}
|
||||
|
||||
val params = mapOf(
|
||||
"af_dp" to "voiceon://",
|
||||
"deep_link_value" to deepLinkValue,
|
||||
"deep_link_sub1" to it.pid,
|
||||
"deep_link_sub2" to it.utmSource,
|
||||
"deep_link_sub3" to it.utmMedium,
|
||||
"deep_link_sub4" to it.pidName,
|
||||
"utm_source" to it.utmSource,
|
||||
"utm_medium" to it.utmMedium,
|
||||
"utm_campaign" to it.pid
|
||||
)
|
||||
val link = createUrl(params = params)
|
||||
|
||||
it.link = link
|
||||
it
|
||||
}
|
||||
|
||||
return GetAdminAdMediaPartnerResponse(
|
||||
totalCount = totalCount,
|
||||
items = items
|
||||
)
|
||||
}
|
||||
|
||||
private fun createUrl(params: Map<String, String>): String {
|
||||
val encodedParams = params.map { (key, value) ->
|
||||
"${URLEncoder.encode(key, StandardCharsets.UTF_8.toString())}=" +
|
||||
URLEncoder.encode(value, StandardCharsets.UTF_8.toString())
|
||||
}.joinToString("&")
|
||||
|
||||
return "$ONE_LINK_HOST?$encodedParams"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ONE_LINK_HOST = "https://voiceon.onelink.me/RkTm"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.marketing
|
||||
|
||||
import kr.co.vividnext.sodalive.marketing.AdMediaPartner
|
||||
import kr.co.vividnext.sodalive.marketing.AdMediaPartnerType
|
||||
|
||||
data class CreateAdMediaPartnerRequest(
|
||||
val mediaGroup: String,
|
||||
val pid: String,
|
||||
val pidName: String,
|
||||
val type: AdMediaPartnerType,
|
||||
val utmSource: String,
|
||||
val utmMedium: String
|
||||
) {
|
||||
fun toEntity(): AdMediaPartner {
|
||||
return AdMediaPartner(
|
||||
mediaGroup = mediaGroup,
|
||||
pid = pid,
|
||||
pidName = pidName,
|
||||
type = type,
|
||||
utmSource = utmSource,
|
||||
utmMedium = utmMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.marketing
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.marketing.AdMediaPartnerType
|
||||
|
||||
data class GetAdminAdMediaPartnerResponse(
|
||||
val totalCount: Int,
|
||||
val items: List<GetAdminAdMediaPartnerResponseItem>
|
||||
)
|
||||
|
||||
data class GetAdminAdMediaPartnerResponseItem @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val mediaGroup: String,
|
||||
val pid: String,
|
||||
val pidName: String,
|
||||
val type: AdMediaPartnerType,
|
||||
val utmSource: String,
|
||||
val utmMedium: String,
|
||||
val isActive: Boolean,
|
||||
val createdAt: String,
|
||||
var link: String
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.marketing
|
||||
|
||||
import kr.co.vividnext.sodalive.marketing.AdMediaPartnerType
|
||||
|
||||
data class UpdateAdMediaPartnerRequest(
|
||||
val id: Long,
|
||||
val mediaGroup: String?,
|
||||
val pid: String?,
|
||||
val pidName: String?,
|
||||
val type: AdMediaPartnerType?,
|
||||
val utmSource: String?,
|
||||
val utmMedium: String?,
|
||||
val isActive: Boolean?
|
||||
)
|
||||
@@ -36,12 +36,6 @@ class AdminMemberController(private val service: AdminMemberService) {
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(service.searchMember(searchWord, pageable))
|
||||
|
||||
@GetMapping("/search-by-nickname")
|
||||
fun searchMemberByNickname(
|
||||
@RequestParam(value = "search_word") searchWord: String,
|
||||
@RequestParam(value = "size", required = false) size: Int?
|
||||
) = ApiResponse.ok(service.searchMemberByNickname(searchWord = searchWord, size = size ?: 20))
|
||||
|
||||
@GetMapping("/creator/all/list")
|
||||
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ interface AdminMemberQueryRepository {
|
||||
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
|
||||
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
|
||||
fun findByIdAndActive(memberId: Long): Member?
|
||||
fun searchMemberByNickname(searchWord: String, limit: Long = 20): List<AdminSimpleMemberResponse>
|
||||
}
|
||||
|
||||
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
|
||||
@@ -122,22 +121,4 @@ class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
|
||||
.orderBy(member.id.desc())
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun searchMemberByNickname(searchWord: String, limit: Long): List<AdminSimpleMemberResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QAdminSimpleMemberResponse(
|
||||
member.id,
|
||||
member.nickname
|
||||
)
|
||||
)
|
||||
.from(member)
|
||||
.where(
|
||||
member.nickname.contains(searchWord)
|
||||
.and(member.isActive.isTrue)
|
||||
)
|
||||
.orderBy(member.id.desc())
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.admin.member
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberProvider
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.Pageable
|
||||
@@ -99,13 +98,6 @@ class AdminMemberService(
|
||||
MemberRole.BOT -> "봇"
|
||||
}
|
||||
|
||||
val loginType = when (it.provider) {
|
||||
MemberProvider.EMAIL -> "이메일"
|
||||
MemberProvider.KAKAO -> "카카오"
|
||||
MemberProvider.GOOGLE -> "구글"
|
||||
MemberProvider.APPLE -> "애플"
|
||||
}
|
||||
|
||||
val signUpDate = it.createdAt!!
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||
@@ -130,7 +122,6 @@ class AdminMemberService(
|
||||
"$cloudFrontHost/profile/default-profile.png"
|
||||
},
|
||||
userType = userType,
|
||||
loginType = loginType,
|
||||
container = it.container,
|
||||
auth = it.auth != null,
|
||||
signUpDate = signUpDate,
|
||||
@@ -145,12 +136,6 @@ class AdminMemberService(
|
||||
return repository.getCreatorAllList()
|
||||
}
|
||||
|
||||
fun searchMemberByNickname(searchWord: String, size: Int = 20): List<AdminSimpleMemberResponse> {
|
||||
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
||||
val limit = if (size <= 0) 20 else size
|
||||
return repository.searchMemberByNickname(searchWord = searchWord, limit = limit.toLong())
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetPassword(request: ResetPasswordRequest) {
|
||||
val member = repository.findByIdAndActive(memberId = request.memberId)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.member
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
/**
|
||||
* 관리자용 간단 회원 응답 DTO
|
||||
* 닉네임 검색 결과로 사용되며 charge 등에서 memberId 선택에 활용된다.
|
||||
*/
|
||||
data class AdminSimpleMemberResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val nickname: String
|
||||
)
|
||||
@@ -11,7 +11,6 @@ data class GetAdminMemberListResponseItem(
|
||||
val nickname: String,
|
||||
val profileUrl: String,
|
||||
val userType: String,
|
||||
val loginType: String,
|
||||
val container: String,
|
||||
val auth: Boolean,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||
|
||||
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/marketing/statistics")
|
||||
class AdminAdStatisticsController(private val service: AdminAdStatisticsService) {
|
||||
@GetMapping
|
||||
fun getStatistics(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam endDateStr: String,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getStatistics(
|
||||
startDateStr = startDateStr,
|
||||
endDateStr = endDateStr,
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||
|
||||
import com.querydsl.core.types.dsl.CaseBuilder
|
||||
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.marketing.AdTrackingHistoryType
|
||||
import kr.co.vividnext.sodalive.marketing.QAdTrackingHistory.adTrackingHistory
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
fun getAdStatisticsDataTotalCount(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime
|
||||
): Int {
|
||||
return queryFactory
|
||||
.select(adTrackingHistory.pid)
|
||||
.from(adTrackingHistory)
|
||||
.where(
|
||||
adTrackingHistory.createdAt.goe(startDate),
|
||||
adTrackingHistory.createdAt.loe(endDate)
|
||||
)
|
||||
.groupBy(
|
||||
getFormattedDate(adTrackingHistory.createdAt),
|
||||
adTrackingHistory.mediaGroup,
|
||||
adTrackingHistory.pid,
|
||||
adTrackingHistory.pidName
|
||||
)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
fun getAdStatisticsDataList(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<GetAdminAdStatisticsItem> {
|
||||
val signUpCount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.SIGNUP))
|
||||
.then(1)
|
||||
.otherwise(0)
|
||||
.sum()
|
||||
|
||||
val launchCount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.APP_LAUNCH))
|
||||
.then(1)
|
||||
.otherwise(0)
|
||||
.sum()
|
||||
|
||||
val loginCount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.LOGIN))
|
||||
.then(1)
|
||||
.otherwise(0)
|
||||
.sum()
|
||||
|
||||
val firstPaymentCount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
||||
.then(1)
|
||||
.otherwise(0)
|
||||
.sum()
|
||||
|
||||
val firstPaymentTotalAmount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(0.toBigDecimal())
|
||||
.sum()
|
||||
|
||||
val repeatPaymentCount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||
.then(1)
|
||||
.otherwise(0)
|
||||
.sum()
|
||||
|
||||
val repeatPaymentTotalAmount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(0.toBigDecimal())
|
||||
.sum()
|
||||
|
||||
val allPaymentCount = CaseBuilder()
|
||||
.`when`(
|
||||
adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT)
|
||||
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||
)
|
||||
.then(1)
|
||||
.otherwise(0)
|
||||
.sum()
|
||||
|
||||
val allPaymentTotalAmount = CaseBuilder()
|
||||
.`when`(
|
||||
adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT)
|
||||
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||
)
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(0.toBigDecimal())
|
||||
.sum()
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAdminAdStatisticsItem(
|
||||
getFormattedDate(adTrackingHistory.createdAt),
|
||||
adTrackingHistory.mediaGroup,
|
||||
adTrackingHistory.pid,
|
||||
adTrackingHistory.pidName,
|
||||
launchCount,
|
||||
loginCount,
|
||||
signUpCount,
|
||||
firstPaymentCount,
|
||||
firstPaymentTotalAmount,
|
||||
repeatPaymentCount,
|
||||
repeatPaymentTotalAmount,
|
||||
allPaymentCount,
|
||||
allPaymentTotalAmount
|
||||
)
|
||||
)
|
||||
.from(adTrackingHistory)
|
||||
.where(
|
||||
adTrackingHistory.createdAt.goe(startDate),
|
||||
adTrackingHistory.createdAt.loe(endDate)
|
||||
)
|
||||
.groupBy(
|
||||
getFormattedDate(adTrackingHistory.createdAt),
|
||||
adTrackingHistory.mediaGroup,
|
||||
adTrackingHistory.pid,
|
||||
adTrackingHistory.pidName
|
||||
)
|
||||
.orderBy(getFormattedDate(adTrackingHistory.createdAt).desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
||||
return Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
Expressions.dateTimeTemplate(
|
||||
LocalDateTime::class.java,
|
||||
"CONVERT_TZ({0},{1},{2})",
|
||||
dateTimePath,
|
||||
"UTC",
|
||||
"Asia/Seoul"
|
||||
),
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||
|
||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class AdminAdStatisticsService(
|
||||
private val repository: AdminAdStatisticsRepository
|
||||
) {
|
||||
fun getStatistics(
|
||||
startDateStr: String,
|
||||
endDateStr: String,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): GetAdminAdStatisticsResponse {
|
||||
val startDate = startDateStr.convertLocalDateTime()
|
||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||
|
||||
val totalCount = repository.getAdStatisticsDataTotalCount(startDate, endDate)
|
||||
val items = repository.getAdStatisticsDataList(startDate, endDate, offset, limit)
|
||||
|
||||
return GetAdminAdStatisticsResponse(
|
||||
totalCount = totalCount,
|
||||
items = items
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetAdminAdStatisticsResponse(
|
||||
val totalCount: Int,
|
||||
val items: List<GetAdminAdStatisticsItem>
|
||||
)
|
||||
|
||||
data class GetAdminAdStatisticsItem @QueryProjection constructor(
|
||||
val date: String,
|
||||
val mediaGroup: String,
|
||||
val pid: String,
|
||||
val pidName: String,
|
||||
val launchCount: Int,
|
||||
val loginCount: Int,
|
||||
val signUpCount: Int,
|
||||
val firstPaymentCount: Int,
|
||||
val firstPaymentTotalAmount: BigDecimal,
|
||||
val repeatPaymentCount: Int,
|
||||
val repeatPaymentTotalAmount: BigDecimal,
|
||||
val allPaymentCount: Int,
|
||||
val allPaymentTotalAmount: BigDecimal
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.member
|
||||
|
||||
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/member/statistics")
|
||||
class AdminMemberStatisticsController(private val service: AdminMemberStatisticsService) {
|
||||
@GetMapping
|
||||
fun getStatistics(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam endDateStr: String,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getStatistics(
|
||||
startDateStr = startDateStr,
|
||||
endDateStr = endDateStr,
|
||||
pageable = pageable
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.member
|
||||
|
||||
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.can.charge.ChargeStatus
|
||||
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||
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.QSignOut.signOut
|
||||
import kr.co.vividnext.sodalive.member.auth.QAuth.auth
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
fun getTotalSignUpCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||
return queryFactory
|
||||
.select(member.id)
|
||||
.from(member)
|
||||
.where(
|
||||
member.createdAt.goe(startDate),
|
||||
member.createdAt.loe(endDate)
|
||||
)
|
||||
.fetch()
|
||||
.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 {
|
||||
return queryFactory
|
||||
.select(signOut.id)
|
||||
.from(signOut)
|
||||
.where(
|
||||
signOut.createdAt.goe(startDate),
|
||||
signOut.createdAt.loe(endDate)
|
||||
)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
fun getPaymentMemberCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||
return queryFactory
|
||||
.select(
|
||||
QDateAndMemberCount(
|
||||
getFormattedDate(charge.createdAt),
|
||||
member.id.countDistinct().castToNum(Int::class.java)
|
||||
)
|
||||
)
|
||||
.from(charge)
|
||||
.innerJoin(charge.member, member)
|
||||
.innerJoin(charge.payment, payment)
|
||||
.where(
|
||||
charge.status.eq(ChargeStatus.CHARGE),
|
||||
payment.status.eq(PaymentStatus.COMPLETE),
|
||||
charge.createdAt.goe(startDate),
|
||||
charge.createdAt.loe(endDate)
|
||||
)
|
||||
.groupBy(getFormattedDate(charge.createdAt))
|
||||
.fetch()
|
||||
.sumOf { it.memberCount }
|
||||
}
|
||||
|
||||
fun getSignUpCountInRange(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)
|
||||
)
|
||||
.groupBy(getFormattedDate(member.createdAt))
|
||||
.orderBy(getFormattedDate(member.createdAt).desc())
|
||||
.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> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QDateAndMemberCount(
|
||||
getFormattedDate(signOut.createdAt),
|
||||
signOut.id.countDistinct().castToNum(Int::class.java)
|
||||
)
|
||||
)
|
||||
.from(signOut)
|
||||
.where(
|
||||
signOut.createdAt.goe(startDate),
|
||||
signOut.createdAt.loe(endDate)
|
||||
)
|
||||
.groupBy(getFormattedDate(signOut.createdAt))
|
||||
.orderBy(getFormattedDate(signOut.createdAt).desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getPaymentMemberCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QDateAndMemberCount(
|
||||
getFormattedDate(charge.createdAt),
|
||||
member.id.countDistinct().castToNum(Int::class.java)
|
||||
)
|
||||
)
|
||||
.from(charge)
|
||||
.innerJoin(charge.member, member)
|
||||
.innerJoin(charge.payment, payment)
|
||||
.where(
|
||||
charge.status.eq(ChargeStatus.CHARGE),
|
||||
payment.status.eq(PaymentStatus.COMPLETE),
|
||||
charge.createdAt.goe(startDate),
|
||||
charge.createdAt.loe(endDate)
|
||||
)
|
||||
.groupBy(getFormattedDate(charge.createdAt))
|
||||
.orderBy(getFormattedDate(charge.createdAt).desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
|
||||
return Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
Expressions.dateTimeTemplate(
|
||||
LocalDateTime::class.java,
|
||||
"CONVERT_TZ({0},{1},{2})",
|
||||
dateTimePath,
|
||||
"UTC",
|
||||
"Asia/Seoul"
|
||||
),
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.member
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@Service
|
||||
class AdminMemberStatisticsService(private val repository: AdminMemberStatisticsRepository) {
|
||||
fun getStatistics(
|
||||
startDateStr: String,
|
||||
endDateStr: String,
|
||||
pageable: Pageable
|
||||
): GetMemberStatisticsResponse {
|
||||
val startDate = LocalDate.parse(startDateStr)
|
||||
val endDate = LocalDate.parse(endDateStr)
|
||||
val now = LocalDateTime.now()
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||
.toLocalDate()
|
||||
|
||||
val dateRange = getPagedDateRange(
|
||||
startDate = startDate,
|
||||
endDate = endDate.coerceAtMost(now),
|
||||
page = pageable.pageNumber + 1,
|
||||
pageSize = pageable.pageSize
|
||||
)
|
||||
|
||||
if (dateRange == null) {
|
||||
throw SodaException("잘못된 접근입니다.")
|
||||
}
|
||||
|
||||
var startDateTime = startDate.atStartOfDay()
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
var endDateTime = endDate.atTime(LocalTime.MAX)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
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 totalPaymentMemberCount = repository.getPaymentMemberCount(startDate = startDateTime, endDate = endDateTime)
|
||||
|
||||
startDateTime = dateRange.startDate
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
endDateTime = dateRange.endDate
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
val signUpCountInRange = repository.getSignUpCountInRange(
|
||||
startDate = startDateTime,
|
||||
endDate = endDateTime
|
||||
).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(
|
||||
startDate = startDateTime,
|
||||
endDate = endDateTime
|
||||
).associateBy({ it.date }, { it.memberCount })
|
||||
|
||||
val paymentMemberCountInRange = repository.getPaymentMemberCountInRange(
|
||||
startDate = startDateTime,
|
||||
endDate = endDateTime
|
||||
)
|
||||
|
||||
val paymentMemberCountInRangeMap = paymentMemberCountInRange.associateBy({ it.date }, { it.memberCount })
|
||||
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val items = generateSequence(dateRange.endDate) { it.minusDays(1) }
|
||||
.takeWhile { !it.isBefore(dateRange.startDate) }
|
||||
.map {
|
||||
val date = it.format(formatter)
|
||||
GetMemberStatisticsItem(
|
||||
date = date,
|
||||
authCount = authCountInRange[date] ?: 0,
|
||||
signUpCount = signUpCountInRange[date] ?: 0,
|
||||
signUpEmailCount = signUpEmailCountInRange[date] ?: 0,
|
||||
signUpKakaoCount = signUpKakaoCountInRange[date] ?: 0,
|
||||
signUpGoogleCount = signUpGoogleCountInRange[date] ?: 0,
|
||||
signOutCount = signOutCountInRange[date] ?: 0,
|
||||
paymentMemberCount = paymentMemberCountInRangeMap[date] ?: 0
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
return GetMemberStatisticsResponse(
|
||||
totalCount = dateRange.totalDays,
|
||||
totalAuthCount = totalAuthCount,
|
||||
totalSignUpCount = totalSignUpCount,
|
||||
totalSignUpEmailCount = totalSignUpEmailCount,
|
||||
totalSignUpKakaoCount = totalSignUpKakaoCount,
|
||||
totalSignUpGoogleCount = totalSignUpGoogleCount,
|
||||
totalSignOutCount = totalSignOutCount,
|
||||
totalPaymentMemberCount = totalPaymentMemberCount,
|
||||
items = items
|
||||
)
|
||||
}
|
||||
|
||||
private fun getPagedDateRange(startDate: LocalDate, endDate: LocalDate, page: Int, pageSize: Int): PagedDateRange? {
|
||||
val totalDays = ChronoUnit.DAYS.between(startDate, endDate).toInt() + 1
|
||||
val totalPages = (totalDays + pageSize - 1) / pageSize // 전체 페이지 개수 계산
|
||||
|
||||
if (page < 1 || page > totalPages) return null // 페이지 범위를 벗어나면 null 반환
|
||||
|
||||
val rangeEnd = endDate.minusDays((page - 1) * pageSize.toLong()).atTime(LocalTime.MAX)
|
||||
val rangeStart = endDate.minusDays((page * pageSize - 1).toLong()).coerceAtLeast(startDate).atStartOfDay()
|
||||
|
||||
return PagedDateRange(rangeStart, rangeEnd, totalDays)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.member
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class DateAndMemberCount @QueryProjection constructor(
|
||||
val date: String,
|
||||
val memberCount: Int
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.member
|
||||
|
||||
data class GetMemberStatisticsResponse(
|
||||
val totalCount: Int,
|
||||
val totalAuthCount: Int,
|
||||
val totalSignUpCount: Int,
|
||||
val totalSignUpEmailCount: Int,
|
||||
val totalSignUpKakaoCount: Int,
|
||||
val totalSignUpGoogleCount: Int,
|
||||
val totalSignOutCount: Int,
|
||||
val totalPaymentMemberCount: Int,
|
||||
val items: List<GetMemberStatisticsItem>
|
||||
)
|
||||
|
||||
data class GetMemberStatisticsItem(
|
||||
val date: String,
|
||||
val authCount: Int,
|
||||
val signUpCount: Int,
|
||||
val signUpEmailCount: Int,
|
||||
val signUpKakaoCount: Int,
|
||||
val signUpGoogleCount: Int,
|
||||
val signOutCount: Int,
|
||||
val paymentMemberCount: Int
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.member
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class PagedDateRange(
|
||||
val startDate: LocalDateTime,
|
||||
val endDate: LocalDateTime,
|
||||
val totalDays: Int
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.GetAuditionListItem
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||
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 popularCharacters: List<Character>,
|
||||
val contentRanking: List<GetAudioContentRankingItem>,
|
||||
val recommendChannelList: List<RecommendChannelResponse>,
|
||||
val freeContentList: List<AudioContentMainItem>,
|
||||
val pointAvailableContentList: List<AudioContentMainItem>,
|
||||
val recommendContentList: List<AudioContentMainItem>,
|
||||
val curationList: List<GetContentCurationResponse>
|
||||
)
|
||||
@@ -1,107 +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 kr.co.vividnext.sodalive.rank.ContentRankingSortType
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 추천 콘텐츠만 새로고침하기 위한 엔드포인트
|
||||
@GetMapping("/recommend-contents")
|
||||
fun getRecommendContents(
|
||||
@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.getRecommendContentList(
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
member = member
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 콘텐츠 랭킹 엔드포인트
|
||||
@GetMapping("/content-ranking")
|
||||
fun getContentRanking(
|
||||
@RequestParam("sort", required = false) sort: ContentRankingSortType? = null,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@RequestParam("offset", required = false) offset: Long? = null,
|
||||
@RequestParam("limit", required = false) limit: Long? = null,
|
||||
@RequestParam("theme", required = false) theme: String? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.getContentRankingBySort(
|
||||
sort = sort ?: ContentRankingSortType.REVENUE,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
offset = offset,
|
||||
limit = limit,
|
||||
theme = theme,
|
||||
member = member
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,515 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.AuditionService
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
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.GetAudioContentRankingItem
|
||||
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.content.translation.ContentTranslationRepository
|
||||
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.i18n.LangContext
|
||||
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.ContentRankingSortType
|
||||
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 characterService: ChatCharacterService,
|
||||
private val rankingService: RankingService,
|
||||
private val rankingRepository: RankingRepository,
|
||||
private val explorerQueryRepository: ExplorerQueryRepository,
|
||||
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
|
||||
private val langContext: LangContext,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
companion object {
|
||||
private const val RECOMMEND_TARGET_SIZE = 30
|
||||
private const val RECOMMEND_MAX_ATTEMPTS = 3
|
||||
}
|
||||
|
||||
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 translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
|
||||
|
||||
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,
|
||||
orderByRandom = true
|
||||
)
|
||||
|
||||
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
||||
|
||||
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
||||
)
|
||||
|
||||
// 인기 캐릭터 조회
|
||||
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
|
||||
|
||||
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,
|
||||
sort = ContentRankingSortType.REVENUE
|
||||
)
|
||||
|
||||
val contentRankingContentIds = contentRanking.map { it.contentId }
|
||||
val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) {
|
||||
val translations = contentTranslationRepository
|
||||
.findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code)
|
||||
.associateBy { it.contentId }
|
||||
|
||||
contentRanking.map { item ->
|
||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contentRanking
|
||||
}
|
||||
|
||||
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
/**
|
||||
* recommendChannelList의 콘텐츠 번역 데이터 조회
|
||||
*
|
||||
* languageCode != null
|
||||
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
|
||||
*
|
||||
* 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다
|
||||
*/
|
||||
val channelContentIds = recommendChannelList
|
||||
.flatMap { it.contentList }
|
||||
.map { it.contentId }
|
||||
.distinct()
|
||||
|
||||
val translatedRecommendChannelList = if (channelContentIds.isNotEmpty()) {
|
||||
val translations = contentTranslationRepository
|
||||
.findByContentIdInAndLocale(contentIds = channelContentIds, locale = langContext.lang.code)
|
||||
.associateBy { it.contentId }
|
||||
|
||||
recommendChannelList.map { channel ->
|
||||
val translatedContentList = channel.contentList.map { item ->
|
||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
}
|
||||
|
||||
channel.copy(contentList = translatedContentList)
|
||||
}
|
||||
} else {
|
||||
recommendChannelList
|
||||
}
|
||||
|
||||
val freeContentList = contentService.getLatestContentByTheme(
|
||||
theme = contentThemeService.getActiveThemeOfContent(
|
||||
isAdult = isAdult,
|
||||
isFree = true,
|
||||
contentType = contentType
|
||||
),
|
||||
contentType = contentType,
|
||||
isFree = true,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = true
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList)
|
||||
|
||||
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
|
||||
val pointAvailableContentList = contentService.getLatestContentByTheme(
|
||||
theme = emptyList(),
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = true,
|
||||
isPointAvailableOnly = true
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
|
||||
|
||||
val curationList = curationService.getContentCurationList(
|
||||
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
memberId = memberId
|
||||
)
|
||||
|
||||
return GetHomeResponse(
|
||||
liveList = liveList,
|
||||
creatorRanking = creatorRanking,
|
||||
latestContentThemeList = latestContentThemeList,
|
||||
latestContentList = translatedLatestContentList,
|
||||
bannerList = bannerList,
|
||||
eventBannerList = eventBannerList,
|
||||
originalAudioDramaList = originalAudioDramaList,
|
||||
auditionList = auditionList,
|
||||
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
||||
popularCharacters = translatedPopularCharacters,
|
||||
contentRanking = translatedContentRanking,
|
||||
recommendChannelList = translatedRecommendChannelList,
|
||||
freeContentList = translatedFreeContentList,
|
||||
pointAvailableContentList = translatedPointAvailableContentList,
|
||||
recommendContentList = getRecommendContentList(
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
contentType = contentType,
|
||||
member = member
|
||||
),
|
||||
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)
|
||||
}
|
||||
|
||||
val contentList = contentService.getLatestContentByTheme(
|
||||
theme = themeList,
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
isAdult = isAdult
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
return getTranslatedContentList(contentList = contentList)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
fun getContentRankingBySort(
|
||||
sort: ContentRankingSortType,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
offset: Long?,
|
||||
limit: Long?,
|
||||
theme: String?,
|
||||
member: Member?
|
||||
): List<GetAudioContentRankingItem> {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
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)
|
||||
|
||||
return rankingService.getContentRanking(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
startDate = startDate.minusDays(1),
|
||||
endDate = endDate,
|
||||
offset = offset ?: 0,
|
||||
limit = limit ?: 12,
|
||||
sort = sort,
|
||||
theme = theme ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 추천 콘텐츠 조회 로직은 변경 가능성을 고려하여 별도 메서드로 추출한다.
|
||||
fun getRecommendContentList(
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member?
|
||||
): List<AudioContentMainItem> {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
// Set + List 조합으로 중복 제거 및 순서 보존, 각 시도마다 limit=60으로 조회
|
||||
val seen = HashSet<Long>(RECOMMEND_TARGET_SIZE * 2)
|
||||
val result = ArrayList<AudioContentMainItem>(RECOMMEND_TARGET_SIZE)
|
||||
var attempt = 0
|
||||
while (attempt < RECOMMEND_MAX_ATTEMPTS && result.size < RECOMMEND_TARGET_SIZE) {
|
||||
attempt += 1
|
||||
val batch = contentService.getLatestContentByTheme(
|
||||
theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회
|
||||
contentType = contentType,
|
||||
offset = 0,
|
||||
limit = (RECOMMEND_TARGET_SIZE * RECOMMEND_MAX_ATTEMPTS).toLong(), // 60개 조회
|
||||
isFree = false,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = true
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
for (item in batch) {
|
||||
if (result.size >= RECOMMEND_TARGET_SIZE) break
|
||||
if (seen.add(item.contentId)) {
|
||||
result.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getTranslatedContentList(contentList = result)
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||
*
|
||||
* 처리 절차:
|
||||
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
||||
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
|
||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||
*
|
||||
* 성능:
|
||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||
*
|
||||
* @param contentList 번역 대상 AudioContentMainItem 목록
|
||||
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
|
||||
*/
|
||||
private fun getTranslatedContentList(contentList: List<AudioContentMainItem>): List<AudioContentMainItem> {
|
||||
val contentIds = contentList.map { it.contentId }
|
||||
|
||||
return if (contentIds.isNotEmpty()) {
|
||||
val translations = contentTranslationRepository
|
||||
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||
.associateBy { it.contentId }
|
||||
|
||||
contentList.map { item ->
|
||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contentList
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||
*
|
||||
* 처리 절차:
|
||||
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
|
||||
* 번역 데이터를 한 번에 조회한다.
|
||||
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
|
||||
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
|
||||
*
|
||||
* @param aiCharacterList 번역 대상 캐릭터 목록
|
||||
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
|
||||
*/
|
||||
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
|
||||
val characterIds = aiCharacterList.map { it.characterId }
|
||||
|
||||
return if (characterIds.isNotEmpty()) {
|
||||
val translations = aiCharacterTranslationRepository
|
||||
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||
.associateBy { it.characterId }
|
||||
|
||||
aiCharacterList.map { character ->
|
||||
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
||||
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
|
||||
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
|
||||
character
|
||||
} else {
|
||||
character.copy(name = translatedName, description = translatedDesc)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
aiCharacterList
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -18,11 +18,13 @@ class AuditionController(private val service: AuditionService) {
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
pageable: Pageable
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
ApiResponse.ok(
|
||||
service.getAuditionList(
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong(),
|
||||
isAdult = member?.auth != null
|
||||
isAdult = member.auth != null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ interface AuditionQueryRepository {
|
||||
fun getCompletedAuditionCount(isAdult: Boolean): Int
|
||||
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem>
|
||||
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
|
||||
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem>
|
||||
}
|
||||
|
||||
class AuditionQueryRepositoryImpl(
|
||||
@@ -95,27 +94,4 @@ class AuditionQueryRepositoryImpl(
|
||||
.where(audition.id.eq(auditionId))
|
||||
.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
|
||||
)
|
||||
}
|
||||
|
||||
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
|
||||
return repository.getInProgressAuditionList(isAdult)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,10 @@ class AuditionApplicantQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(auditionApplicant.id)
|
||||
.from(auditionApplicant)
|
||||
.innerJoin(auditionApplicant.member, member)
|
||||
.innerJoin(auditionApplicant.role, auditionRole)
|
||||
.where(
|
||||
auditionRole.id.eq(auditionRoleId),
|
||||
auditionApplicant.isActive.isTrue,
|
||||
member.isActive.isTrue
|
||||
auditionApplicant.isActive.isTrue
|
||||
)
|
||||
.fetch()
|
||||
.size
|
||||
@@ -89,8 +87,7 @@ class AuditionApplicantQueryRepositoryImpl(
|
||||
.leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id))
|
||||
.where(
|
||||
auditionRole.id.eq(auditionRoleId),
|
||||
auditionApplicant.isActive.isTrue,
|
||||
member.isActive.isTrue
|
||||
auditionApplicant.isActive.isTrue
|
||||
)
|
||||
.groupBy(auditionApplicant.id)
|
||||
.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
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import java.math.BigDecimal
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
@@ -12,10 +10,7 @@ data class Can(
|
||||
var title: String,
|
||||
var can: Int,
|
||||
var rewardCan: Int,
|
||||
@Column(precision = 10, scale = 4, nullable = false)
|
||||
var price: BigDecimal,
|
||||
@Column(length = 3, nullable = false, columnDefinition = "CHAR(3)")
|
||||
var currency: String,
|
||||
var price: Int,
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
var status: CanStatus
|
||||
) : BaseEntity()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user