Compare commits
309 Commits
test
...
3c087bc275
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c087bc275 | |||
| 8ad13c289e | |||
| 7577f48a09 | |||
| 0251906964 | |||
| 2723a5f134 | |||
| c3c60605fd | |||
| 238f704b22 | |||
| 5639d8ac8e | |||
| 9aac591591 | |||
| ffa8e5aebb | |||
| cbbfe014cc | |||
| 83028f7817 | |||
| 70d1795557 | |||
| 8c6c681424 | |||
| 50bc9f4ff3 | |||
| f00ea03fad | |||
| f22e7b9ad1 | |||
| c7ec95f4bb | |||
| 229e7a8ccc | |||
| 3c616474ff | |||
| 56eb6b3ce3 | |||
| 545836d43c | |||
| 219f83dec0 | |||
| a76a841238 | |||
| c26680de84 | |||
| 8fffad9d3a | |||
| f4f0f203a2 | |||
| b7196f5a0c | |||
| 5d33a18890 | |||
| 96186a1a50 | |||
| bc8bc479d1 | |||
| 47595b1291 | |||
| 01a88964df | |||
| 3a2b77379f | |||
| dc4e5f75cd | |||
| d0178d551c | |||
| 827333108d | |||
| 587b90bd27 | |||
| 4dc20c5e90 | |||
| ac25782f2b | |||
| 20437d56e7 | |||
| f0b412828a | |||
| 367faac5c3 | |||
| 84deaaa970 | |||
| a2b39466c2 | |||
| 03586c4005 | |||
| 6ea69e1510 | |||
| 553c6dc539 | |||
| 6cc22f5b6d | |||
| 9103d67cc1 | |||
| 25083fb0e4 | |||
| d2dc045255 | |||
| b8621dfbb0 | |||
| 93633940dd | |||
| b6f5325351 | |||
| 7c32c08f1f | |||
| 1d268da08d | |||
| 797666ae0d | |||
| dcf470997e | |||
| 0974d1dbf8 | |||
| 12a35db6cd | |||
| 9abbb05ad8 | |||
| 1ecaf69b0b | |||
| e334d1e5d9 | |||
| b735e861d0 | |||
| 4eb433d372 | |||
| 2416ae61f3 | |||
| 01fb336985 | |||
| b6af88a732 | |||
| 58a2a17d6d | |||
| 79f5a0f520 | |||
| 7f6c0f7f04 | |||
| f658df4dca | |||
| 9d43b8e23a | |||
| 4270aef79b | |||
| 1c0dc82d44 | |||
| c1e325aadf | |||
| cec87da69d | |||
| f68f24cb2c | |||
| ed094347fc | |||
| b8afdffbe1 | |||
| f6ba79f31c | |||
| 5f3b1663d2 | |||
| 66e786b4bb | |||
| f671114574 | |||
| ce37060d94 | |||
| 7d19a4d184 | |||
| 22f28a2f8a | |||
| ceef9ca979 | |||
| efe8f4f939 | |||
| ba692a1195 | |||
| d732bad042 | |||
| 4c935c3bee | |||
| c160dd791f | |||
| 23cd1b4601 | |||
| 031fc8ba1b | |||
| c6853289ad | |||
| 2497bb69bc | |||
| a58a67e0a2 | |||
| 4315fe12a5 | |||
| 42f10a8899 | |||
| 1e4b47f989 | |||
| ff255dbfae | |||
| dbe9b72feb | |||
| 95a714b391 | |||
| 28f58c7f56 | |||
| 8bd46d8f21 | |||
| e1bb8e54ed | |||
| 1de705b063 | |||
| f6926ad356 | |||
| 2cdbbb1b37 | |||
| 4dce8c8f03 | |||
| 97a5bace6f | |||
| d4d51ec48f | |||
| fb91398462 | |||
| 105dadd798 | |||
| 2abf2837d3 | |||
| 422aa67af6 | |||
| 7fffab6985 | |||
| 5a4be3d2c1 | |||
| f39a7681db | |||
| c60a7580ba | |||
| 97edb56edc | |||
| 6ebca8d22b | |||
| 95371ad934 | |||
| 2c176825fd | |||
| fae7de48d3 | |||
| b8230646a2 | |||
| 43279541dd | |||
| b4791977c1 | |||
| ef917ecc25 | |||
| a93faad951 | |||
| fd001d24d3 | |||
| 7aa5884797 | |||
| 5b237a1547 | |||
| 2e37990d87 | |||
| dd07d724a8 | |||
| 03ce8618e7 | |||
| db1a7a7fd6 | |||
| 36a82d7f53 | |||
| 3a34401113 | |||
| 9927268330 | |||
| c45c97e29d | |||
| c64a315226 | |||
| a4cafca6ab | |||
| 46284a0660 | |||
| 05df86e15a | |||
| 8b433027e2 | |||
| 5bd4ff7610 | |||
| d693c397ea | |||
| 1d8d1ec9a5 | |||
| 5e491f11ee | |||
| 7cedea06ac | |||
| 2e5f750e50 | |||
| 20289cad10 | |||
| e0d64c31c7 | |||
| 8c1b95dc97 | |||
| fb5641343e | |||
| 87765941eb | |||
| 1809862c16 | |||
| 300f784f7d | |||
| 67a045eae6 | |||
| 2a79903a28 | |||
| d3222ce083 | |||
| 406a421742 | |||
| 10bf728faf | |||
| 607617747c | |||
| f0a69eb1a2 | |||
| 6b307a6e17 | |||
| 08d08a934a | |||
| c500c12668 | |||
| 62060adeba | |||
| b2fc75edb8 | |||
| a999dd2085 | |||
| 49f95ab100 | |||
| 1a84d5b30c | |||
| 3b65050632 | |||
| d0df31674c | |||
| 1fe88402e2 | |||
| 67097696e6 | |||
| 8e7e77067a | |||
| 9899390b61 | |||
| 80c476a908 | |||
| 59da1d6e49 | |||
| 5aef7dac33 | |||
| faf7aa06b6 | |||
| 38ef6e5583 | |||
| c0b15b5d94 | |||
| 2cfc067ea1 | |||
| a91db4f956 | |||
| 8a09780a02 | |||
| 45e8ec6505 | |||
| 4554b85914 | |||
| 8aa79c4a9c | |||
| c8d3210b57 | |||
| 2282a49563 | |||
| b82fdfb2c8 | |||
| 2d17eac199 | |||
| e482bc3aad | |||
| ec022b74d1 | |||
| dc42c09ce3 | |||
| 046a34d2a4 | |||
| 9ff6ec1888 | |||
| d2950106ec | |||
| 962f800d2e | |||
| 962107e507 | |||
| 039bd11963 | |||
| 5c250ea4ae | |||
| e3405bcec6 | |||
| 0fd1c2235f | |||
| b20c29b022 | |||
| 12d5dcd298 | |||
| 2c305dc6c6 | |||
| 62f76f7433 | |||
| 858ce524f9 | |||
| 3795fb4a40 | |||
| 0c01aeec50 | |||
| 892206744d | |||
| 9e2c1474db | |||
| 16328f73d9 | |||
| e0d4f53cf4 | |||
| e09a59c5b4 | |||
| 049e654535 | |||
| c927dc4ecd | |||
| fe4ecd0ad8 | |||
| 78d476fe80 | |||
| a11c8465d5 | |||
| 366304a9b7 | |||
| 4356663688 | |||
| 26b55e6fcf | |||
| 0d743f7204 | |||
| 6cbe113b3e | |||
| 6409b69d6c | |||
| c5164c76fc | |||
| baade8e138 | |||
| b848d6b4e0 | |||
| d8139d2ab0 | |||
| e96d8f7469 | |||
| 2acffd8afc | |||
| 3c8e72073c | |||
| 724d7a9d9b | |||
| 2da3b0db78 | |||
| 685ad7afaf | |||
| 264cf75964 | |||
| c773dbc7b5 | |||
| 37cbc64f52 | |||
| cb1dde17bb | |||
| c29988acf4 | |||
| eadbf56dae | |||
| 4b3b455135 | |||
| e6ac177396 | |||
| 3d0e29003f | |||
| 78b9b00f77 | |||
| 0ee7faa551 | |||
| e5fdced681 | |||
| afb99fef64 | |||
| 7dfaa36024 | |||
| 0496f665aa | |||
| 0d19e1be74 | |||
| 4aff0111aa | |||
| 63b3ba2bb2 | |||
| 7444b41f60 | |||
| 8e90dbc8b6 | |||
| 9f70722521 | |||
| 52fae596fa | |||
| ccb67957bc | |||
| fb82538d0d | |||
| 72ee39612e | |||
| 51fd5408dc | |||
| 3fae40fbef | |||
| 0745890af0 | |||
| 4abe1730a7 | |||
| 626f0e6989 | |||
| 9f42d9d173 | |||
| f90a93c4bc | |||
| 8000ad6c6a | |||
| 1f1f1bea1a | |||
| d95460c7cd | |||
| a3d93d4b08 | |||
| 07a92af982 | |||
| f4618877d4 | |||
| 2b914fd222 | |||
| 109e42a5a3 | |||
| fa515ad39c | |||
| f09673a795 | |||
| f71536c614 | |||
| 7bdddc7ae8 | |||
| aa8926a624 | |||
| be71e59be2 | |||
| 4d7753378f | |||
| 60257c4ef4 | |||
| 1e0b79bf62 | |||
| 6883434d0d | |||
| eda2193e64 | |||
| 99bf829c88 | |||
| 5feafe1b48 | |||
| c9292b7d04 | |||
| ae7e1a91c1 | |||
| 3e1887e0d1 | |||
| 474646db47 | |||
| 56f7b6c449 | |||
| 76b2b5f7e3 | |||
| e918d809eb | |||
| 7af059e543 | |||
| 897726e1ec | |||
| 8b98a2dd07 | |||
| cca75420f0 | |||
| 86c627ed1d | |||
| d55514e3a7 |
@@ -7,5 +7,5 @@ indent_size = 4
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 130
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -323,7 +323,4 @@ gradle-app.setting
|
||||
### Gradle Patch ###
|
||||
**/build/
|
||||
|
||||
.kiro/
|
||||
.junie
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
||||
|
||||
@@ -70,9 +70,6 @@ dependencies {
|
||||
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")
|
||||
runtimeOnly("com.mysql:mysql-connector-j")
|
||||
|
||||
@@ -39,10 +39,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -78,10 +75,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -148,10 +142,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(order.isActive.isTrue)
|
||||
.groupBy(
|
||||
member.id,
|
||||
@@ -239,10 +230,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
@@ -263,10 +251,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -296,10 +281,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -319,10 +301,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -352,10 +331,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -375,10 +351,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
@@ -409,10 +382,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
|
||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
|
||||
|
||||
@Entity
|
||||
data class CreatorSettlementRatio(
|
||||
var subsidy: Int,
|
||||
var liveSettlementRatio: Int,
|
||||
var contentSettlementRatio: Int,
|
||||
var communitySettlementRatio: Int
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
val contentSettlementRatio: Int,
|
||||
val communitySettlementRatio: Int
|
||||
) : BaseEntity() {
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
var member: Member? = null
|
||||
|
||||
var deletedAt: LocalDateTime? = null
|
||||
|
||||
fun softDelete() {
|
||||
this.deletedAt = LocalDateTime.now()
|
||||
}
|
||||
|
||||
fun restore() {
|
||||
this.deletedAt = null
|
||||
}
|
||||
|
||||
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
|
||||
this.subsidy = subsidy
|
||||
this.liveSettlementRatio = live
|
||||
this.contentSettlementRatio = content
|
||||
this.communitySettlementRatio = community
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
@@ -28,14 +27,4 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
|
||||
@PostMapping("/update")
|
||||
fun updateCreatorSettlementRatio(
|
||||
@RequestBody request: CreateCreatorSettlementRatioRequest
|
||||
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
|
||||
|
||||
@PostMapping("/delete/{memberId}")
|
||||
fun deleteCreatorSettlementRatio(
|
||||
@PathVariable memberId: Long
|
||||
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CreatorSettlementRatioRepository :
|
||||
JpaRepository<CreatorSettlementRatio, Long>,
|
||||
CreatorSettlementRatioQueryRepository {
|
||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
||||
}
|
||||
CreatorSettlementRatioQueryRepository
|
||||
|
||||
interface CreatorSettlementRatioQueryRepository {
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCreatorSettlementRatioItem(
|
||||
member.id,
|
||||
member.nickname,
|
||||
creatorSettlementRatio.subsidy,
|
||||
creatorSettlementRatio.liveSettlementRatio,
|
||||
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
)
|
||||
.from(creatorSettlementRatio)
|
||||
.innerJoin(creatorSettlementRatio.member, member)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.orderBy(creatorSettlementRatio.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(creatorSettlementRatio.id)
|
||||
.from(creatorSettlementRatio)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class CreatorSettlementRatioService(
|
||||
) {
|
||||
@Transactional
|
||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||
val creatorSettlementRatio = request.toEntity()
|
||||
|
||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||
|
||||
@@ -21,52 +23,10 @@ class CreatorSettlementRatioService(
|
||||
throw SodaException("잘못된 크리에이터 입니다.")
|
||||
}
|
||||
|
||||
val existing = repository.findByMemberId(request.memberId)
|
||||
if (existing != null) {
|
||||
// revive if soft-deleted, then update values
|
||||
existing.restore()
|
||||
existing.updateValues(
|
||||
request.subsidy,
|
||||
request.liveSettlementRatio,
|
||||
request.contentSettlementRatio,
|
||||
request.communitySettlementRatio
|
||||
)
|
||||
repository.save(existing)
|
||||
return
|
||||
}
|
||||
|
||||
val creatorSettlementRatio = request.toEntity()
|
||||
creatorSettlementRatio.member = creator
|
||||
repository.save(creatorSettlementRatio)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||
if (creator.role != MemberRole.CREATOR) {
|
||||
throw SodaException("잘못된 크리에이터 입니다.")
|
||||
}
|
||||
val existing = repository.findByMemberId(request.memberId)
|
||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
||||
existing.restore()
|
||||
existing.updateValues(
|
||||
request.subsidy,
|
||||
request.liveSettlementRatio,
|
||||
request.contentSettlementRatio,
|
||||
request.communitySettlementRatio
|
||||
)
|
||||
repository.save(existing)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteCreatorSettlementRatio(memberId: Long) {
|
||||
val existing = repository.findByMemberId(memberId)
|
||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
||||
existing.softDelete()
|
||||
repository.save(existing)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||
|
||||
@@ -8,7 +8,6 @@ data class GetCreatorSettlementRatioResponse(
|
||||
)
|
||||
|
||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
|
||||
@@ -1,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())
|
||||
|
||||
@@ -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,423 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/character")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminChatCharacterController(
|
||||
private val service: ChatCharacterService,
|
||||
private val adminService: AdminChatCharacterService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val originalWorkService: AdminOriginalWorkService,
|
||||
|
||||
@Value("\${weraser.api-key}")
|
||||
private val apiKey: String,
|
||||
|
||||
@Value("\${weraser.api-url}")
|
||||
private val apiUrl: String,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val s3Bucket: String,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
/**
|
||||
* 활성화된 캐릭터 목록 조회 API
|
||||
*
|
||||
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||
* @param size 페이지 크기 (기본값 20)
|
||||
* @return 페이징된 캐릭터 목록
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
fun getCharacterList(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = adminService.createDefaultPageRequest(page, size)
|
||||
val response = adminService.getActiveChatCharacters(pageable, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 검색(관리자)
|
||||
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
|
||||
* - 페이징 지원: page, size 파라미터 사용
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
fun searchCharacters(
|
||||
@RequestParam("searchTerm") searchTerm: String,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = adminService.createDefaultPageRequest(page, size)
|
||||
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
|
||||
val response = ChatCharacterSearchListPageResponse(
|
||||
totalCount = resultPage.totalElements,
|
||||
content = resultPage.content
|
||||
)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 상세 정보 조회 API
|
||||
*
|
||||
* @param characterId 캐릭터 ID
|
||||
* @return 캐릭터 상세 정보
|
||||
*/
|
||||
@GetMapping("/{characterId}")
|
||||
fun getCharacterDetail(
|
||||
@PathVariable characterId: Long
|
||||
) = run {
|
||||
val response = adminService.getChatCharacterDetail(characterId, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
fun registerCharacter(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
// JSON 문자열을 ChatCharacterRegisterRequest 객체로 변환
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, ChatCharacterRegisterRequest::class.java)
|
||||
|
||||
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
|
||||
val existingCharacter = service.findByName(request.name)
|
||||
if (existingCharacter != null) {
|
||||
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
||||
}
|
||||
|
||||
// 1. 외부 API 호출
|
||||
val characterUUID = callExternalApi(request)
|
||||
|
||||
// 2. ChatCharacter 저장
|
||||
val chatCharacter = service.createChatCharacterWithDetails(
|
||||
characterUUID = characterUUID,
|
||||
name = request.name,
|
||||
description = request.description,
|
||||
systemPrompt = request.systemPrompt,
|
||||
age = request.age?.toIntOrNull(),
|
||||
gender = request.gender,
|
||||
mbti = request.mbti,
|
||||
speechPattern = request.speechPattern,
|
||||
speechStyle = request.speechStyle,
|
||||
appearance = request.appearance,
|
||||
originalTitle = request.originalTitle,
|
||||
originalLink = request.originalLink,
|
||||
characterType = request.characterType?.let {
|
||||
runCatching { CharacterType.valueOf(it) }
|
||||
.getOrDefault(CharacterType.Character)
|
||||
} ?: CharacterType.Character,
|
||||
tags = request.tags,
|
||||
values = request.values,
|
||||
hobbies = request.hobbies,
|
||||
goals = request.goals,
|
||||
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
|
||||
personalities = request.personalities.map { Pair(it.trait, it.description) },
|
||||
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
|
||||
relationships = request.relationships
|
||||
)
|
||||
|
||||
// 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정
|
||||
val imagePath = saveImage(
|
||||
characterId = chatCharacter.id!!,
|
||||
image = image
|
||||
)
|
||||
chatCharacter.imagePath = imagePath
|
||||
service.saveChatCharacter(chatCharacter)
|
||||
|
||||
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||
if (request.originalWorkId != null) {
|
||||
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
private fun callExternalApi(request: ChatCharacterRegisterRequest): String {
|
||||
try {
|
||||
val factory = SimpleClientHttpRequestFactory()
|
||||
factory.setConnectTimeout(20000) // 20초
|
||||
factory.setReadTimeout(20000) // 20초
|
||||
|
||||
val restTemplate = RestTemplate(factory)
|
||||
|
||||
val headers = HttpHeaders()
|
||||
headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
|
||||
// 외부 API에 전달하지 않을 필드(originalTitle, originalLink, characterType)를 제외하고 바디 구성
|
||||
val body = mutableMapOf<String, Any>()
|
||||
body["name"] = request.name
|
||||
body["systemPrompt"] = request.systemPrompt
|
||||
body["description"] = request.description
|
||||
request.age?.let { body["age"] = it }
|
||||
request.gender?.let { body["gender"] = it }
|
||||
request.mbti?.let { body["mbti"] = it }
|
||||
request.speechPattern?.let { body["speechPattern"] = it }
|
||||
request.speechStyle?.let { body["speechStyle"] = it }
|
||||
request.appearance?.let { body["appearance"] = it }
|
||||
if (request.tags.isNotEmpty()) body["tags"] = request.tags
|
||||
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
|
||||
if (request.values.isNotEmpty()) body["values"] = request.values
|
||||
if (request.goals.isNotEmpty()) body["goals"] = request.goals
|
||||
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
|
||||
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
|
||||
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
|
||||
if (request.memories.isNotEmpty()) body["memories"] = request.memories
|
||||
|
||||
val httpEntity = HttpEntity(body, headers)
|
||||
|
||||
val response = restTemplate.exchange(
|
||||
"$apiUrl/api/characters",
|
||||
HttpMethod.POST,
|
||||
httpEntity,
|
||||
String::class.java
|
||||
)
|
||||
|
||||
// 응답 파싱
|
||||
val objectMapper = ObjectMapper()
|
||||
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
||||
|
||||
// success가 false이면 throw
|
||||
if (!apiResponse.success) {
|
||||
throw SodaException(apiResponse.message ?: "등록에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
|
||||
// success가 true이면 data.id 반환
|
||||
return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.")
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveImage(characterId: Long, image: MultipartFile): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
|
||||
// S3에 이미지 업로드
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = s3Bucket,
|
||||
filePath = "characters/$characterId/${generateFileName(prefix = "character")}",
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 수정 API
|
||||
* 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
||||
* 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
||||
* 3. 이미지 있는지 확인
|
||||
* 4. 2, 3번 중 하나라도 해당 하면 계속 진행
|
||||
* 5. 2, 3번에 데이터 없으면 throw SodaException('변경된 데이터가 없습니다.')
|
||||
*
|
||||
* @param image 캐릭터 이미지 (선택적)
|
||||
* @param requestString ChatCharacterUpdateRequest 객체를 JSON 문자열로 변환한 값
|
||||
* @return ApiResponse 객체
|
||||
* @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
fun updateCharacter(
|
||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
// 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java)
|
||||
|
||||
// 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
||||
val hasChangedData = hasChanges(request) // 외부 API 대상으로의 변경 여부(3가지 필드 제외)
|
||||
|
||||
// 3. 이미지 있는지 확인
|
||||
val hasImage = image != null && !image.isEmpty
|
||||
|
||||
// 3가지만 변경된 경우(외부 API 변경은 없지만 DB 변경은 있는 경우)를 허용하기 위해 별도 플래그 계산
|
||||
val hasDbOnlyChanges =
|
||||
request.originalTitle != null ||
|
||||
request.originalLink != null ||
|
||||
request.characterType != null ||
|
||||
request.originalWorkId != null
|
||||
|
||||
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
||||
throw SodaException("변경된 데이터가 없습니다.")
|
||||
}
|
||||
|
||||
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
|
||||
if (hasChangedData) {
|
||||
val chatCharacter = service.findById(request.id)
|
||||
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")
|
||||
|
||||
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
|
||||
if (request.name != null && request.name != chatCharacter.name) {
|
||||
val existingCharacter = service.findByName(request.name)
|
||||
if (existingCharacter != null) {
|
||||
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
||||
}
|
||||
}
|
||||
|
||||
callExternalApiForUpdate(chatCharacter.characterUUID, request)
|
||||
}
|
||||
|
||||
// 이미지 경로 변수 초기화
|
||||
// 이미지가 있으면 이미지 저장
|
||||
val imagePath = if (hasImage) {
|
||||
saveImage(
|
||||
characterId = request.id,
|
||||
image = image!!
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 엔티티 수정
|
||||
service.updateChatCharacterWithDetails(
|
||||
imagePath = imagePath,
|
||||
request = request
|
||||
)
|
||||
|
||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||
if (request.originalWorkId != null) {
|
||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청에 변경된 데이터가 있는지 확인
|
||||
* id를 제외한 모든 필드가 null이면 변경된 데이터가 없는 것으로 판단
|
||||
*
|
||||
* @param request 수정 요청 데이터
|
||||
* @return 변경된 데이터가 있으면 true, 없으면 false
|
||||
*/
|
||||
private fun hasChanges(request: ChatCharacterUpdateRequest): Boolean {
|
||||
return request.systemPrompt != null ||
|
||||
request.description != null ||
|
||||
request.age != null ||
|
||||
request.gender != null ||
|
||||
request.mbti != null ||
|
||||
request.speechPattern != null ||
|
||||
request.speechStyle != null ||
|
||||
request.appearance != null ||
|
||||
request.isActive != null ||
|
||||
request.tags != null ||
|
||||
request.hobbies != null ||
|
||||
request.values != null ||
|
||||
request.goals != null ||
|
||||
request.relationships != null ||
|
||||
request.personalities != null ||
|
||||
request.backgrounds != null ||
|
||||
request.memories != null ||
|
||||
request.name != null
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 호출 - 수정 API
|
||||
* 변경된 데이터만 요청에 포함
|
||||
*
|
||||
* @param characterUUID 캐릭터 UUID
|
||||
* @param request 수정 요청 데이터
|
||||
*/
|
||||
private fun callExternalApiForUpdate(characterUUID: String, request: ChatCharacterUpdateRequest) {
|
||||
try {
|
||||
val factory = SimpleClientHttpRequestFactory()
|
||||
factory.setConnectTimeout(20000) // 20초
|
||||
factory.setReadTimeout(20000) // 20초
|
||||
|
||||
val restTemplate = RestTemplate(factory)
|
||||
|
||||
val headers = HttpHeaders()
|
||||
headers.set("x-api-key", apiKey)
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
|
||||
// 변경된 데이터만 포함하는 맵 생성
|
||||
val updateData = mutableMapOf<String, Any>()
|
||||
|
||||
// isActive = false인 경우 처리
|
||||
if (request.isActive != null && !request.isActive) {
|
||||
val inactiveName = "inactive_${request.name}"
|
||||
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
|
||||
updateData["name"] = inactiveName + randomSuffix
|
||||
} else {
|
||||
request.name?.let { updateData["name"] = it }
|
||||
request.systemPrompt?.let { updateData["systemPrompt"] = it }
|
||||
request.description?.let { updateData["description"] = it }
|
||||
request.age?.let { updateData["age"] = it }
|
||||
request.gender?.let { updateData["gender"] = it }
|
||||
request.mbti?.let { updateData["mbti"] = it }
|
||||
request.speechPattern?.let { updateData["speechPattern"] = it }
|
||||
request.speechStyle?.let { updateData["speechStyle"] = it }
|
||||
request.appearance?.let { updateData["appearance"] = it }
|
||||
request.tags?.let { updateData["tags"] = it }
|
||||
request.hobbies?.let { updateData["hobbies"] = it }
|
||||
request.values?.let { updateData["values"] = it }
|
||||
request.goals?.let { updateData["goals"] = it }
|
||||
request.relationships?.let { updateData["relationships"] = it }
|
||||
request.personalities?.let { updateData["personalities"] = it }
|
||||
request.backgrounds?.let { updateData["backgrounds"] = it }
|
||||
request.memories?.let { updateData["memories"] = it }
|
||||
}
|
||||
|
||||
val httpEntity = HttpEntity(updateData, headers)
|
||||
val response = restTemplate.exchange(
|
||||
"$apiUrl/api/characters/$characterUUID",
|
||||
HttpMethod.PUT,
|
||||
httpEntity,
|
||||
String::class.java
|
||||
)
|
||||
|
||||
// 응답 파싱
|
||||
val objectMapper = ObjectMapper()
|
||||
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
||||
|
||||
// success가 false이면 throw
|
||||
if (!apiResponse.success) {
|
||||
throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/character/curation")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class CharacterCurationAdminController(
|
||||
private val service: CharacterCurationAdminService,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
@GetMapping("/list")
|
||||
fun listAll(): ApiResponse<List<CharacterCurationListItemResponse>> =
|
||||
ApiResponse.ok(service.listAll())
|
||||
|
||||
@GetMapping("/{curationId}/characters")
|
||||
fun listCharacters(
|
||||
@PathVariable curationId: Long
|
||||
): ApiResponse<List<CharacterCurationCharacterItemResponse>> {
|
||||
val characters = service.listCharacters(curationId)
|
||||
val items = characters.map {
|
||||
CharacterCurationCharacterItemResponse(
|
||||
id = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
return ApiResponse.ok(items)
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
fun register(@RequestBody request: CharacterCurationRegisterRequest) =
|
||||
ApiResponse.ok(service.register(request).id)
|
||||
|
||||
@PutMapping("/update")
|
||||
fun update(@RequestBody request: CharacterCurationUpdateRequest) =
|
||||
ApiResponse.ok(service.update(request).id)
|
||||
|
||||
@DeleteMapping("/{curationId}")
|
||||
fun delete(@PathVariable curationId: Long) =
|
||||
ApiResponse.ok(service.softDelete(curationId))
|
||||
|
||||
@PutMapping("/reorder")
|
||||
fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) =
|
||||
ApiResponse.ok(service.reorder(request.ids))
|
||||
|
||||
@PostMapping("/{curationId}/characters")
|
||||
fun addCharacter(
|
||||
@PathVariable curationId: Long,
|
||||
@RequestBody request: CharacterCurationAddCharacterRequest
|
||||
): ApiResponse<Boolean> {
|
||||
val ids = request.characterIds.filter { it > 0 }.distinct()
|
||||
if (ids.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
||||
service.addCharacters(curationId, ids)
|
||||
return ApiResponse.ok(true)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{curationId}/characters/{characterId}")
|
||||
fun removeCharacter(
|
||||
@PathVariable curationId: Long,
|
||||
@PathVariable characterId: Long
|
||||
) = ApiResponse.ok(service.removeCharacter(curationId, characterId))
|
||||
|
||||
@PutMapping("/{curationId}/characters/reorder")
|
||||
fun reorderCharacters(
|
||||
@PathVariable curationId: Long,
|
||||
@RequestBody request: CharacterCurationReorderCharactersRequest
|
||||
) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds))
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||
|
||||
data class CharacterCurationRegisterRequest(
|
||||
val title: String,
|
||||
val isAdult: Boolean = false,
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
|
||||
data class CharacterCurationUpdateRequest(
|
||||
val id: Long,
|
||||
val title: String? = null,
|
||||
val isAdult: Boolean? = null,
|
||||
val isActive: Boolean? = null
|
||||
)
|
||||
|
||||
data class CharacterCurationOrderUpdateRequest(
|
||||
val ids: List<Long>
|
||||
)
|
||||
|
||||
data class CharacterCurationAddCharacterRequest(
|
||||
val characterIds: List<Long>
|
||||
)
|
||||
|
||||
data class CharacterCurationReorderCharactersRequest(
|
||||
val characterIds: List<Long>
|
||||
)
|
||||
|
||||
data class CharacterCurationListItemResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val isAdult: Boolean,
|
||||
val isActive: Boolean,
|
||||
val characterCount: Int
|
||||
)
|
||||
|
||||
// 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO
|
||||
// id, name, description, 이미지 URL
|
||||
// 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성
|
||||
|
||||
data class CharacterCurationCharacterItemResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val imageUrl: String
|
||||
)
|
||||
@@ -1,153 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class CharacterCurationAdminService(
|
||||
private val curationRepository: CharacterCurationRepository,
|
||||
private val mappingRepository: CharacterCurationMappingRepository,
|
||||
private val characterRepository: ChatCharacterRepository
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun register(request: CharacterCurationRegisterRequest): CharacterCuration {
|
||||
val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1
|
||||
val curation = CharacterCuration(
|
||||
title = request.title,
|
||||
isAdult = request.isAdult,
|
||||
isActive = request.isActive,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
return curationRepository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
|
||||
val curation = curationRepository.findById(request.id)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") }
|
||||
|
||||
request.title?.let { curation.title = it }
|
||||
request.isAdult?.let { curation.isAdult = it }
|
||||
request.isActive?.let { curation.isActive = it }
|
||||
|
||||
return curationRepository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun softDelete(curationId: Long) {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
curation.isActive = false
|
||||
curationRepository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun reorder(ids: List<Long>) {
|
||||
ids.forEachIndexed { index, id ->
|
||||
val curation = curationRepository.findById(id)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
|
||||
curation.sortOrder = index + 1
|
||||
curationRepository.save(curation)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addCharacters(curationId: Long, characterIds: List<Long>) {
|
||||
if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
||||
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
|
||||
|
||||
val uniqueIds = characterIds.filter { it > 0 }.distinct()
|
||||
if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다")
|
||||
|
||||
// 활성 캐릭터만 조회 (조회 단계에서 검증 포함)
|
||||
val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds)
|
||||
val characterMap = characters.associateBy { it.id!! }
|
||||
|
||||
// 조회 결과에 존재하는 캐릭터만 유효
|
||||
val validIds = uniqueIds.filter { id -> characterMap.containsKey(id) }
|
||||
|
||||
val existingMappings = mappingRepository.findByCuration(curation)
|
||||
val existingCharacterIds = existingMappings.mapNotNull { it.chatCharacter.id }.toSet()
|
||||
var nextOrder = (existingMappings.maxOfOrNull { it.sortOrder } ?: 0) + 1
|
||||
|
||||
val toSave = mutableListOf<CharacterCurationMapping>()
|
||||
validIds.forEach { id ->
|
||||
if (!existingCharacterIds.contains(id)) {
|
||||
val character = characterMap[id] ?: return@forEach
|
||||
toSave += CharacterCurationMapping(
|
||||
curation = curation,
|
||||
chatCharacter = character,
|
||||
sortOrder = nextOrder++
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (toSave.isNotEmpty()) {
|
||||
mappingRepository.saveAll(toSave)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun removeCharacter(curationId: Long, characterId: Long) {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
val mappings = mappingRepository.findByCuration(curation)
|
||||
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
|
||||
?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId")
|
||||
mappingRepository.delete(target)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
val mappings = mappingRepository.findByCuration(curation)
|
||||
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
|
||||
|
||||
characterIds.forEachIndexed { index, cid ->
|
||||
val mapping = mappingByCharacterId[cid]
|
||||
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
|
||||
mapping.sortOrder = index + 1
|
||||
mappingRepository.save(mapping)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listAll(): List<CharacterCurationListItemResponse> {
|
||||
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
|
||||
if (curations.isEmpty()) return emptyList()
|
||||
|
||||
// DB 집계로 활성 캐릭터 수 카운트
|
||||
val counts = mappingRepository.countActiveCharactersByCurations(curations)
|
||||
val countByCurationId: Map<Long, Int> = counts.associate { it.curationId to it.count.toInt() }
|
||||
|
||||
return curations.map { curation ->
|
||||
CharacterCurationListItemResponse(
|
||||
id = curation.id!!,
|
||||
title = curation.title,
|
||||
isAdult = curation.isAdult,
|
||||
isActive = curation.isActive,
|
||||
characterCount = countByCurationId[curation.id!!] ?: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listCharacters(curationId: Long): List<ChatCharacter> {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
|
||||
return mappings.map { it.chatCharacter }
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
|
||||
/**
|
||||
* 관리자 캐릭터 상세 응답 DTO
|
||||
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
|
||||
*/
|
||||
data class ChatCharacterDetailResponse(
|
||||
val id: Long,
|
||||
val characterUUID: String,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val description: String,
|
||||
val systemPrompt: String,
|
||||
val characterType: String,
|
||||
val age: Int?,
|
||||
val gender: String?,
|
||||
val mbti: String?,
|
||||
val speechPattern: String?,
|
||||
val speechStyle: String?,
|
||||
val appearance: String?,
|
||||
val isActive: Boolean,
|
||||
val tags: List<String>,
|
||||
val hobbies: List<String>,
|
||||
val values: List<String>,
|
||||
val goals: List<String>,
|
||||
val relationships: List<RelationshipResponse>,
|
||||
val personalities: List<PersonalityResponse>,
|
||||
val backgrounds: List<BackgroundResponse>,
|
||||
val memories: List<MemoryResponse>,
|
||||
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
|
||||
) {
|
||||
companion object {
|
||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${chatCharacter.imagePath}"
|
||||
} else {
|
||||
chatCharacter.imagePath ?: ""
|
||||
}
|
||||
|
||||
val ow = chatCharacter.originalWork
|
||||
val originalWorkBrief = ow?.let {
|
||||
val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${it.imagePath}"
|
||||
} else {
|
||||
it.imagePath
|
||||
}
|
||||
OriginalWorkBriefResponse(
|
||||
id = it.id!!,
|
||||
imageUrl = owImage,
|
||||
title = it.title
|
||||
)
|
||||
}
|
||||
|
||||
return ChatCharacterDetailResponse(
|
||||
id = chatCharacter.id!!,
|
||||
characterUUID = chatCharacter.characterUUID,
|
||||
name = chatCharacter.name,
|
||||
imageUrl = fullImagePath,
|
||||
description = chatCharacter.description,
|
||||
systemPrompt = chatCharacter.systemPrompt,
|
||||
characterType = chatCharacter.characterType.name,
|
||||
age = chatCharacter.age,
|
||||
gender = chatCharacter.gender,
|
||||
mbti = chatCharacter.mbti,
|
||||
speechPattern = chatCharacter.speechPattern,
|
||||
speechStyle = chatCharacter.speechStyle,
|
||||
appearance = chatCharacter.appearance,
|
||||
isActive = chatCharacter.isActive,
|
||||
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
||||
hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby },
|
||||
values = chatCharacter.valueMappings.map { it.value.value },
|
||||
goals = chatCharacter.goalMappings.map { it.goal.goal },
|
||||
relationships = chatCharacter.relationships.map {
|
||||
RelationshipResponse(
|
||||
personName = it.personName,
|
||||
relationshipName = it.relationshipName,
|
||||
description = it.description,
|
||||
importance = it.importance,
|
||||
relationshipType = it.relationshipType,
|
||||
currentStatus = it.currentStatus
|
||||
)
|
||||
},
|
||||
personalities = chatCharacter.personalities.map {
|
||||
PersonalityResponse(it.trait, it.description)
|
||||
},
|
||||
backgrounds = chatCharacter.backgrounds.map {
|
||||
BackgroundResponse(it.topic, it.description)
|
||||
},
|
||||
memories = chatCharacter.memories.map {
|
||||
MemoryResponse(it.title, it.content, it.emotion)
|
||||
},
|
||||
originalWork = originalWorkBrief
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PersonalityResponse(
|
||||
val trait: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class BackgroundResponse(
|
||||
val topic: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class MemoryResponse(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val emotion: String
|
||||
)
|
||||
|
||||
data class RelationshipResponse(
|
||||
val personName: String,
|
||||
val relationshipName: String,
|
||||
val description: String,
|
||||
val importance: Int,
|
||||
val relationshipType: String,
|
||||
val currentStatus: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
|
||||
*/
|
||||
data class OriginalWorkBriefResponse(
|
||||
val id: Long,
|
||||
val imageUrl: String?,
|
||||
val title: String
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class ChatCharacterPersonalityRequest(
|
||||
@JsonProperty("trait") val trait: String,
|
||||
@JsonProperty("description") val description: String
|
||||
)
|
||||
|
||||
data class ChatCharacterBackgroundRequest(
|
||||
@JsonProperty("topic") val topic: String,
|
||||
@JsonProperty("description") val description: String
|
||||
)
|
||||
|
||||
data class ChatCharacterMemoryRequest(
|
||||
@JsonProperty("title") val title: String,
|
||||
@JsonProperty("content") val content: String,
|
||||
@JsonProperty("emotion") val emotion: String
|
||||
)
|
||||
|
||||
data class ChatCharacterRelationshipRequest(
|
||||
@JsonProperty("personName") val personName: String,
|
||||
@JsonProperty("relationshipName") val relationshipName: String,
|
||||
@JsonProperty("description") val description: String,
|
||||
@JsonProperty("importance") val importance: Int,
|
||||
@JsonProperty("relationshipType") val relationshipType: String,
|
||||
@JsonProperty("currentStatus") val currentStatus: String
|
||||
)
|
||||
|
||||
data class ChatCharacterRegisterRequest(
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("systemPrompt") val systemPrompt: String,
|
||||
@JsonProperty("description") val description: String,
|
||||
@JsonProperty("age") val age: String?,
|
||||
@JsonProperty("gender") val gender: String?,
|
||||
@JsonProperty("mbti") val mbti: String?,
|
||||
@JsonProperty("speechPattern") val speechPattern: String?,
|
||||
@JsonProperty("speechStyle") val speechStyle: String?,
|
||||
@JsonProperty("appearance") val appearance: String?,
|
||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||
@JsonProperty("characterType") val characterType: String? = null,
|
||||
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
||||
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
||||
@JsonProperty("values") val values: List<String> = emptyList(),
|
||||
@JsonProperty("goals") val goals: List<String> = emptyList(),
|
||||
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest> = emptyList(),
|
||||
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest> = emptyList(),
|
||||
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest> = emptyList(),
|
||||
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest> = emptyList()
|
||||
)
|
||||
|
||||
data class ExternalApiResponse(
|
||||
@JsonProperty("success") val success: Boolean,
|
||||
@JsonProperty("data") val data: ExternalApiData? = null,
|
||||
@JsonProperty("message") val message: String? = null
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ExternalApiData(
|
||||
@JsonProperty("id") val id: String
|
||||
)
|
||||
|
||||
data class ChatCharacterUpdateRequest(
|
||||
@JsonProperty("id") val id: Long,
|
||||
@JsonProperty("name") val name: String? = null,
|
||||
@JsonProperty("systemPrompt") val systemPrompt: String? = null,
|
||||
@JsonProperty("description") val description: String? = null,
|
||||
@JsonProperty("age") val age: String? = null,
|
||||
@JsonProperty("gender") val gender: String? = null,
|
||||
@JsonProperty("mbti") val mbti: String? = null,
|
||||
@JsonProperty("speechPattern") val speechPattern: String? = null,
|
||||
@JsonProperty("speechStyle") val speechStyle: String? = null,
|
||||
@JsonProperty("appearance") val appearance: String? = null,
|
||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||
@JsonProperty("characterType") val characterType: String? = null,
|
||||
@JsonProperty("isActive") val isActive: Boolean? = null,
|
||||
@JsonProperty("tags") val tags: List<String>? = null,
|
||||
@JsonProperty("hobbies") val hobbies: List<String>? = null,
|
||||
@JsonProperty("values") val values: List<String>? = null,
|
||||
@JsonProperty("goals") val goals: List<String>? = null,
|
||||
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest>? = null,
|
||||
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest>? = null,
|
||||
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
|
||||
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest>? = null
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
data class ChatCharacterListResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val description: String,
|
||||
val gender: String?,
|
||||
val age: Int?,
|
||||
val mbti: String?,
|
||||
val speechStyle: String?,
|
||||
val speechPattern: String?,
|
||||
val tags: List<String>,
|
||||
val createdAt: String?,
|
||||
val updatedAt: String?
|
||||
) {
|
||||
companion object {
|
||||
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
private val seoulZoneId = ZoneId.of("Asia/Seoul")
|
||||
|
||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterListResponse {
|
||||
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${chatCharacter.imagePath}"
|
||||
} else {
|
||||
chatCharacter.imagePath
|
||||
}
|
||||
|
||||
// UTC에서 Asia/Seoul로 시간대 변환 및 문자열 포맷팅
|
||||
val createdAtStr = chatCharacter.createdAt?.atZone(ZoneId.of("UTC"))
|
||||
?.withZoneSameInstant(seoulZoneId)
|
||||
?.format(formatter)
|
||||
|
||||
val updatedAtStr = chatCharacter.updatedAt?.atZone(ZoneId.of("UTC"))
|
||||
?.withZoneSameInstant(seoulZoneId)
|
||||
?.format(formatter)
|
||||
|
||||
return ChatCharacterListResponse(
|
||||
id = chatCharacter.id!!,
|
||||
name = chatCharacter.name,
|
||||
imageUrl = fullImagePath,
|
||||
description = chatCharacter.description,
|
||||
gender = chatCharacter.gender,
|
||||
age = chatCharacter.age,
|
||||
mbti = chatCharacter.mbti,
|
||||
speechStyle = chatCharacter.speechStyle,
|
||||
speechPattern = chatCharacter.speechPattern,
|
||||
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
||||
createdAt = createdAtStr,
|
||||
updatedAt = updatedAtStr
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChatCharacterListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<ChatCharacterListResponse>
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
/**
|
||||
* 캐릭터 검색 결과 페이지 응답 DTO
|
||||
*/
|
||||
data class ChatCharacterSearchListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<ChatCharacterListResponse>
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
|
||||
/**
|
||||
* 원작 연결된 캐릭터 결과 응답 DTO
|
||||
*/
|
||||
data class OriginalWorkChatCharacterResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val imagePath: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
|
||||
return OriginalWorkChatCharacterResponse(
|
||||
id = character.id!!,
|
||||
name = character.name,
|
||||
imagePath = character.imagePath?.let { "$imageHost/$it" }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 연결된 캐릭터 결과 페이지 응답 DTO
|
||||
*/
|
||||
data class OriginalWorkChatCharacterListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<OriginalWorkChatCharacterResponse>
|
||||
)
|
||||
@@ -1,170 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.image
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.utils.ImageBlurUtil
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/character/image")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminCharacterImageController(
|
||||
private val imageService: CharacterImageService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val imageCloudFront: ImageContentCloudFront,
|
||||
|
||||
@Value("\${cloud.aws.s3.content-bucket}")
|
||||
private val s3Bucket: String,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val freeBucket: String
|
||||
) {
|
||||
|
||||
@GetMapping("/list")
|
||||
fun list(@RequestParam characterId: Long) = run {
|
||||
val expiration = 5L * 60L * 1000L // 5분
|
||||
val list = imageService.listActiveByCharacter(characterId)
|
||||
.map { img ->
|
||||
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||
AdminCharacterImageResponse.fromWithUrl(img, signedUrl)
|
||||
}
|
||||
ApiResponse.ok(list)
|
||||
}
|
||||
|
||||
@GetMapping("/{imageId}")
|
||||
fun detail(@PathVariable imageId: Long) = run {
|
||||
val img = imageService.getById(imageId)
|
||||
val expiration = 5L * 60L * 1000L // 5분
|
||||
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||
ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl))
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
fun register(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java)
|
||||
|
||||
// 업로드 키 생성
|
||||
val s3Key = buildS3Key(characterId = request.characterId)
|
||||
|
||||
// 원본 저장 (content-bucket)
|
||||
val imagePath = saveImageToBucket(s3Key, image, s3Bucket)
|
||||
|
||||
// 블러 생성 및 저장 (무료 이미지 버킷)
|
||||
val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket)
|
||||
|
||||
imageService.registerImage(
|
||||
characterId = request.characterId,
|
||||
imagePath = imagePath,
|
||||
blurImagePath = blurImagePath,
|
||||
imagePriceCan = request.imagePriceCan,
|
||||
messagePriceCan = request.messagePriceCan,
|
||||
isAdult = request.isAdult,
|
||||
triggers = request.triggers ?: emptyList()
|
||||
)
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@PutMapping("/{imageId}/triggers")
|
||||
fun updateTriggers(
|
||||
@PathVariable imageId: Long,
|
||||
@RequestBody request: UpdateCharacterImageTriggersRequest
|
||||
) = run {
|
||||
if (!request.triggers.isNullOrEmpty()) {
|
||||
imageService.updateTriggers(imageId, request.triggers)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{imageId}")
|
||||
fun delete(@PathVariable imageId: Long) = run {
|
||||
imageService.deleteImage(imageId)
|
||||
ApiResponse.ok(null, "이미지가 삭제되었습니다.")
|
||||
}
|
||||
|
||||
@PutMapping("/orders")
|
||||
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
|
||||
if (request.characterId == null) throw SodaException("characterId는 필수입니다")
|
||||
imageService.updateOrders(request.characterId, request.ids)
|
||||
ApiResponse.ok(null, "정렬 순서가 변경되었습니다.")
|
||||
}
|
||||
|
||||
private fun buildS3Key(characterId: Long): String {
|
||||
val fileName = generateFileName("character-image")
|
||||
return "characters/$characterId/images/$fileName"
|
||||
}
|
||||
|
||||
private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = bucket,
|
||||
filePath = filePath,
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||
try {
|
||||
// 멀티파트를 BufferedImage로 읽기
|
||||
val bytes = image.bytes
|
||||
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
||||
?: throw SodaException("이미지 포맷을 인식할 수 없습니다.")
|
||||
val blurred = ImageBlurUtil.blurFast(bimg)
|
||||
|
||||
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
||||
val baos = java.io.ByteArrayOutputStream()
|
||||
val format = when (image.contentType?.lowercase()) {
|
||||
"image/png" -> "png"
|
||||
else -> "jpg"
|
||||
}
|
||||
javax.imageio.ImageIO.write(blurred, format, baos)
|
||||
val inputStream = java.io.ByteArrayInputStream(baos.toByteArray())
|
||||
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = baos.size().toLong()
|
||||
metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg"
|
||||
|
||||
return s3Uploader.upload(
|
||||
inputStream = inputStream,
|
||||
bucket = bucket,
|
||||
filePath = filePath,
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.image.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||
|
||||
// 요청 DTOs
|
||||
|
||||
data class RegisterCharacterImageRequest(
|
||||
@JsonProperty("characterId") val characterId: Long,
|
||||
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
|
||||
@JsonProperty("messagePriceCan") val messagePriceCan: Long,
|
||||
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
||||
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||
)
|
||||
|
||||
data class UpdateCharacterImageTriggersRequest(
|
||||
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||
)
|
||||
|
||||
data class UpdateCharacterImageOrdersRequest(
|
||||
@JsonProperty("characterId") val characterId: Long?,
|
||||
@JsonProperty("ids") val ids: List<Long>
|
||||
)
|
||||
|
||||
// 응답 DTOs
|
||||
|
||||
data class AdminCharacterImageResponse(
|
||||
val id: Long,
|
||||
val characterId: Long,
|
||||
val imagePriceCan: Long,
|
||||
val messagePriceCan: Long,
|
||||
val imageUrl: String,
|
||||
val triggers: List<String>,
|
||||
val isAdult: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse {
|
||||
return base(entity, signedUrl)
|
||||
}
|
||||
|
||||
private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse {
|
||||
return AdminCharacterImageResponse(
|
||||
id = entity.id!!,
|
||||
characterId = entity.chatCharacter.id!!,
|
||||
imagePriceCan = entity.imagePriceCan,
|
||||
messagePriceCan = entity.messagePriceCan,
|
||||
imageUrl = url,
|
||||
triggers = entity.triggerMappings.map { it.tag.word },
|
||||
isAdult = entity.isAdult
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.service
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AdminChatCharacterService(
|
||||
private val chatCharacterRepository: ChatCharacterRepository
|
||||
) {
|
||||
/**
|
||||
* 활성화된 캐릭터 목록을 페이징하여 조회
|
||||
*
|
||||
* @param pageable 페이징 정보
|
||||
* @return 페이징된 캐릭터 목록
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse {
|
||||
// isActive가 true인 캐릭터만 조회
|
||||
val page = chatCharacterRepository.findByIsActiveTrue(pageable)
|
||||
|
||||
// 페이지 정보 생성
|
||||
val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) }
|
||||
|
||||
return ChatCharacterListPageResponse(
|
||||
totalCount = page.totalElements,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 페이지 요청 생성
|
||||
*
|
||||
* @param page 페이지 번호 (0부터 시작)
|
||||
* @param size 페이지 크기
|
||||
* @return 페이지 요청 객체
|
||||
*/
|
||||
fun createDefaultPageRequest(page: Int = 0, size: Int = 20): PageRequest {
|
||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 상세 정보 조회
|
||||
*
|
||||
* @param characterId 캐릭터 ID
|
||||
* @param imageHost 이미지 호스트 URL
|
||||
* @return 캐릭터 상세 정보
|
||||
* @throws SodaException 캐릭터를 찾을 수 없는 경우
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||
val chatCharacter = chatCharacterRepository.findById(characterId)
|
||||
.orElseThrow { SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") }
|
||||
|
||||
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun searchCharacters(
|
||||
searchTerm: String,
|
||||
pageable: Pageable,
|
||||
imageHost: String = ""
|
||||
): Page<ChatCharacterListResponse> {
|
||||
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
|
||||
return characters.map { ChatCharacterListResponse.from(it, imageHost) }
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 등록 요청 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerRegisterRequest(
|
||||
// 캐릭터 ID
|
||||
@JsonProperty("characterId") val characterId: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 수정 요청 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerUpdateRequest(
|
||||
// 배너 ID
|
||||
@JsonProperty("bannerId") val bannerId: Long,
|
||||
|
||||
// 캐릭터 ID (변경할 캐릭터)
|
||||
@JsonProperty("characterId") val characterId: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 정렬 순서 일괄 변경 요청 DTO
|
||||
*/
|
||||
data class UpdateBannerOrdersRequest(
|
||||
// 배너 ID 목록 (순서대로 정렬됨)
|
||||
@JsonProperty("ids") val ids: List<Long>
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 응답 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerResponse(
|
||||
val id: Long,
|
||||
val imagePath: String,
|
||||
val characterId: Long,
|
||||
val characterName: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse {
|
||||
return ChatCharacterBannerResponse(
|
||||
id = banner.id!!,
|
||||
imagePath = "$imageHost/${banner.imagePath}",
|
||||
characterId = banner.chatCharacter.id!!,
|
||||
characterName = banner.chatCharacter.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 목록 페이지 응답 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<ChatCharacterBannerResponse>
|
||||
)
|
||||
@@ -1,199 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.original
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
/**
|
||||
* 원작(오리지널 작품) 관리자 API
|
||||
* - 원작 등록/수정/삭제
|
||||
* - 원작과 캐릭터 연결(배정) 및 해제
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/original")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminOriginalWorkController(
|
||||
private val originalWorkService: AdminOriginalWorkService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val s3Bucket: String,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
|
||||
/**
|
||||
* 원작 등록
|
||||
* - 이미지 파일과 JSON 요청을 멀티파트로 받는다.
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
fun register(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java)
|
||||
|
||||
// 서비스 계층을 통해 원작을 생성
|
||||
val saved = originalWorkService.createOriginalWork(request)
|
||||
|
||||
// 이미지 업로드 후 이미지 경로 업데이트
|
||||
val imagePath = uploadImage(saved.id!!, image)
|
||||
originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath)
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 수정
|
||||
* - 이미지가 있으면 교체, 없으면 유지
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
fun update(
|
||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java)
|
||||
|
||||
// 이미지가 전달된 경우 먼저 업로드하여 경로를 생성
|
||||
val imagePath = if (image != null && !image.isEmpty) {
|
||||
uploadImage(request.id, image)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
originalWorkService.updateOriginalWork(request, imagePath)
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 삭제
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
fun delete(@PathVariable id: Long) = run {
|
||||
originalWorkService.deleteOriginalWork(id)
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 목록(페이징)
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
fun list(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageRes = originalWorkService.getOriginalWorkPage(page, size)
|
||||
val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) }
|
||||
ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content))
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 검색(관리자)
|
||||
* - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외
|
||||
* - 페이징 제거: 전체 목록 반환
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
fun search(
|
||||
@RequestParam("searchTerm") searchTerm: String
|
||||
) = run {
|
||||
val list = originalWorkService.searchOriginalWorksAll(searchTerm)
|
||||
val content = list.map { OriginalWorkResponse.from(it, imageHost) }
|
||||
ApiResponse.ok(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 상세
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
fun detail(@PathVariable id: Long) = run {
|
||||
ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost))
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작에 기존 캐릭터들을 배정
|
||||
* - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 이 원작으로 설정
|
||||
*/
|
||||
@PostMapping("/{id}/assign-characters")
|
||||
fun assignCharacters(
|
||||
@PathVariable id: Long,
|
||||
@RequestBody body: OriginalWorkAssignCharactersRequest
|
||||
) = run {
|
||||
originalWorkService.assignCharacters(id, body.characterIds)
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작에서 캐릭터들 해제
|
||||
* - 캐릭터들의 originalWork를 null로 설정
|
||||
*/
|
||||
@PostMapping("/{id}/unassign-characters")
|
||||
fun unassignCharacters(
|
||||
@PathVariable id: Long,
|
||||
@RequestBody body: OriginalWorkAssignCharactersRequest
|
||||
) = run {
|
||||
originalWorkService.unassignCharacters(id, body.characterIds)
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회
|
||||
* - 활성 캐릭터만 포함
|
||||
* - 응답 항목: 캐릭터 이미지(URL), 이름
|
||||
*/
|
||||
@GetMapping("/{id}/characters")
|
||||
fun listCharactersOfOriginal(
|
||||
@PathVariable id: Long,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
|
||||
val content = pageRes.content.map { OriginalWorkChatCharacterResponse.from(it, imageHost) }
|
||||
ApiResponse.ok(
|
||||
OriginalWorkChatCharacterListPageResponse(
|
||||
totalCount = pageRes.totalElements,
|
||||
content = content
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** 이미지 업로드 공통 처리 */
|
||||
private fun uploadImage(originalWorkId: Long, image: MultipartFile): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = s3Bucket,
|
||||
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.original.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
|
||||
/**
|
||||
* 원작 등록 요청 DTO
|
||||
*/
|
||||
data class OriginalWorkRegisterRequest(
|
||||
@JsonProperty("title") val title: String,
|
||||
@JsonProperty("contentType") val contentType: String,
|
||||
@JsonProperty("category") val category: String,
|
||||
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
||||
@JsonProperty("description") val description: String = "",
|
||||
@JsonProperty("originalWork") val originalWork: String? = null,
|
||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||
@JsonProperty("writer") val writer: String? = null,
|
||||
@JsonProperty("studio") val studio: String? = null,
|
||||
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
||||
@JsonProperty("tags") val tags: List<String>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 원작 수정 요청 DTO (부분 수정 가능)
|
||||
*/
|
||||
data class OriginalWorkUpdateRequest(
|
||||
@JsonProperty("id") val id: Long,
|
||||
@JsonProperty("title") val title: String? = null,
|
||||
@JsonProperty("contentType") val contentType: String? = null,
|
||||
@JsonProperty("category") val category: String? = null,
|
||||
@JsonProperty("isAdult") val isAdult: Boolean? = null,
|
||||
@JsonProperty("description") val description: String? = null,
|
||||
@JsonProperty("originalWork") val originalWork: String? = null,
|
||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||
@JsonProperty("writer") val writer: String? = null,
|
||||
@JsonProperty("studio") val studio: String? = null,
|
||||
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
||||
@JsonProperty("tags") val tags: List<String>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 원작 상세/목록 응답 DTO
|
||||
*/
|
||||
data class OriginalWorkResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val contentType: String,
|
||||
val category: String,
|
||||
val isAdult: Boolean,
|
||||
val description: String,
|
||||
val originalWork: String?,
|
||||
val originalLink: String?,
|
||||
val writer: String?,
|
||||
val studio: String?,
|
||||
val originalLinks: List<String>,
|
||||
val tags: List<String>,
|
||||
val imageUrl: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkResponse {
|
||||
val fullImagePath = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${entity.imagePath}"
|
||||
} else {
|
||||
entity.imagePath
|
||||
}
|
||||
return OriginalWorkResponse(
|
||||
id = entity.id!!,
|
||||
title = entity.title,
|
||||
contentType = entity.contentType,
|
||||
category = entity.category,
|
||||
isAdult = entity.isAdult,
|
||||
description = entity.description,
|
||||
originalWork = entity.originalWork,
|
||||
originalLink = entity.originalLink,
|
||||
writer = entity.writer,
|
||||
studio = entity.studio,
|
||||
originalLinks = entity.originalLinks.map { it.url },
|
||||
tags = entity.tagMappings.map { it.tag.tag },
|
||||
imageUrl = fullImagePath
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class OriginalWorkPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<OriginalWorkResponse>
|
||||
)
|
||||
|
||||
/**
|
||||
* 원작-캐릭터 연결/해제 요청 DTO
|
||||
*/
|
||||
data class OriginalWorkAssignCharactersRequest(
|
||||
@JsonProperty("characterIds") val characterIds: List<Long>
|
||||
)
|
||||
@@ -1,213 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.original.service
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 원작(오리지널 작품) 관련 관리자 서비스
|
||||
* - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다.
|
||||
*/
|
||||
@Service
|
||||
class AdminOriginalWorkService(
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val originalWorkTagRepository: OriginalWorkTagRepository
|
||||
) {
|
||||
|
||||
/** 원작 등록 (중복 제목 방지 포함) */
|
||||
@Transactional
|
||||
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
|
||||
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
|
||||
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
|
||||
}
|
||||
val entity = OriginalWork(
|
||||
title = request.title,
|
||||
contentType = request.contentType,
|
||||
category = request.category,
|
||||
isAdult = request.isAdult,
|
||||
description = request.description,
|
||||
originalWork = request.originalWork,
|
||||
originalLink = request.originalLink,
|
||||
writer = request.writer,
|
||||
studio = request.studio
|
||||
)
|
||||
// 링크 리스트 생성
|
||||
request.originalLinks?.filter { it.isNotBlank() }?.forEach { link ->
|
||||
entity.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = entity))
|
||||
}
|
||||
// 태그 매핑 생성 (기존 태그 재사용)
|
||||
request.tags?.let { tags ->
|
||||
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
||||
normalized.forEach { t ->
|
||||
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
||||
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
|
||||
}
|
||||
}
|
||||
return originalWorkRepository.save(entity)
|
||||
}
|
||||
|
||||
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
||||
@Transactional
|
||||
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
|
||||
request.title?.let { ow.title = it }
|
||||
request.contentType?.let { ow.contentType = it }
|
||||
request.category?.let { ow.category = it }
|
||||
request.isAdult?.let { ow.isAdult = it }
|
||||
request.description?.let { ow.description = it }
|
||||
request.originalWork?.let { ow.originalWork = it }
|
||||
request.originalLink?.let { ow.originalLink = it }
|
||||
request.writer?.let { ow.writer = it }
|
||||
request.studio?.let { ow.studio = it }
|
||||
// 링크 리스트가 전달되면 기존 것을 교체
|
||||
request.originalLinks?.let { links ->
|
||||
ow.originalLinks.clear()
|
||||
links.filter { it.isNotBlank() }.forEach { link ->
|
||||
ow.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = ow))
|
||||
}
|
||||
}
|
||||
// 태그 변경사항만 반영 (요청이 null이면 변경 없음)
|
||||
request.tags?.let { tags ->
|
||||
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
||||
val current = ow.tagMappings.map { it.tag.tag }.toSet()
|
||||
val toAdd = normalized.minus(current)
|
||||
val toRemove = current.minus(normalized)
|
||||
|
||||
if (toRemove.isNotEmpty()) {
|
||||
val itr = ow.tagMappings.iterator()
|
||||
while (itr.hasNext()) {
|
||||
val m = itr.next()
|
||||
if (toRemove.contains(m.tag.tag)) {
|
||||
itr.remove() // orphanRemoval=true로 매핑 삭제
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toAdd.isNotEmpty()) {
|
||||
toAdd.forEach { t ->
|
||||
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
||||
ow.tagMappings.add(OriginalWorkTagMapping(originalWork = ow, tag = tagEntity))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imagePath != null) {
|
||||
ow.imagePath = imagePath
|
||||
}
|
||||
return originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 이미지 경로만 별도 갱신 */
|
||||
@Transactional
|
||||
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
ow.imagePath = imagePath
|
||||
return originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 삭제 (소프트 삭제) */
|
||||
@Transactional
|
||||
fun deleteOriginalWork(id: Long) {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
|
||||
ow.isDeleted = true
|
||||
originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 상세 조회 (소프트 삭제 제외) */
|
||||
@Transactional(readOnly = true)
|
||||
fun getOriginalWork(id: Long): OriginalWork {
|
||||
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
}
|
||||
|
||||
/** 원작 페이징 조회 */
|
||||
@Transactional(readOnly = true)
|
||||
fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> {
|
||||
val safePage = if (page < 0) 0 else page
|
||||
val safeSize = when {
|
||||
size <= 0 -> 20
|
||||
size > 100 -> 100
|
||||
else -> size
|
||||
}
|
||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||
return originalWorkRepository.findByIsDeletedFalse(pageable)
|
||||
}
|
||||
|
||||
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
|
||||
@Transactional(readOnly = true)
|
||||
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
|
||||
// 원작 존재 및 소프트 삭제 여부 확인
|
||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
|
||||
val safePage = if (page < 0) 0 else page
|
||||
val safeSize = when {
|
||||
size <= 0 -> 20
|
||||
size > 100 -> 100
|
||||
else -> size
|
||||
}
|
||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
|
||||
}
|
||||
|
||||
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
|
||||
@Transactional(readOnly = true)
|
||||
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
|
||||
return originalWorkRepository.searchNoPaging(searchTerm)
|
||||
}
|
||||
|
||||
/** 원작에 기존 캐릭터들을 배정 */
|
||||
@Transactional
|
||||
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
if (characterIds.isEmpty()) return
|
||||
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||
characters.forEach { it.originalWork = ow }
|
||||
chatCharacterRepository.saveAll(characters)
|
||||
}
|
||||
|
||||
/** 원작에서 캐릭터들 해제 */
|
||||
@Transactional
|
||||
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||
// 원작 존재 확인 (소프트 삭제 제외)
|
||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
if (characterIds.isEmpty()) return
|
||||
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||
characters.forEach { it.originalWork = null }
|
||||
chatCharacterRepository.saveAll(characters)
|
||||
}
|
||||
|
||||
/** 단일 캐릭터를 지정 원작에 배정 */
|
||||
@Transactional
|
||||
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
|
||||
val character = chatCharacterRepository.findById(characterId)
|
||||
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
|
||||
|
||||
if (originalWorkId == 0L) {
|
||||
character.originalWork = null
|
||||
} else {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
character.originalWork = ow
|
||||
}
|
||||
|
||||
chatCharacterRepository.save(character)
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,6 @@ class AdminAudioContentQueryRepositoryImpl(
|
||||
audioContent.duration.isNotNull
|
||||
.and(audioContent.member.isNotNull)
|
||||
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
|
||||
.and(audioContentHashTag.isActive.isTrue)
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -2,17 +2,14 @@ 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
|
||||
@@ -22,10 +19,8 @@ data class CreatePointRewardPolicyRequest(
|
||||
|
||||
return PointRewardPolicy(
|
||||
title = title,
|
||||
policyType = policyType,
|
||||
actionType = actionType,
|
||||
threshold = threshold,
|
||||
availableCount = availableCount,
|
||||
pointAmount = pointAmount,
|
||||
startDate = LocalDateTime.parse(startDate, dateTimeFormatter)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -12,10 +11,8 @@ data class GetPointRewardPolicyListResponse(
|
||||
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,
|
||||
|
||||
@@ -33,10 +33,8 @@ class PointPolicyQueryRepositoryImpl(
|
||||
QGetPointRewardPolicyListItem(
|
||||
pointRewardPolicy.id,
|
||||
pointRewardPolicy.title,
|
||||
pointRewardPolicy.policyType,
|
||||
pointRewardPolicy.actionType,
|
||||
pointRewardPolicy.threshold,
|
||||
pointRewardPolicy.availableCount,
|
||||
pointRewardPolicy.pointAmount,
|
||||
getFormattedDate(pointRewardPolicy.startDate),
|
||||
getFormattedDate(pointRewardPolicy.endDate),
|
||||
|
||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||
import com.querydsl.core.types.dsl.CaseBuilder
|
||||
import com.querydsl.core.types.dsl.DateTimePath
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.core.types.dsl.NumberExpression
|
||||
import com.querydsl.core.types.dsl.StringTemplate
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||
@@ -66,7 +67,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
val firstPaymentTotalAmount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(0.toBigDecimal())
|
||||
.otherwise(Expressions.constant(0.0))
|
||||
.sum()
|
||||
|
||||
val repeatPaymentCount = CaseBuilder()
|
||||
@@ -78,7 +79,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
val repeatPaymentTotalAmount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(0.toBigDecimal())
|
||||
.otherwise(Expressions.constant(0.0))
|
||||
.sum()
|
||||
|
||||
val allPaymentCount = CaseBuilder()
|
||||
@@ -96,7 +97,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||
)
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(0.toBigDecimal())
|
||||
.otherwise(Expressions.constant(0.0))
|
||||
.sum()
|
||||
|
||||
return queryFactory
|
||||
@@ -110,11 +111,11 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
loginCount,
|
||||
signUpCount,
|
||||
firstPaymentCount,
|
||||
firstPaymentTotalAmount,
|
||||
roundedValueDecimalPlaces2(firstPaymentTotalAmount),
|
||||
repeatPaymentCount,
|
||||
repeatPaymentTotalAmount,
|
||||
roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
|
||||
allPaymentCount,
|
||||
allPaymentTotalAmount
|
||||
roundedValueDecimalPlaces2(allPaymentTotalAmount)
|
||||
)
|
||||
)
|
||||
.from(adTrackingHistory)
|
||||
@@ -147,4 +148,13 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
}
|
||||
|
||||
private fun roundedValueDecimalPlaces2(valueExpression: NumberExpression<Double>): NumberExpression<Double> {
|
||||
return Expressions.numberTemplate(
|
||||
Double::class.java,
|
||||
"ROUND({0}, {1})",
|
||||
valueExpression,
|
||||
2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetAdminAdStatisticsResponse(
|
||||
val totalCount: Int,
|
||||
@@ -17,9 +16,9 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
|
||||
val loginCount: Int,
|
||||
val signUpCount: Int,
|
||||
val firstPaymentCount: Int,
|
||||
val firstPaymentTotalAmount: BigDecimal,
|
||||
val firstPaymentTotalAmount: Double,
|
||||
val repeatPaymentCount: Int,
|
||||
val repeatPaymentTotalAmount: BigDecimal,
|
||||
val repeatPaymentTotalAmount: Double,
|
||||
val allPaymentCount: Int,
|
||||
val allPaymentTotalAmount: BigDecimal
|
||||
val allPaymentTotalAmount: Double
|
||||
)
|
||||
|
||||
@@ -1,30 +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 curationList: List<GetContentCurationResponse>
|
||||
)
|
||||
@@ -1,66 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/home")
|
||||
class HomeController(private val service: HomeService) {
|
||||
@GetMapping
|
||||
fun fetchData(
|
||||
@RequestParam timezone: String,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.fetchData(
|
||||
timezone = timezone,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
member
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/latest-content")
|
||||
fun getLatestContentByTheme(
|
||||
@RequestParam("theme") theme: String,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.getLatestContentByTheme(
|
||||
theme = theme,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
member
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/day-of-week-series")
|
||||
fun getDayOfWeekSeriesList(
|
||||
@RequestParam("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.getDayOfWeekSeriesList(
|
||||
dayOfWeek = dayOfWeek,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
member
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.AuditionService
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.event.GetEventResponse
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberService
|
||||
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
|
||||
import kr.co.vividnext.sodalive.rank.RankingRepository
|
||||
import kr.co.vividnext.sodalive.rank.RankingService
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
|
||||
@Service
|
||||
class HomeService(
|
||||
private val memberService: MemberService,
|
||||
private val liveRoomService: LiveRoomService,
|
||||
private val auditionService: AuditionService,
|
||||
private val seriesService: ContentSeriesService,
|
||||
private val contentService: AudioContentService,
|
||||
private val bannerService: AudioContentBannerService,
|
||||
private val curationService: AudioContentCurationService,
|
||||
private val contentThemeService: AudioContentThemeService,
|
||||
private val recommendChannelService: RecommendChannelQueryService,
|
||||
|
||||
private val characterService: ChatCharacterService,
|
||||
private val rankingService: RankingService,
|
||||
private val rankingRepository: RankingRepository,
|
||||
private val explorerQueryRepository: ExplorerQueryRepository,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
fun fetchData(
|
||||
timezone: String,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member?
|
||||
): GetHomeResponse {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
val liveList = liveRoomService.getRoomList(
|
||||
dateString = null,
|
||||
status = LiveRoomStatus.NOW,
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
pageable = Pageable.ofSize(10),
|
||||
member = member,
|
||||
timezone = timezone
|
||||
)
|
||||
|
||||
val creatorRanking = rankingRepository
|
||||
.getCreatorRankings()
|
||||
.filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.id!!)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
.map {
|
||||
val followerCount = explorerQueryRepository.getNotificationUserIds(it.id!!).size
|
||||
val follow = if (memberId != null) {
|
||||
explorerQueryRepository.isFollow(it.id!!, memberId = memberId)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
it.toExplorerSectionCreator(imageHost, follow, followerCount = followerCount)
|
||||
}
|
||||
|
||||
val latestContentThemeList = contentThemeService.getActiveThemeOfContent(
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
val latestContentList = contentService.getLatestContentByTheme(
|
||||
theme = latestContentThemeList,
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
isAdult = isAdult
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val eventBannerList = GetEventResponse(
|
||||
totalCount = 0,
|
||||
eventList = emptyList()
|
||||
)
|
||||
|
||||
val bannerList = bannerService.getBannerList(
|
||||
tabId = 1,
|
||||
memberId = member?.id,
|
||||
isAdult = isAdult
|
||||
)
|
||||
|
||||
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
||||
|
||||
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
||||
)
|
||||
|
||||
// 인기 캐릭터 조회
|
||||
val popularCharacters = 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,
|
||||
sortType = "매출"
|
||||
)
|
||||
|
||||
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
val freeContentList = contentService.getLatestContentByTheme(
|
||||
theme = contentThemeService.getActiveThemeOfContent(
|
||||
isAdult = isAdult,
|
||||
isFree = true,
|
||||
contentType = contentType
|
||||
),
|
||||
contentType = contentType,
|
||||
isFree = true,
|
||||
isAdult = isAdult
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val curationList = curationService.getContentCurationList(
|
||||
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
memberId = memberId
|
||||
)
|
||||
|
||||
return GetHomeResponse(
|
||||
liveList = liveList,
|
||||
creatorRanking = creatorRanking,
|
||||
latestContentThemeList = latestContentThemeList,
|
||||
latestContentList = latestContentList,
|
||||
bannerList = bannerList,
|
||||
eventBannerList = eventBannerList,
|
||||
originalAudioDramaList = originalAudioDramaList,
|
||||
auditionList = auditionList,
|
||||
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
||||
popularCharacters = popularCharacters,
|
||||
contentRanking = contentRanking,
|
||||
recommendChannelList = recommendChannelList,
|
||||
freeContentList = freeContentList,
|
||||
curationList = curationList
|
||||
)
|
||||
}
|
||||
|
||||
fun getLatestContentByTheme(
|
||||
theme: String,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member?
|
||||
): List<AudioContentMainItem> {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
val themeList = if (theme.isBlank()) {
|
||||
contentThemeService.getActiveThemeOfContent(
|
||||
isAdult = isAdult,
|
||||
isFree = true,
|
||||
contentType = contentType
|
||||
)
|
||||
} else {
|
||||
listOf(theme)
|
||||
}
|
||||
|
||||
return contentService.getLatestContentByTheme(
|
||||
theme = themeList,
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
isAdult = isAdult
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getDayOfWeekSeriesList(
|
||||
dayOfWeek: SeriesPublishedDaysOfWeek,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member?
|
||||
): List<GetSeriesListResponse.SeriesListItem> {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
return seriesService.getDayOfWeekSeriesList(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
dayOfWeek = dayOfWeek
|
||||
)
|
||||
}
|
||||
|
||||
private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek {
|
||||
val systemTime = LocalDateTime.now()
|
||||
val zoneId = ZoneId.of(timezone)
|
||||
val zonedDateTime = systemTime.atZone(ZoneId.systemDefault()).withZoneSameInstant(zoneId)
|
||||
|
||||
val dayToSeriesPublishedDaysOfWeek = mapOf(
|
||||
DayOfWeek.MONDAY to SeriesPublishedDaysOfWeek.MON,
|
||||
DayOfWeek.TUESDAY to SeriesPublishedDaysOfWeek.TUE,
|
||||
DayOfWeek.WEDNESDAY to SeriesPublishedDaysOfWeek.WED,
|
||||
DayOfWeek.THURSDAY to SeriesPublishedDaysOfWeek.THU,
|
||||
DayOfWeek.FRIDAY to SeriesPublishedDaysOfWeek.FRI,
|
||||
DayOfWeek.SATURDAY to SeriesPublishedDaysOfWeek.SAT,
|
||||
DayOfWeek.SUNDAY to SeriesPublishedDaysOfWeek.SUN
|
||||
)
|
||||
|
||||
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.live
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/live")
|
||||
class LiveApiController(
|
||||
private val service: LiveApiService
|
||||
) {
|
||||
@GetMapping
|
||||
fun fetchData(
|
||||
@RequestParam timezone: String,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.fetchData(
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
timezone = timezone,
|
||||
member = member
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.live
|
||||
|
||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||
import kr.co.vividnext.sodalive.live.recommend.LiveRecommendService
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class LiveApiService(
|
||||
private val liveService: LiveRoomService,
|
||||
private val contentService: AudioContentService,
|
||||
private val recommendService: LiveRecommendService,
|
||||
private val creatorCommunityService: CreatorCommunityService,
|
||||
|
||||
private val blockMemberRepository: BlockMemberRepository
|
||||
) {
|
||||
fun fetchData(
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
timezone: String,
|
||||
member: Member?
|
||||
): LiveMainResponse {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
val liveOnAirRoomList = liveService.getRoomList(
|
||||
dateString = null,
|
||||
status = LiveRoomStatus.NOW,
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
pageable = Pageable.ofSize(20),
|
||||
member = member,
|
||||
timezone = timezone
|
||||
)
|
||||
|
||||
val communityPostList = if (memberId != null) {
|
||||
creatorCommunityService.getLatestPostListFromCreatorsYouFollow(
|
||||
timezone = timezone,
|
||||
memberId = memberId,
|
||||
isAdult = isAdult
|
||||
)
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
|
||||
val recommendLiveList = recommendService.getRecommendLive(member)
|
||||
|
||||
val latestFinishedLiveList = liveService.getLatestFinishedLive(member)
|
||||
|
||||
val replayLive = contentService.getLatestContentByTheme(
|
||||
theme = listOf("다시듣기"),
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
isAdult = isAdult
|
||||
)
|
||||
.filter { content ->
|
||||
if (memberId != null) {
|
||||
!blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = content.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val followingChannelList = if (memberId != null) {
|
||||
recommendService.getFollowingChannelList(member)
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
|
||||
val liveReservationRoomList = liveService.getRoomList(
|
||||
dateString = null,
|
||||
status = LiveRoomStatus.RESERVATION,
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
pageable = Pageable.ofSize(10),
|
||||
member = member,
|
||||
timezone = timezone
|
||||
)
|
||||
|
||||
return LiveMainResponse(
|
||||
liveOnAirRoomList = liveOnAirRoomList,
|
||||
communityPostList = communityPostList,
|
||||
recommendLiveList = recommendLiveList,
|
||||
latestFinishedLiveList = latestFinishedLiveList,
|
||||
replayLive = replayLive,
|
||||
followingChannelList = followingChannelList,
|
||||
liveReservationRoomList = liveReservationRoomList
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.live
|
||||
|
||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse
|
||||
import kr.co.vividnext.sodalive.live.recommend.GetRecommendChannelResponse
|
||||
import kr.co.vividnext.sodalive.live.recommend.GetRecommendLiveResponse
|
||||
import kr.co.vividnext.sodalive.live.room.GetLatestFinishedLiveResponse
|
||||
import kr.co.vividnext.sodalive.live.room.GetRoomListResponse
|
||||
|
||||
data class LiveMainResponse(
|
||||
val liveOnAirRoomList: List<GetRoomListResponse>,
|
||||
val communityPostList: List<GetCommunityPostListResponse>,
|
||||
val recommendLiveList: List<GetRecommendLiveResponse>,
|
||||
val latestFinishedLiveList: List<GetLatestFinishedLiveResponse>,
|
||||
val replayLive: List<AudioContentMainItem>,
|
||||
val followingChannelList: List<GetRecommendChannelResponse>,
|
||||
val liveReservationRoomList: List<GetRoomListResponse>
|
||||
)
|
||||
@@ -12,7 +12,6 @@ interface AuditionQueryRepository {
|
||||
fun getCompletedAuditionCount(isAdult: Boolean): Int
|
||||
fun 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.can
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.GeoCountry
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.data.domain.Pageable
|
||||
@@ -10,15 +9,13 @@ import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/can")
|
||||
class CanController(private val service: CanService) {
|
||||
@GetMapping
|
||||
fun getCans(request: HttpServletRequest): ApiResponse<List<CanResponse>> {
|
||||
val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER
|
||||
return ApiResponse.ok(service.getCans(geoCountry))
|
||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
||||
return ApiResponse.ok(service.getCans())
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository
|
||||
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
|
||||
|
||||
interface CanQueryRepository {
|
||||
fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse>
|
||||
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
||||
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
|
||||
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
|
||||
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
|
||||
@@ -32,7 +32,7 @@ interface CanQueryRepository {
|
||||
|
||||
@Repository
|
||||
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
|
||||
override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> {
|
||||
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QCanResponse(
|
||||
@@ -40,16 +40,11 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
|
||||
can1.title,
|
||||
can1.can,
|
||||
can1.rewardCan,
|
||||
can1.price.intValue(),
|
||||
can1.currency,
|
||||
can1.price.stringValue()
|
||||
can1.price
|
||||
)
|
||||
)
|
||||
.from(can1)
|
||||
.where(
|
||||
can1.status.eq(status),
|
||||
can1.currency.eq(currency)
|
||||
)
|
||||
.where(can1.status.eq(status))
|
||||
.orderBy(can1.can.asc())
|
||||
.fetch()
|
||||
}
|
||||
@@ -69,13 +64,11 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
|
||||
val chargeStatusCondition = when (container) {
|
||||
"aos" -> {
|
||||
charge.payment.paymentGateway.eq(PaymentGateway.PG)
|
||||
.or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||
.or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
||||
}
|
||||
|
||||
"ios" -> {
|
||||
charge.payment.paymentGateway.eq(PaymentGateway.PG)
|
||||
.or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||
.or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,5 @@ data class CanResponse @QueryProjection constructor(
|
||||
val title: String,
|
||||
val can: Int,
|
||||
val rewardCan: Int,
|
||||
val price: Int,
|
||||
val currency: String,
|
||||
val priceStr: String
|
||||
val price: Int
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.can
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.common.GeoCountry
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -12,12 +11,8 @@ import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
class CanService(private val repository: CanRepository) {
|
||||
fun getCans(geoCountry: GeoCountry): List<CanResponse> {
|
||||
val currency = when (geoCountry) {
|
||||
GeoCountry.KR -> "KRW"
|
||||
else -> "USD"
|
||||
}
|
||||
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
|
||||
fun getCans(): List<CanResponse> {
|
||||
return repository.findAllByStatus(status = CanStatus.SALE)
|
||||
}
|
||||
|
||||
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
|
||||
@@ -40,7 +35,6 @@ class CanService(private val repository: CanRepository) {
|
||||
"aos" -> {
|
||||
it.useCanCalculates.any { useCanCalculate ->
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
|
||||
}
|
||||
}
|
||||
@@ -48,14 +42,12 @@ class CanService(private val repository: CanRepository) {
|
||||
"ios" -> {
|
||||
it.useCanCalculates.any { useCanCalculate ->
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
|
||||
}
|
||||
}
|
||||
|
||||
else -> it.useCanCalculates.any { useCanCalculate ->
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PG
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,10 +72,6 @@ class CanService(private val repository: CanRepository) {
|
||||
CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}"
|
||||
CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}"
|
||||
CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}"
|
||||
CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||
CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}"
|
||||
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
|
||||
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
|
||||
}
|
||||
|
||||
val createdAt = it.createdAt!!
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.can.charge
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class ChargeCompleteResponse(
|
||||
val price: BigDecimal,
|
||||
val price: Double,
|
||||
val currencyCode: String,
|
||||
val isFirstCharged: Boolean
|
||||
)
|
||||
|
||||
@@ -6,77 +6,20 @@ import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||
import kr.co.vividnext.sodalive.marketing.AdTrackingService
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import java.time.LocalDateTime
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/charge")
|
||||
class ChargeController(
|
||||
private val service: ChargeService,
|
||||
private val trackingService: AdTrackingService,
|
||||
|
||||
@Value("\${payverse.inbound-ip}")
|
||||
private val payverseInboundIp: String
|
||||
private val trackingService: AdTrackingService
|
||||
) {
|
||||
|
||||
@PostMapping("/payverse")
|
||||
fun payverseCharge(
|
||||
@RequestBody request: PayverseChargeRequest,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) {
|
||||
throw SodaException("로그인 정보를 확인해주세요.")
|
||||
}
|
||||
|
||||
ApiResponse.ok(service.payverseCharge(member, request))
|
||||
}
|
||||
|
||||
@PostMapping("/payverse/verify")
|
||||
fun payverseVerify(
|
||||
@RequestBody verifyRequest: PayverseVerifyRequest,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) {
|
||||
throw SodaException("로그인 정보를 확인해주세요.")
|
||||
}
|
||||
|
||||
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
|
||||
trackingCharge(member, response)
|
||||
ApiResponse.ok(Unit)
|
||||
}
|
||||
|
||||
// Payverse Webhook 엔드포인트 (payverseVerify 아래)
|
||||
@PostMapping("/payverse/webhook")
|
||||
fun payverseWebhook(
|
||||
@RequestBody request: PayverseWebhookRequest,
|
||||
servletRequest: HttpServletRequest
|
||||
): PayverseWebhookResponse {
|
||||
val header = servletRequest.getHeader("X-Forwarded-For")
|
||||
val remoteIp = if (header.isNullOrEmpty()) {
|
||||
servletRequest.remoteAddr
|
||||
} else {
|
||||
header.split(",")[0].trim() // 첫 번째 값이 클라이언트 IP
|
||||
}
|
||||
|
||||
if (remoteIp != payverseInboundIp) {
|
||||
throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
|
||||
val success = service.payverseWebhook(request)
|
||||
if (!success) {
|
||||
throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
return PayverseWebhookResponse(receiveResult = "SUCCESS")
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
fun charge(
|
||||
@RequestBody chargeRequest: ChargeRequest,
|
||||
@@ -168,7 +111,8 @@ class ChargeController(
|
||||
memberId = member.id!!,
|
||||
chargeId = chargeId,
|
||||
productId = request.productId,
|
||||
purchaseToken = request.purchaseToken
|
||||
purchaseToken = request.purchaseToken,
|
||||
paymentGateway = request.paymentGateway
|
||||
)
|
||||
|
||||
trackingCharge(member, response)
|
||||
|
||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.can.charge
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
|
||||
|
||||
@@ -21,14 +20,14 @@ data class VerifyResult(
|
||||
val method: String,
|
||||
val pg: String,
|
||||
val status: Int,
|
||||
val price: BigDecimal
|
||||
val price: Int
|
||||
)
|
||||
|
||||
data class AppleChargeRequest(
|
||||
val title: String,
|
||||
val chargeCan: Int,
|
||||
val paymentGateway: PaymentGateway,
|
||||
var price: BigDecimal? = null,
|
||||
var price: Double? = null,
|
||||
var locale: String? = null
|
||||
)
|
||||
|
||||
@@ -39,53 +38,9 @@ data class AppleVerifyResponse(val status: Int)
|
||||
data class GoogleChargeRequest(
|
||||
val title: String,
|
||||
val chargeCan: Int,
|
||||
val price: BigDecimal,
|
||||
val price: Double,
|
||||
val currencyCode: String,
|
||||
val productId: String,
|
||||
val purchaseToken: String,
|
||||
val paymentGateway: PaymentGateway
|
||||
)
|
||||
|
||||
data class PayverseChargeRequest(
|
||||
val canId: Long
|
||||
)
|
||||
|
||||
data class PayverseChargeResponse(
|
||||
val chargeId: Long,
|
||||
val payloadJson: String
|
||||
)
|
||||
|
||||
data class PayverseVerifyRequest(
|
||||
val transactionId: String,
|
||||
val orderId: String
|
||||
)
|
||||
|
||||
data class PayverseVerifyResponse(
|
||||
val resultStatus: String,
|
||||
val tid: String,
|
||||
val schemeGroup: String,
|
||||
val schemeCode: String,
|
||||
val transactionType: String,
|
||||
val transactionStatus: String,
|
||||
val transactionMessage: String,
|
||||
val orderId: String,
|
||||
val customerId: String,
|
||||
val requestCurrency: String,
|
||||
val requestAmount: BigDecimal
|
||||
)
|
||||
|
||||
data class PayverseWebhookRequest(
|
||||
val type: String,
|
||||
val mid: String,
|
||||
val tid: String,
|
||||
val schemeGroup: String,
|
||||
val schemeCode: String,
|
||||
val orderId: String,
|
||||
val requestCurrency: String,
|
||||
val requestAmount: BigDecimal,
|
||||
val resultStatus: String,
|
||||
val approvalDay: String,
|
||||
val sign: String
|
||||
)
|
||||
|
||||
data class PayverseWebhookResponse(val receiveResult: String)
|
||||
|
||||
@@ -113,18 +113,15 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha
|
||||
val paymentGatewayCondition = when (container) {
|
||||
"aos" -> {
|
||||
payment.paymentGateway.eq(PaymentGateway.PG)
|
||||
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
||||
}
|
||||
|
||||
"ios" -> {
|
||||
payment.paymentGateway.eq(PaymentGateway.PG)
|
||||
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
||||
}
|
||||
|
||||
else -> payment.paymentGateway.eq(PaymentGateway.PG)
|
||||
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||
}
|
||||
|
||||
return paymentGatewayCondition.or(payment.paymentGateway.eq(PaymentGateway.POINT_CLICK_AD))
|
||||
|
||||
@@ -5,7 +5,6 @@ import kr.co.bootpay.Bootpay
|
||||
import kr.co.vividnext.sodalive.can.CanRepository
|
||||
import kr.co.vividnext.sodalive.can.charge.event.ChargeSpringEvent
|
||||
import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository
|
||||
import kr.co.vividnext.sodalive.can.coupon.CouponType
|
||||
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||
@@ -13,16 +12,10 @@ import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.google.GooglePlayService
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.point.MemberPoint
|
||||
import kr.co.vividnext.sodalive.point.MemberPointRepository
|
||||
import kr.co.vividnext.sodalive.point.PointGrantLog
|
||||
import kr.co.vividnext.sodalive.point.PointGrantLogRepository
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
import org.json.JSONObject
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
@@ -34,8 +27,6 @@ import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
@@ -45,9 +36,6 @@ class ChargeService(
|
||||
private val memberRepository: MemberRepository,
|
||||
private val couponNumberRepository: CanCouponNumberRepository,
|
||||
|
||||
private val grantLogRepository: PointGrantLogRepository,
|
||||
private val memberPointRepository: MemberPointRepository,
|
||||
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
@@ -65,341 +53,34 @@ class ChargeService(
|
||||
@Value("\${apple.iap-verify-sandbox-url}")
|
||||
private val appleInAppVerifySandBoxUrl: String,
|
||||
@Value("\${apple.iap-verify-url}")
|
||||
private val appleInAppVerifyUrl: String,
|
||||
|
||||
@Value("\${payverse.mid}")
|
||||
private val payverseMid: String,
|
||||
@Value("\${payverse.client-key}")
|
||||
private val payverseClientKey: String,
|
||||
@Value("\${payverse.secret-key}")
|
||||
private val payverseSecretKey: String,
|
||||
|
||||
@Value("\${payverse.usd-mid}")
|
||||
private val payverseUsdMid: String,
|
||||
@Value("\${payverse.usd-client-key}")
|
||||
private val payverseUsdClientKey: String,
|
||||
@Value("\${payverse.usd-secret-key}")
|
||||
private val payverseUsdSecretKey: String,
|
||||
|
||||
@Value("\${payverse.host}")
|
||||
private val payverseHost: String,
|
||||
|
||||
@Value("\${server.env}")
|
||||
private val serverEnv: String
|
||||
private val appleInAppVerifyUrl: String
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun payverseWebhook(request: PayverseWebhookRequest): Boolean {
|
||||
val chargeId = request.orderId.toLongOrNull() ?: return false
|
||||
val charge = chargeRepository.findByIdOrNull(chargeId) ?: return false
|
||||
|
||||
// 결제수단 확인
|
||||
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 결제 상태 분기 처리
|
||||
return when (charge.payment?.status) {
|
||||
PaymentStatus.REQUEST -> {
|
||||
// 성공 조건 검증
|
||||
val mid = if (request.requestCurrency == "KRW") {
|
||||
payverseMid
|
||||
} else {
|
||||
payverseUsdMid
|
||||
}
|
||||
val expectedSign = DigestUtils.sha512Hex(
|
||||
String.format(
|
||||
"||%s||%s||%s||%s||%s||",
|
||||
if (request.requestCurrency == "KRW") {
|
||||
payverseSecretKey
|
||||
} else {
|
||||
payverseUsdSecretKey
|
||||
},
|
||||
mid,
|
||||
request.orderId,
|
||||
request.requestAmount,
|
||||
request.approvalDay
|
||||
)
|
||||
)
|
||||
|
||||
val isAmountMatch = request.requestAmount.compareTo(
|
||||
charge.payment!!.price
|
||||
) == 0
|
||||
|
||||
val isSuccess = request.resultStatus == "SUCCESS" &&
|
||||
request.mid == mid &&
|
||||
request.orderId.toLongOrNull() == charge.id &&
|
||||
isAmountMatch &&
|
||||
request.sign == expectedSign
|
||||
|
||||
if (isSuccess) {
|
||||
// payverseVerify의 226~246 라인과 동일 처리
|
||||
charge.payment?.receiptId = request.tid
|
||||
val mappedMethod = if (request.schemeGroup == "PVKR") {
|
||||
mapPayverseSchemeToMethodByCode(request.schemeCode)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
charge.payment?.method = mappedMethod ?: request.schemeCode
|
||||
charge.payment?.status = PaymentStatus.COMPLETE
|
||||
charge.payment?.locale = request.requestCurrency
|
||||
|
||||
val member = charge.member!!
|
||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ChargeSpringEvent(
|
||||
chargeId = charge.id!!,
|
||||
memberId = member.id!!
|
||||
)
|
||||
)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
PaymentStatus.COMPLETE -> {
|
||||
// 이미 결제가 완료된 경우 성공 처리(idempotent)
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
// 그 외 상태는 404
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun chargeByCoupon(couponNumber: String, member: Member): String {
|
||||
fun chargeByCoupon(couponNumber: String, member: Member) {
|
||||
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
||||
?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.")
|
||||
|
||||
if (canCouponNumber.member != null) {
|
||||
throw SodaException("이미 사용한 쿠폰번호 입니다.")
|
||||
}
|
||||
|
||||
canCouponNumber.member = member
|
||||
|
||||
val coupon = canCouponNumber.canCoupon!!
|
||||
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
|
||||
couponCharge.title = "${coupon.can} 캔"
|
||||
couponCharge.member = member
|
||||
|
||||
when (coupon.couponType) {
|
||||
CouponType.CAN -> {
|
||||
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
|
||||
couponCharge.title = "${coupon.can} 캔"
|
||||
couponCharge.member = member
|
||||
|
||||
val payment = Payment(
|
||||
status = PaymentStatus.COMPLETE,
|
||||
paymentGateway = PaymentGateway.PG
|
||||
)
|
||||
payment.method = coupon.couponName
|
||||
couponCharge.payment = payment
|
||||
chargeRepository.save(couponCharge)
|
||||
|
||||
member.charge(0, coupon.can, "pg")
|
||||
return "쿠폰 사용이 완료되었습니다.\n${coupon.can}캔이 지급되었습니다."
|
||||
}
|
||||
|
||||
CouponType.POINT -> {
|
||||
val memberId = member.id!!
|
||||
val point = coupon.can
|
||||
val actionType = ActionType.COUPON
|
||||
|
||||
grantLogRepository.save(
|
||||
PointGrantLog(
|
||||
memberId = memberId,
|
||||
point = point,
|
||||
actionType = actionType,
|
||||
policyId = null,
|
||||
orderId = null,
|
||||
couponName = coupon.couponName
|
||||
)
|
||||
)
|
||||
|
||||
memberPointRepository.save(
|
||||
MemberPoint(
|
||||
memberId = memberId,
|
||||
point = point,
|
||||
actionType = actionType,
|
||||
expiresAt = LocalDateTime.now().plusDays(3)
|
||||
)
|
||||
)
|
||||
|
||||
return "쿠폰 사용이 완료되었습니다.\n${coupon.can}포인트가 지급되었습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun payverseCharge(member: Member, request: PayverseChargeRequest): PayverseChargeResponse {
|
||||
val can = canRepository.findByIdOrNull(request.canId)
|
||||
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
|
||||
|
||||
val requestCurrency = can.currency
|
||||
val isKrw = requestCurrency == "KRW"
|
||||
val mid = if (isKrw) {
|
||||
payverseMid
|
||||
} else {
|
||||
payverseUsdMid
|
||||
}
|
||||
val clientKey = if (isKrw) {
|
||||
payverseClientKey
|
||||
} else {
|
||||
payverseUsdClientKey
|
||||
}
|
||||
val secretKey = if (isKrw) {
|
||||
payverseSecretKey
|
||||
} else {
|
||||
payverseUsdSecretKey
|
||||
}
|
||||
|
||||
val charge = Charge(can.can, can.rewardCan)
|
||||
charge.title = can.title
|
||||
charge.member = member
|
||||
charge.can = can
|
||||
|
||||
val payment = Payment(paymentGateway = PaymentGateway.PAYVERSE)
|
||||
payment.price = can.price
|
||||
charge.payment = payment
|
||||
|
||||
val savedCharge = chargeRepository.save(charge)
|
||||
|
||||
val chargeId = savedCharge.id!!
|
||||
val amount = BigDecimal(
|
||||
savedCharge.payment!!.price
|
||||
.setScale(4, RoundingMode.HALF_UP)
|
||||
.stripTrailingZeros()
|
||||
.toPlainString()
|
||||
val payment = Payment(
|
||||
status = PaymentStatus.COMPLETE,
|
||||
paymentGateway = PaymentGateway.PG
|
||||
)
|
||||
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||
val sign = DigestUtils.sha512Hex(
|
||||
String.format(
|
||||
"||%s||%s||%s||%s||%s||",
|
||||
secretKey,
|
||||
mid,
|
||||
chargeId,
|
||||
amount,
|
||||
reqDate
|
||||
)
|
||||
)
|
||||
val customerId = "${serverEnv}_user_${member.id!!}"
|
||||
payment.method = coupon.couponName
|
||||
couponCharge.payment = payment
|
||||
chargeRepository.save(couponCharge)
|
||||
|
||||
val payload = linkedMapOf(
|
||||
"mid" to mid,
|
||||
"clientKey" to clientKey,
|
||||
"orderId" to chargeId.toString(),
|
||||
"customerId" to customerId,
|
||||
"productName" to can.title,
|
||||
"requestCurrency" to requestCurrency,
|
||||
"requestAmount" to amount,
|
||||
"reqDate" to reqDate,
|
||||
"sign" to sign
|
||||
)
|
||||
val payloadJson = objectMapper.writeValueAsString(payload)
|
||||
|
||||
return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
|
||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||
val member = memberRepository.findByIdOrNull(memberId)
|
||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
val isKrw = charge.can?.currency == "KRW"
|
||||
val mid = if (isKrw) {
|
||||
payverseMid
|
||||
} else {
|
||||
payverseUsdMid
|
||||
}
|
||||
val clientKey = if (isKrw) {
|
||||
payverseClientKey
|
||||
} else {
|
||||
payverseUsdClientKey
|
||||
}
|
||||
|
||||
// 결제수단 확인
|
||||
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
|
||||
// 결제 상태에 따른 분기 처리
|
||||
when (charge.payment?.status) {
|
||||
PaymentStatus.REQUEST -> {
|
||||
try {
|
||||
val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("mid", mid)
|
||||
.addHeader("clientKey", clientKey)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
|
||||
val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
|
||||
|
||||
val customerId = "${serverEnv}_user_${member.id!!}"
|
||||
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
|
||||
verifyResponse.transactionStatus == "SUCCESS" &&
|
||||
verifyResponse.orderId.toLongOrNull() == charge.id &&
|
||||
verifyResponse.customerId == customerId &&
|
||||
verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0
|
||||
|
||||
if (isSuccess) {
|
||||
// verify 함수의 232~248 라인과 동일 처리
|
||||
charge.payment?.receiptId = verifyResponse.tid
|
||||
val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") {
|
||||
mapPayverseSchemeToMethodByCode(verifyResponse.schemeCode)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
charge.payment?.method = mappedMethod ?: verifyResponse.schemeCode
|
||||
charge.payment?.status = PaymentStatus.COMPLETE
|
||||
// 통화코드 설정
|
||||
charge.payment?.locale = verifyResponse.requestCurrency
|
||||
|
||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ChargeSpringEvent(
|
||||
chargeId = charge.id!!,
|
||||
memberId = member.id!!
|
||||
)
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
} else {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
}
|
||||
|
||||
PaymentStatus.COMPLETE -> {
|
||||
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
}
|
||||
member.charge(0, coupon.can, "pg")
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -413,7 +94,7 @@ class ChargeService(
|
||||
charge.can = can
|
||||
|
||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||
payment.price = can.price
|
||||
payment.price = can.price.toDouble()
|
||||
charge.payment = payment
|
||||
|
||||
chargeRepository.save(charge)
|
||||
@@ -452,14 +133,14 @@ class ChargeService(
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
} else {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
} else {
|
||||
@@ -484,7 +165,7 @@ class ChargeService(
|
||||
VerifyResult::class.java
|
||||
)
|
||||
|
||||
if (verifyResult.status == 1) {
|
||||
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
|
||||
charge.payment?.receiptId = verifyResult.receiptId
|
||||
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
|
||||
"${verifyResult.pg}-${verifyResult.method}"
|
||||
@@ -502,14 +183,14 @@ class ChargeService(
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
} else {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
} else {
|
||||
@@ -527,7 +208,7 @@ class ChargeService(
|
||||
payment.price = if (request.price != null) {
|
||||
request.price!!
|
||||
} else {
|
||||
0.toBigDecimal()
|
||||
0.toDouble()
|
||||
}
|
||||
|
||||
payment.locale = request.locale
|
||||
@@ -562,7 +243,7 @@ class ChargeService(
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
@@ -579,7 +260,7 @@ class ChargeService(
|
||||
member: Member,
|
||||
title: String,
|
||||
chargeCan: Int,
|
||||
price: BigDecimal,
|
||||
price: Double,
|
||||
currencyCode: String,
|
||||
productId: String,
|
||||
purchaseToken: String,
|
||||
@@ -607,7 +288,8 @@ class ChargeService(
|
||||
memberId: Long,
|
||||
chargeId: Long,
|
||||
productId: String,
|
||||
purchaseToken: String
|
||||
purchaseToken: String,
|
||||
paymentGateway: PaymentGateway
|
||||
): ChargeCompleteResponse {
|
||||
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||
@@ -629,7 +311,7 @@ class ChargeService(
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
@@ -711,13 +393,4 @@ class ChargeService(
|
||||
throw SodaException("결제를 완료하지 못했습니다.")
|
||||
}
|
||||
}
|
||||
|
||||
// Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환
|
||||
private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? {
|
||||
val cardCodes = setOf(
|
||||
"041", "044", "361", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "381",
|
||||
"218", "071", "002", "089", "045", "050", "048", "090", "092"
|
||||
)
|
||||
return if (schemeCode != null && cardCodes.contains(schemeCode)) "카드" else null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.can.charge.temp
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class ChargeTempRequest(
|
||||
val can: Int,
|
||||
val price: BigDecimal,
|
||||
val price: Int,
|
||||
val paymentGateway: PaymentGateway
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ class ChargeTempService(
|
||||
charge.member = member
|
||||
|
||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||
payment.price = request.price
|
||||
payment.price = request.price.toDouble()
|
||||
charge.payment = payment
|
||||
|
||||
chargeRepository.save(charge)
|
||||
@@ -66,7 +66,7 @@ class ChargeTempService(
|
||||
VerifyResult::class.java
|
||||
)
|
||||
|
||||
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price) {
|
||||
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) {
|
||||
charge.payment?.receiptId = verifyResult.receiptId
|
||||
charge.payment?.method = verifyResult.method
|
||||
charge.payment?.status = PaymentStatus.COMPLETE
|
||||
@@ -74,7 +74,7 @@ class ChargeTempService(
|
||||
} else {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -3,21 +3,13 @@ package kr.co.vividnext.sodalive.can.coupon
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
|
||||
@Entity
|
||||
data class CanCoupon(
|
||||
val couponName: String,
|
||||
@Enumerated(EnumType.STRING)
|
||||
val couponType: CouponType,
|
||||
val can: Int,
|
||||
val couponCount: Int,
|
||||
var validity: LocalDateTime,
|
||||
var isActive: Boolean,
|
||||
var isMultipleUse: Boolean
|
||||
) : BaseEntity()
|
||||
|
||||
enum class CouponType {
|
||||
CAN, POINT
|
||||
}
|
||||
|
||||
@@ -109,11 +109,11 @@ class CanCouponController(private val service: CanCouponService) {
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
val completeMessage = service.useCanCoupon(
|
||||
couponNumber = request.couponNumber,
|
||||
memberId = member.id!!
|
||||
ApiResponse.ok(
|
||||
service.useCanCoupon(
|
||||
couponNumber = request.couponNumber,
|
||||
memberId = member.id!!
|
||||
)
|
||||
)
|
||||
|
||||
ApiResponse.ok(Unit, completeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ class CanCouponNumberQueryRepositoryImpl(private val queryFactory: JPAQueryFacto
|
||||
override fun findByCouponNumber(couponNumber: String): CanCouponNumber? {
|
||||
return queryFactory
|
||||
.selectFrom(canCouponNumber)
|
||||
.innerJoin(canCouponNumber.canCoupon, canCoupon)
|
||||
.where(canCouponNumber.couponNumber.eq(couponNumber))
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.can.coupon
|
||||
|
||||
import com.querydsl.core.types.dsl.CaseBuilder
|
||||
import com.querydsl.core.types.dsl.DateTimePath
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.core.types.dsl.StringTemplate
|
||||
@@ -31,9 +30,6 @@ class CanCouponQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) :
|
||||
QGetCouponListItemResponse(
|
||||
canCoupon.id,
|
||||
canCoupon.couponName,
|
||||
CaseBuilder()
|
||||
.`when`(canCoupon.couponType.eq(CouponType.POINT)).then("포인트 쿠폰")
|
||||
.otherwise("캔 쿠폰"),
|
||||
canCoupon.can,
|
||||
canCoupon.couponCount,
|
||||
Expressions.ZERO,
|
||||
|
||||
@@ -68,12 +68,15 @@ class CanCouponService(
|
||||
|
||||
fun getCouponList(offset: Long, limit: Long): GetCouponListResponse {
|
||||
val totalCount = repository.getCouponTotalCount()
|
||||
|
||||
val items = repository.getCouponList(offset = offset, limit = limit)
|
||||
.asSequence()
|
||||
.map {
|
||||
val useCouponCount = couponNumberRepository.getUseCouponCount(id = it.id)
|
||||
it.useCouponCount = useCouponCount
|
||||
it
|
||||
}
|
||||
.toList()
|
||||
|
||||
return GetCouponListResponse(totalCount, items)
|
||||
}
|
||||
@@ -121,7 +124,7 @@ class CanCouponService(
|
||||
}
|
||||
}
|
||||
|
||||
fun useCanCoupon(couponNumber: String, memberId: Long): String {
|
||||
fun useCanCoupon(couponNumber: String, memberId: Long) {
|
||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
@@ -129,7 +132,7 @@ class CanCouponService(
|
||||
|
||||
issueService.validateAvailableUseCoupon(couponNumber, memberId)
|
||||
|
||||
return chargeService.chargeByCoupon(couponNumber, member)
|
||||
chargeService.chargeByCoupon(couponNumber, member)
|
||||
}
|
||||
|
||||
private fun insertHyphens(input: String): String {
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class GenerateCanCouponRequest(
|
||||
@JsonProperty("couponName") val couponName: String,
|
||||
@JsonProperty("couponType") val couponType: CouponType,
|
||||
@JsonProperty("can") val can: Int,
|
||||
@JsonProperty("validity") val validity: String,
|
||||
@JsonProperty("isMultipleUse") val isMultipleUse: Boolean,
|
||||
|
||||
@@ -10,7 +10,6 @@ data class GetCouponListResponse(
|
||||
data class GetCouponListItemResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val couponName: String,
|
||||
val couponType: String,
|
||||
val can: Int,
|
||||
val couponCount: Int,
|
||||
var useCouponCount: Int,
|
||||
|
||||
@@ -13,7 +13,6 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.order.Order
|
||||
@@ -38,8 +37,6 @@ class CanPaymentService(
|
||||
memberId: Long,
|
||||
needCan: Int,
|
||||
canUsage: CanUsage,
|
||||
chatRoomId: Long? = null,
|
||||
characterId: Long? = null,
|
||||
isSecret: Boolean = false,
|
||||
liveRoom: LiveRoom? = null,
|
||||
order: Order? = null,
|
||||
@@ -112,14 +109,6 @@ class CanPaymentService(
|
||||
recipientId = liveRoom.member!!.id!!
|
||||
useCan.room = liveRoom
|
||||
useCan.member = member
|
||||
} else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE && chatRoomId != null && characterId != null) {
|
||||
useCan.member = member
|
||||
useCan.chatRoomId = chatRoomId
|
||||
useCan.characterId = characterId
|
||||
} else if (canUsage == CanUsage.CHAT_ROOM_RESET) {
|
||||
useCan.member = member
|
||||
useCan.chatRoomId = chatRoomId
|
||||
useCan.characterId = characterId
|
||||
} else {
|
||||
throw SodaException("잘못된 요청입니다.")
|
||||
}
|
||||
@@ -127,7 +116,6 @@ class CanPaymentService(
|
||||
useCanRepository.save(useCan)
|
||||
|
||||
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
||||
setUseCanCalculate(
|
||||
recipientId,
|
||||
useRewardCan,
|
||||
@@ -339,100 +327,4 @@ class CanPaymentService(
|
||||
chargeRepository.save(charge)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun spendCanForCharacterImage(
|
||||
memberId: Long,
|
||||
needCan: Int,
|
||||
image: CharacterImage,
|
||||
container: String
|
||||
) {
|
||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
|
||||
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
||||
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
||||
throw SodaException(
|
||||
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
|
||||
"캔이 부족합니다. 충전 후 이용해 주세요."
|
||||
)
|
||||
}
|
||||
|
||||
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
||||
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
}
|
||||
|
||||
val useCan = UseCan(
|
||||
canUsage = CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||
can = useChargeCan?.total ?: 0,
|
||||
rewardCan = useRewardCan.total,
|
||||
isSecret = false
|
||||
)
|
||||
useCan.member = member
|
||||
useCan.characterImage = image
|
||||
|
||||
useCanRepository.save(useCan)
|
||||
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun spendCanForChatMessage(
|
||||
memberId: Long,
|
||||
needCan: Int,
|
||||
message: kr.co.vividnext.sodalive.chat.room.ChatMessage,
|
||||
container: String
|
||||
) {
|
||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
|
||||
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
||||
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
||||
throw SodaException(
|
||||
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
|
||||
"캔이 부족합니다. 충전 후 이용해 주세요."
|
||||
)
|
||||
}
|
||||
|
||||
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
||||
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
}
|
||||
|
||||
val useCan = UseCan(
|
||||
canUsage = CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||
can = useChargeCan?.total ?: 0,
|
||||
rewardCan = useRewardCan.total,
|
||||
isSecret = false
|
||||
)
|
||||
useCan.member = member
|
||||
useCan.chatMessage = message
|
||||
// 이미지 메시지의 경우 이미지 연관도 함께 기록
|
||||
message.characterImage?.let { img ->
|
||||
useCan.characterImage = img
|
||||
}
|
||||
|
||||
useCanRepository.save(useCan)
|
||||
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.can.payment
|
||||
|
||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import java.math.BigDecimal
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
@@ -26,8 +25,7 @@ data class Payment(
|
||||
var receiptId: String? = null
|
||||
var method: String? = null
|
||||
|
||||
@Column(precision = 10, scale = 4, nullable = false)
|
||||
var price: BigDecimal = 0.toBigDecimal()
|
||||
var price: Double = 0.toDouble()
|
||||
var locale: String? = null
|
||||
var orderId: String? = null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.can.payment
|
||||
|
||||
enum class PaymentGateway {
|
||||
PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
|
||||
PG, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
|
||||
}
|
||||
|
||||
@@ -9,9 +9,5 @@ enum class CanUsage {
|
||||
SPIN_ROULETTE,
|
||||
PAID_COMMUNITY_POST,
|
||||
ALARM_SLOT,
|
||||
AUDITION_VOTE,
|
||||
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
||||
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
|
||||
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
|
||||
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
|
||||
AUDITION_VOTE
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.can.use
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.AuditionApplicant
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||
import kr.co.vividnext.sodalive.chat.room.ChatMessage
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.order.Order
|
||||
@@ -30,11 +28,7 @@ data class UseCan(
|
||||
|
||||
var isRefund: Boolean = false,
|
||||
|
||||
val isSecret: Boolean = false,
|
||||
|
||||
// 채팅 연동을 위한 식별자 (옵션)
|
||||
var chatRoomId: Long? = null,
|
||||
var characterId: Long? = null
|
||||
val isSecret: Boolean = false
|
||||
) : BaseEntity() {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
@@ -64,16 +58,6 @@ data class UseCan(
|
||||
@JoinColumn(name = "audition_applicant_id", nullable = true)
|
||||
var auditionApplicant: AuditionApplicant? = null
|
||||
|
||||
// 메시지를 통한 구매 연관 (옵션)
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_message_id", nullable = true)
|
||||
var chatMessage: ChatMessage? = null
|
||||
|
||||
// 캐릭터 이미지 연관 (메시지 구매/단독 구매 공통 사용)
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "character_image_id", nullable = true)
|
||||
var characterImage: CharacterImage? = null
|
||||
|
||||
@OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL])
|
||||
val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf()
|
||||
}
|
||||
|
||||
@@ -6,56 +6,23 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository {
|
||||
// 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외)
|
||||
fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn(
|
||||
memberId: Long,
|
||||
imageId: Long,
|
||||
usages: Collection<CanUsage>
|
||||
): Boolean
|
||||
}
|
||||
interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository
|
||||
|
||||
interface UseCanQueryRepository {
|
||||
fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean
|
||||
fun countPurchasedActiveImagesByCharacter(
|
||||
memberId: Long,
|
||||
characterId: Long,
|
||||
usages: Collection<CanUsage>
|
||||
): Long
|
||||
fun isExistOrdered(postId: Long, memberId: Long): Boolean
|
||||
}
|
||||
|
||||
class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository {
|
||||
override fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean {
|
||||
override fun isExistOrdered(postId: Long, memberId: Long): Boolean {
|
||||
val useCanId = queryFactory.select(useCan.id)
|
||||
.from(useCan)
|
||||
.where(
|
||||
useCan.member.id.eq(memberId)
|
||||
.and(useCan.isRefund.isFalse)
|
||||
.and(useCan.communityPost.id.eq(postId))
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
)
|
||||
.fetchFirst()
|
||||
|
||||
return useCanId != null && useCanId > 0
|
||||
}
|
||||
|
||||
override fun countPurchasedActiveImagesByCharacter(
|
||||
memberId: Long,
|
||||
characterId: Long,
|
||||
usages: Collection<CanUsage>
|
||||
): Long {
|
||||
val count = queryFactory
|
||||
.selectDistinct(useCan.characterImage.id)
|
||||
.from(useCan)
|
||||
.where(
|
||||
useCan.member.id.eq(memberId)
|
||||
.and(useCan.isRefund.isFalse)
|
||||
.and(useCan.characterImage.chatCharacter.id.eq(characterId))
|
||||
.and(useCan.characterImage.isActive.isTrue)
|
||||
.and(useCan.canUsage.`in`(usages))
|
||||
)
|
||||
.fetch()
|
||||
.size
|
||||
return count.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.CascadeType
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
import javax.persistence.OneToMany
|
||||
|
||||
@Entity
|
||||
class ChatCharacter(
|
||||
val characterUUID: String,
|
||||
|
||||
// 캐릭터 이름 (API 키 내에서 유일해야 함)
|
||||
var name: String,
|
||||
|
||||
// 캐릭터 한 줄 소개
|
||||
var description: String,
|
||||
|
||||
// AI 시스템 프롬프트
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var systemPrompt: String,
|
||||
|
||||
// 나이
|
||||
var age: Int? = null,
|
||||
|
||||
// 성별
|
||||
var gender: String? = null,
|
||||
|
||||
// mbti
|
||||
var mbti: String? = null,
|
||||
|
||||
// 말투 패턴 설명
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var speechPattern: String? = null,
|
||||
|
||||
// 대화 스타일
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var speechStyle: String? = null,
|
||||
|
||||
// 외모 설명
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var appearance: String? = null,
|
||||
|
||||
// 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
||||
@Column(nullable = true)
|
||||
var originalTitle: String? = null,
|
||||
|
||||
// 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
||||
@Column(nullable = true)
|
||||
var originalLink: String? = null,
|
||||
|
||||
// 연관 원작 (한 캐릭터는 하나의 원작에만 속함)
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "original_work_id")
|
||||
var originalWork: OriginalWork? = null,
|
||||
|
||||
// 캐릭터 유형
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
var characterType: CharacterType = CharacterType.Character,
|
||||
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
var imagePath: String? = null
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var memories: MutableList<ChatCharacterMemory> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var personalities: MutableList<ChatCharacterPersonality> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var backgrounds: MutableList<ChatCharacterBackground> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var relationships: MutableList<ChatCharacterRelationship> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var tagMappings: MutableList<ChatCharacterTagMapping> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var valueMappings: MutableList<ChatCharacterValueMapping> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf()
|
||||
|
||||
@OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||
var goalMappings: MutableList<ChatCharacterGoalMapping> = mutableListOf()
|
||||
|
||||
// 태그 추가 헬퍼 메소드
|
||||
fun addTag(tag: ChatCharacterTag) {
|
||||
val mapping = ChatCharacterTagMapping(this, tag)
|
||||
tagMappings.add(mapping)
|
||||
}
|
||||
|
||||
// 가치관 추가 헬퍼 메소드
|
||||
fun addValue(value: ChatCharacterValue) {
|
||||
val mapping = ChatCharacterValueMapping(this, value)
|
||||
valueMappings.add(mapping)
|
||||
}
|
||||
|
||||
// 취미 추가 헬퍼 메소드
|
||||
fun addHobby(hobby: ChatCharacterHobby) {
|
||||
val mapping = ChatCharacterHobbyMapping(this, hobby)
|
||||
hobbyMappings.add(mapping)
|
||||
}
|
||||
|
||||
// 목표 추가 헬퍼 메소드
|
||||
fun addGoal(goal: ChatCharacterGoal) {
|
||||
val mapping = ChatCharacterGoalMapping(this, goal)
|
||||
goalMappings.add(mapping)
|
||||
}
|
||||
|
||||
// 기억 추가 헬퍼 메소드
|
||||
fun addMemory(title: String, content: String, emotion: String) {
|
||||
val memory = ChatCharacterMemory(title, content, emotion, this)
|
||||
memories.add(memory)
|
||||
}
|
||||
|
||||
// 성격 추가 헬퍼 메소드
|
||||
fun addPersonality(trait: String, description: String) {
|
||||
val personality = ChatCharacterPersonality(trait, description, this)
|
||||
personalities.add(personality)
|
||||
}
|
||||
|
||||
// 배경 추가 헬퍼 메소드
|
||||
fun addBackground(topic: String, description: String) {
|
||||
val background = ChatCharacterBackground(topic, description, this)
|
||||
backgrounds.add(background)
|
||||
}
|
||||
|
||||
// 관계 추가 헬퍼 메소드
|
||||
fun addRelationship(
|
||||
personName: String,
|
||||
relationshipName: String,
|
||||
description: String,
|
||||
importance: Int,
|
||||
relationshipType: String,
|
||||
currentStatus: String
|
||||
) {
|
||||
val relationship = ChatCharacterRelationship(
|
||||
personName,
|
||||
relationshipName,
|
||||
description,
|
||||
importance,
|
||||
relationshipType,
|
||||
currentStatus,
|
||||
this
|
||||
)
|
||||
relationships.add(relationship)
|
||||
}
|
||||
}
|
||||
|
||||
enum class CharacterType {
|
||||
Clone,
|
||||
Character
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* 캐릭터 배경 정보
|
||||
*/
|
||||
|
||||
@Entity
|
||||
class ChatCharacterBackground(
|
||||
// 배경 주제
|
||||
val topic: String,
|
||||
|
||||
// 배경 설명
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var description: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter
|
||||
) : BaseEntity()
|
||||
@@ -1,29 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 엔티티
|
||||
* 이미지와 캐릭터 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다.
|
||||
* 정렬 순서(sortOrder)를 통해 배너의 표시 순서를 결정합니다.
|
||||
*/
|
||||
@Entity
|
||||
class ChatCharacterBanner(
|
||||
// 배너 이미지 경로
|
||||
var imagePath: String? = null,
|
||||
|
||||
// 연관된 캐릭터
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "character_id")
|
||||
var chatCharacter: ChatCharacter,
|
||||
|
||||
// 정렬 순서 (낮을수록 먼저 표시)
|
||||
var sortOrder: Int = 0,
|
||||
|
||||
// 활성화 여부 (소프트 삭제용)
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity()
|
||||
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.OneToMany
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
/**
|
||||
* 캐릭터 목표
|
||||
*/
|
||||
|
||||
@Entity
|
||||
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["goal"])])
|
||||
class ChatCharacterGoal(
|
||||
@Column(nullable = false)
|
||||
val goal: String
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "goal")
|
||||
var goalMappings: MutableList<ChatCharacterGoalMapping> = mutableListOf()
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* ChatCharacter와 ChatCharacterGoal 간의 매핑 엔티티
|
||||
* ChatCharacterGoal의 중복을 방지하기 위한 매핑 테이블
|
||||
*/
|
||||
@Entity
|
||||
class ChatCharacterGoalMapping(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "goal_id")
|
||||
val goal: ChatCharacterGoal
|
||||
) : BaseEntity()
|
||||
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.OneToMany
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
/**
|
||||
* 캐릭터 취미
|
||||
*/
|
||||
|
||||
@Entity
|
||||
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["hobby"])])
|
||||
class ChatCharacterHobby(
|
||||
@Column(nullable = false)
|
||||
val hobby: String
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "hobby")
|
||||
var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf()
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* ChatCharacter와 ChatCharacterHobby 간의 매핑 엔티티
|
||||
* ChatCharacterHobby의 중복을 방지하기 위한 매핑 테이블
|
||||
*/
|
||||
@Entity
|
||||
class ChatCharacterHobbyMapping(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "hobby_id")
|
||||
val hobby: ChatCharacterHobby
|
||||
) : BaseEntity()
|
||||
@@ -1,29 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* 캐릭터 기억
|
||||
*/
|
||||
|
||||
@Entity
|
||||
class ChatCharacterMemory(
|
||||
// 기억 제목
|
||||
val title: String,
|
||||
|
||||
// 기억 내용
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var content: String,
|
||||
|
||||
// 감정
|
||||
var emotion: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter
|
||||
) : BaseEntity()
|
||||
@@ -1,26 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* 캐릭터 성격 특성
|
||||
*/
|
||||
|
||||
@Entity
|
||||
class ChatCharacterPersonality(
|
||||
// 성격 특성
|
||||
val trait: String,
|
||||
|
||||
// 성격 특성 설명
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var description: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter
|
||||
) : BaseEntity()
|
||||
@@ -1,33 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* 캐릭터 관계
|
||||
*/
|
||||
|
||||
@Entity
|
||||
class ChatCharacterRelationship(
|
||||
// 상대 인물 이름
|
||||
var personName: String,
|
||||
// 관계명 (예: 친구, 동료 등)
|
||||
var relationshipName: String,
|
||||
// 관계 설명
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var description: String,
|
||||
// 중요도
|
||||
var importance: Int,
|
||||
// 관계 타입 (분류용)
|
||||
var relationshipType: String,
|
||||
// 현재 상태
|
||||
var currentStatus: String,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter
|
||||
) : BaseEntity()
|
||||
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.OneToMany
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
/**
|
||||
* 캐릭터 태그
|
||||
*/
|
||||
|
||||
@Entity
|
||||
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["tag"])])
|
||||
class ChatCharacterTag(
|
||||
@Column(nullable = false)
|
||||
val tag: String
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "tag")
|
||||
var tagMappings: MutableList<ChatCharacterTagMapping> = mutableListOf()
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* ChatCharacter와 ChatCharacterTag 간의 매핑 엔티티
|
||||
* ChatCharacterTag의 중복을 방지하기 위한 매핑 테이블
|
||||
*/
|
||||
@Entity
|
||||
class ChatCharacterTagMapping(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "tag_id")
|
||||
val tag: ChatCharacterTag
|
||||
) : BaseEntity()
|
||||
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.OneToMany
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
/**
|
||||
* 캐릭터 가치관
|
||||
*/
|
||||
|
||||
@Entity
|
||||
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["value"])])
|
||||
class ChatCharacterValue(
|
||||
@Column(nullable = false)
|
||||
val value: String
|
||||
) : BaseEntity() {
|
||||
@OneToMany(mappedBy = "value")
|
||||
var valueMappings: MutableList<ChatCharacterValueMapping> = mutableListOf()
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
|
||||
/**
|
||||
* ChatCharacter와 ChatCharacterValue 간의 매핑 엔티티
|
||||
* ChatCharacterValue의 중복을 방지하기 위한 매핑 테이블
|
||||
*/
|
||||
@Entity
|
||||
class ChatCharacterValueMapping(
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "chat_character_id")
|
||||
val chatCharacter: ChatCharacter,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "value_id")
|
||||
val value: ChatCharacterValue
|
||||
) : BaseEntity()
|
||||
@@ -1,118 +0,0 @@
|
||||
# ChatCharacter 엔티티 관계 구현
|
||||
|
||||
## 개요
|
||||
|
||||
이 구현은 ChatCharacter와 다른 엔티티들 간의 1:N 관계를 설정합니다. ChatCharacter가 저장될 때 관련 엔티티들도 함께 저장되며, Tag, Value, Hobby, Goal은 중복을 허용하지 않고 기존 내용과 동일한 내용이 들어오면 관계만 맺습니다.
|
||||
|
||||
## 엔티티 구조
|
||||
|
||||
### 주요 엔티티
|
||||
|
||||
- **ChatCharacter**: 메인 엔티티로, 캐릭터의 기본 정보를 저장합니다.
|
||||
- **ChatCharacterMemory**: 캐릭터의 기억 정보를 저장합니다.
|
||||
- **ChatCharacterPersonality**: 캐릭터의 성격 특성을 저장합니다.
|
||||
- **ChatCharacterBackground**: 캐릭터의 배경 정보를 저장합니다.
|
||||
- **ChatCharacterRelationship**: 캐릭터의 관계 정보를 저장합니다.
|
||||
|
||||
### 중복 방지를 위한 엔티티
|
||||
|
||||
- **ChatCharacterTag**: 캐릭터 태그 정보를 저장합니다. 태그 이름은 유일합니다.
|
||||
- **ChatCharacterValue**: 캐릭터 가치관 정보를 저장합니다. 가치관 이름은 유일합니다.
|
||||
- **ChatCharacterHobby**: 캐릭터 취미 정보를 저장합니다. 취미 이름은 유일합니다.
|
||||
- **ChatCharacterGoal**: 캐릭터 목표 정보를 저장합니다. 목표 이름은 유일합니다.
|
||||
|
||||
### 매핑 엔티티
|
||||
|
||||
- **ChatCharacterTagMapping**: ChatCharacter와 ChatCharacterTag 간의 관계를 맺습니다.
|
||||
- **ChatCharacterValueMapping**: ChatCharacter와 ChatCharacterValue 간의 관계를 맺습니다.
|
||||
- **ChatCharacterHobbyMapping**: ChatCharacter와 ChatCharacterHobby 간의 관계를 맺습니다.
|
||||
- **ChatCharacterGoalMapping**: ChatCharacter와 ChatCharacterGoal 간의 관계를 맺습니다.
|
||||
|
||||
## 관계 설정
|
||||
|
||||
- ChatCharacter와 Memory, Personality, Background, Relationship은 1:N 관계입니다.
|
||||
- ChatCharacter와 Tag, Value, Hobby, Goal은 매핑 엔티티를 통한 N:M 관계입니다.
|
||||
- 모든 관계는 ChatCharacter가 저장될 때 함께 저장됩니다(CascadeType.ALL).
|
||||
|
||||
## 서비스 기능
|
||||
|
||||
ChatCharacterService는 다음과 같은 기능을 제공합니다:
|
||||
|
||||
1. 캐릭터 생성 및 저장
|
||||
2. 캐릭터 조회 (UUID, 이름, 전체 목록)
|
||||
3. 캐릭터에 태그, 가치관, 취미, 목표 추가
|
||||
4. 캐릭터에 기억, 성격 특성, 배경 정보, 관계 추가
|
||||
5. 모든 정보를 포함한 캐릭터 생성
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```kotlin
|
||||
// 캐릭터 생성
|
||||
val chatCharacter = chatCharacterService.createChatCharacter(
|
||||
characterUUID = "uuid-123",
|
||||
name = "AI 어시스턴트",
|
||||
description = "친절한 AI 어시스턴트",
|
||||
systemPrompt = "당신은 친절한 AI 어시스턴트입니다."
|
||||
)
|
||||
|
||||
// 태그 추가
|
||||
chatCharacterService.addTagToCharacter(chatCharacter, "친절함")
|
||||
chatCharacterService.addTagToCharacter(chatCharacter, "도움")
|
||||
|
||||
// 가치관 추가
|
||||
chatCharacterService.addValueToCharacter(chatCharacter, "정직")
|
||||
|
||||
// 취미 추가
|
||||
chatCharacterService.addHobbyToCharacter(chatCharacter, "독서")
|
||||
|
||||
// 목표 추가
|
||||
chatCharacterService.addGoalToCharacter(chatCharacter, "사용자 만족")
|
||||
|
||||
// 기억 추가
|
||||
chatCharacterService.addMemoryToChatCharacter(
|
||||
chatCharacter,
|
||||
"첫 만남",
|
||||
"사용자와의 첫 대화",
|
||||
"기쁨"
|
||||
)
|
||||
|
||||
// 성격 특성 추가
|
||||
chatCharacterService.addPersonalityToChatCharacter(
|
||||
chatCharacter,
|
||||
"친절함",
|
||||
"항상 친절하게 대응합니다."
|
||||
)
|
||||
|
||||
// 배경 정보 추가
|
||||
chatCharacterService.addBackgroundToChatCharacter(
|
||||
chatCharacter,
|
||||
"생성 배경",
|
||||
"사용자를 돕기 위해 만들어졌습니다."
|
||||
)
|
||||
|
||||
// 관계 추가
|
||||
chatCharacterService.addRelationshipToChatCharacter(
|
||||
chatCharacter,
|
||||
"사용자와의 관계: 도우미"
|
||||
)
|
||||
|
||||
// 모든 정보를 포함한 캐릭터 생성
|
||||
val completeCharacter = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "uuid-456",
|
||||
name = "종합 AI",
|
||||
description = "모든 정보를 가진 AI",
|
||||
systemPrompt = "당신은 모든 정보를 가진 AI입니다.",
|
||||
tags = listOf("종합", "지식"),
|
||||
values = listOf("정확성", "유용성"),
|
||||
hobbies = listOf("학습", "정보 수집"),
|
||||
goals = listOf("정확한 정보 제공"),
|
||||
memories = listOf(Triple("학습 시작", "처음 학습을 시작했습니다.", "호기심")),
|
||||
personalities = listOf(Pair("분석적", "정보를 분석적으로 처리합니다.")),
|
||||
backgrounds = listOf(Pair("개발 목적", "정보 제공을 위해 개발되었습니다.")),
|
||||
relationships = listOf("사용자와의 관계: 정보 제공자")
|
||||
)
|
||||
```
|
||||
|
||||
## 중복 방지 메커니즘
|
||||
|
||||
Tag, Value, Hobby, Goal 엔티티는 각각 고유한 필드(tag, value, hobby, goal)에 대해 유니크 제약 조건을 가지고 있습니다. 서비스 레이어에서는 이미 존재하는 엔티티를 찾아 재사용하거나, 존재하지 않는 경우 새로 생성합니다. 이를 통해 중복을 방지하고 관계만 맺을 수 있습니다.
|
||||
@@ -1,39 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
import javax.persistence.OneToMany
|
||||
import javax.persistence.Table
|
||||
|
||||
@Entity
|
||||
@Table(name = "character_comment")
|
||||
data class CharacterComment(
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var comment: String,
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "parent_id", nullable = true)
|
||||
var parent: CharacterComment? = null
|
||||
set(value) {
|
||||
value?.children?.add(this)
|
||||
field = value
|
||||
}
|
||||
|
||||
@OneToMany(mappedBy = "parent")
|
||||
var children: MutableList<CharacterComment> = mutableListOf()
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
var member: Member? = null
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "character_id", nullable = false)
|
||||
var chatCharacter: ChatCharacter? = null
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/chat/character")
|
||||
class CharacterCommentController(
|
||||
private val service: CharacterCommentService,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
|
||||
@PostMapping("/{characterId}/comments")
|
||||
fun createComment(
|
||||
@PathVariable characterId: Long,
|
||||
@RequestBody request: CreateCharacterCommentRequest,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val id = service.addComment(characterId, member, request.comment)
|
||||
ApiResponse.ok(id)
|
||||
}
|
||||
|
||||
@PostMapping("/{characterId}/comments/{commentId}/replies")
|
||||
fun createReply(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@RequestBody request: CreateCharacterCommentRequest,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val id = service.addReply(characterId, commentId, member, request.comment)
|
||||
ApiResponse.ok(id)
|
||||
}
|
||||
|
||||
@GetMapping("/{characterId}/comments")
|
||||
fun listComments(
|
||||
@PathVariable characterId: Long,
|
||||
@RequestParam(required = false, defaultValue = "20") limit: Int,
|
||||
@RequestParam(required = false) cursor: Long?,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val data = service.listComments(imageHost, characterId, cursor, limit)
|
||||
ApiResponse.ok(data)
|
||||
}
|
||||
|
||||
@GetMapping("/{characterId}/comments/{commentId}/replies")
|
||||
fun listReplies(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@RequestParam(required = false, defaultValue = "20") limit: Int,
|
||||
@RequestParam(required = false) cursor: Long?,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
// characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨
|
||||
val data = service.getReplies(imageHost, commentId, cursor, limit)
|
||||
ApiResponse.ok(data)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{characterId}/comments/{commentId}")
|
||||
fun deleteComment(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
service.deleteComment(characterId, commentId, member)
|
||||
ApiResponse.ok(true, "댓글이 삭제되었습니다.")
|
||||
}
|
||||
|
||||
@PostMapping("/{characterId}/comments/{commentId}/reports")
|
||||
fun reportComment(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@RequestBody request: ReportCharacterCommentRequest,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
service.reportComment(characterId, commentId, member, request.content)
|
||||
ApiResponse.ok(true, "신고가 접수되었습니다.")
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
// Request DTOs
|
||||
data class CreateCharacterCommentRequest(
|
||||
val comment: String
|
||||
)
|
||||
|
||||
// Response DTOs
|
||||
// 댓글 Response
|
||||
// - 댓글 ID
|
||||
// - 댓글 쓴 Member 프로필 이미지
|
||||
// - 댓글 쓴 Member 닉네임
|
||||
// - 댓글 쓴 시간 timestamp(long)
|
||||
// - 답글 수
|
||||
|
||||
data class CharacterCommentResponse(
|
||||
val commentId: Long,
|
||||
val memberId: Long,
|
||||
val memberProfileImage: String,
|
||||
val memberNickname: String,
|
||||
val createdAt: Long,
|
||||
val replyCount: Int,
|
||||
val comment: String
|
||||
)
|
||||
|
||||
// 답글 Response 단건(목록 원소)
|
||||
// - 답글 ID
|
||||
// - 답글 쓴 Member 프로필 이미지
|
||||
// - 답글 쓴 Member 닉네임
|
||||
// - 답글 쓴 시간 timestamp(long)
|
||||
|
||||
data class CharacterReplyResponse(
|
||||
val replyId: Long,
|
||||
val memberId: Long,
|
||||
val memberProfileImage: String,
|
||||
val memberNickname: String,
|
||||
val createdAt: Long,
|
||||
val comment: String
|
||||
)
|
||||
|
||||
// 댓글의 답글 조회 Response 컨테이너
|
||||
// - 원본 댓글 Response
|
||||
// - 답글 목록(위 사양의 필드 포함)
|
||||
|
||||
data class CharacterCommentRepliesResponse(
|
||||
val original: CharacterCommentResponse,
|
||||
val replies: List<CharacterReplyResponse>,
|
||||
val cursor: Long?
|
||||
)
|
||||
|
||||
// 댓글 리스트 조회 Response 컨테이너
|
||||
// - 전체 댓글 개수(totalCount)
|
||||
// - 댓글 목록(comments)
|
||||
|
||||
data class CharacterCommentListResponse(
|
||||
val totalCount: Int,
|
||||
val comments: List<CharacterCommentResponse>,
|
||||
val cursor: Long?
|
||||
)
|
||||
|
||||
// 신고 Request
|
||||
data class ReportCharacterCommentRequest(
|
||||
val content: String
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user