Compare commits
337 Commits
test
...
a27852ed44
| Author | SHA1 | Date | |
|---|---|---|---|
| a27852ed44 | |||
| c7925c1706 | |||
| be59bd7e89 | |||
| 51ce143fc2 | |||
| 89eb11f808 | |||
| 30d89987a4 | |||
| 7959d3e5ed | |||
| 1e29573ef7 | |||
| cc2f533dc6 | |||
| 32b0c19f9d | |||
| 9af2d768e8 | |||
| 5677824cde | |||
| e8f1bc09f9 | |||
| d1a936d55b | |||
| dc97eaa835 | |||
| dcbe57806c | |||
| b14438cc15 | |||
| b27d3bd5c6 | |||
| 03ebc9cfe9 | |||
| 24841b9850 | |||
| d35a3d1a8c | |||
| 60c4e0b528 | |||
| 84f33d1bc2 | |||
| c4e1709b99 | |||
| e7a5fd5819 | |||
| 4bde03643c | |||
| 1bc52b56af | |||
| 9c33fd93f7 | |||
| 3c087bc275 | |||
| 8ad13c289e | |||
| 7577f48a09 | |||
| 0251906964 | |||
| 2723a5f134 | |||
| c3c60605fd | |||
| 238f704b22 | |||
| 5639d8ac8e | |||
| 9aac591591 | |||
| ffa8e5aebb | |||
| cbbfe014cc | |||
| 83028f7817 | |||
| 70d1795557 | |||
| 8c6c681424 | |||
| 50bc9f4ff3 | |||
| f00ea03fad | |||
| f22e7b9ad1 | |||
| c7ec95f4bb | |||
| 229e7a8ccc | |||
| 3c616474ff | |||
| 56eb6b3ce3 | |||
| 545836d43c | |||
| 219f83dec0 | |||
| a76a841238 | |||
| c26680de84 | |||
| 8fffad9d3a | |||
| f4f0f203a2 | |||
| b7196f5a0c | |||
| 5d33a18890 | |||
| 96186a1a50 | |||
| bc8bc479d1 | |||
| 47595b1291 | |||
| 01a88964df | |||
| 3a2b77379f | |||
| dc4e5f75cd | |||
| d0178d551c | |||
| 827333108d | |||
| 587b90bd27 | |||
| 4dc20c5e90 | |||
| ac25782f2b | |||
| 20437d56e7 | |||
| f0b412828a | |||
| 367faac5c3 | |||
| 84deaaa970 | |||
| a2b39466c2 | |||
| 03586c4005 | |||
| 6ea69e1510 | |||
| 553c6dc539 | |||
| 6cc22f5b6d | |||
| 9103d67cc1 | |||
| 25083fb0e4 | |||
| d2dc045255 | |||
| b8621dfbb0 | |||
| 93633940dd | |||
| b6f5325351 | |||
| 7c32c08f1f | |||
| 1d268da08d | |||
| 797666ae0d | |||
| dcf470997e | |||
| 0974d1dbf8 | |||
| 12a35db6cd | |||
| 9abbb05ad8 | |||
| 1ecaf69b0b | |||
| e334d1e5d9 | |||
| b735e861d0 | |||
| 4eb433d372 | |||
| 2416ae61f3 | |||
| 01fb336985 | |||
| b6af88a732 | |||
| 58a2a17d6d | |||
| 79f5a0f520 | |||
| 7f6c0f7f04 | |||
| f658df4dca | |||
| 9d43b8e23a | |||
| 4270aef79b | |||
| 1c0dc82d44 | |||
| c1e325aadf | |||
| cec87da69d | |||
| f68f24cb2c | |||
| ed094347fc | |||
| b8afdffbe1 | |||
| f6ba79f31c | |||
| 5f3b1663d2 | |||
| 66e786b4bb | |||
| f671114574 | |||
| ce37060d94 | |||
| 7d19a4d184 | |||
| 22f28a2f8a | |||
| ceef9ca979 | |||
| efe8f4f939 | |||
| ba692a1195 | |||
| d732bad042 | |||
| 4c935c3bee | |||
| c160dd791f | |||
| 23cd1b4601 | |||
| 031fc8ba1b | |||
| c6853289ad | |||
| 2497bb69bc | |||
| a58a67e0a2 | |||
| 4315fe12a5 | |||
| 42f10a8899 | |||
| 1e4b47f989 | |||
| ff255dbfae | |||
| dbe9b72feb | |||
| 95a714b391 | |||
| 28f58c7f56 | |||
| 8bd46d8f21 | |||
| e1bb8e54ed | |||
| 1de705b063 | |||
| f6926ad356 | |||
| 2cdbbb1b37 | |||
| 4dce8c8f03 | |||
| 97a5bace6f | |||
| d4d51ec48f | |||
| fb91398462 | |||
| 105dadd798 | |||
| 2abf2837d3 | |||
| 422aa67af6 | |||
| 7fffab6985 | |||
| 5a4be3d2c1 | |||
| f39a7681db | |||
| c60a7580ba | |||
| 97edb56edc | |||
| 6ebca8d22b | |||
| 95371ad934 | |||
| 2c176825fd | |||
| fae7de48d3 | |||
| b8230646a2 | |||
| 43279541dd | |||
| b4791977c1 | |||
| ef917ecc25 | |||
| a93faad951 | |||
| fd001d24d3 | |||
| 7aa5884797 | |||
| 5b237a1547 | |||
| 2e37990d87 | |||
| dd07d724a8 | |||
| 03ce8618e7 | |||
| db1a7a7fd6 | |||
| 36a82d7f53 | |||
| 3a34401113 | |||
| 9927268330 | |||
| c45c97e29d | |||
| c64a315226 | |||
| a4cafca6ab | |||
| 46284a0660 | |||
| 05df86e15a | |||
| 8b433027e2 | |||
| 5bd4ff7610 | |||
| d693c397ea | |||
| 1d8d1ec9a5 | |||
| 5e491f11ee | |||
| 7cedea06ac | |||
| 2e5f750e50 | |||
| 20289cad10 | |||
| e0d64c31c7 | |||
| 8c1b95dc97 | |||
| fb5641343e | |||
| 87765941eb | |||
| 1809862c16 | |||
| 300f784f7d | |||
| 67a045eae6 | |||
| 2a79903a28 | |||
| d3222ce083 | |||
| 406a421742 | |||
| 10bf728faf | |||
| 607617747c | |||
| f0a69eb1a2 | |||
| 6b307a6e17 | |||
| 08d08a934a | |||
| c500c12668 | |||
| 62060adeba | |||
| b2fc75edb8 | |||
| a999dd2085 | |||
| 49f95ab100 | |||
| 1a84d5b30c | |||
| 3b65050632 | |||
| d0df31674c | |||
| 1fe88402e2 | |||
| 67097696e6 | |||
| 8e7e77067a | |||
| 9899390b61 | |||
| 80c476a908 | |||
| 59da1d6e49 | |||
| 5aef7dac33 | |||
| faf7aa06b6 | |||
| 38ef6e5583 | |||
| c0b15b5d94 | |||
| 2cfc067ea1 | |||
| a91db4f956 | |||
| 8a09780a02 | |||
| 45e8ec6505 | |||
| 4554b85914 | |||
| 8aa79c4a9c | |||
| c8d3210b57 | |||
| 2282a49563 | |||
| b82fdfb2c8 | |||
| 2d17eac199 | |||
| e482bc3aad | |||
| ec022b74d1 | |||
| dc42c09ce3 | |||
| 046a34d2a4 | |||
| 9ff6ec1888 | |||
| d2950106ec | |||
| 962f800d2e | |||
| 962107e507 | |||
| 039bd11963 | |||
| 5c250ea4ae | |||
| e3405bcec6 | |||
| 0fd1c2235f | |||
| b20c29b022 | |||
| 12d5dcd298 | |||
| 2c305dc6c6 | |||
| 62f76f7433 | |||
| 858ce524f9 | |||
| 3795fb4a40 | |||
| 0c01aeec50 | |||
| 892206744d | |||
| 9e2c1474db | |||
| 16328f73d9 | |||
| e0d4f53cf4 | |||
| e09a59c5b4 | |||
| 049e654535 | |||
| c927dc4ecd | |||
| fe4ecd0ad8 | |||
| 78d476fe80 | |||
| a11c8465d5 | |||
| 366304a9b7 | |||
| 4356663688 | |||
| 26b55e6fcf | |||
| 0d743f7204 | |||
| 6cbe113b3e | |||
| 6409b69d6c | |||
| c5164c76fc | |||
| baade8e138 | |||
| b848d6b4e0 | |||
| d8139d2ab0 | |||
| e96d8f7469 | |||
| 2acffd8afc | |||
| 3c8e72073c | |||
| 724d7a9d9b | |||
| 2da3b0db78 | |||
| 685ad7afaf | |||
| 264cf75964 | |||
| c773dbc7b5 | |||
| 37cbc64f52 | |||
| cb1dde17bb | |||
| c29988acf4 | |||
| eadbf56dae | |||
| 4b3b455135 | |||
| e6ac177396 | |||
| 3d0e29003f | |||
| 78b9b00f77 | |||
| 0ee7faa551 | |||
| e5fdced681 | |||
| afb99fef64 | |||
| 7dfaa36024 | |||
| 0496f665aa | |||
| 0d19e1be74 | |||
| 4aff0111aa | |||
| 63b3ba2bb2 | |||
| 7444b41f60 | |||
| 8e90dbc8b6 | |||
| 9f70722521 | |||
| 52fae596fa | |||
| ccb67957bc | |||
| fb82538d0d | |||
| 72ee39612e | |||
| 51fd5408dc | |||
| 3fae40fbef | |||
| 0745890af0 | |||
| 4abe1730a7 | |||
| 626f0e6989 | |||
| 9f42d9d173 | |||
| f90a93c4bc | |||
| 8000ad6c6a | |||
| 1f1f1bea1a | |||
| d95460c7cd | |||
| a3d93d4b08 | |||
| 07a92af982 | |||
| f4618877d4 | |||
| 2b914fd222 | |||
| 109e42a5a3 | |||
| fa515ad39c | |||
| f09673a795 | |||
| f71536c614 | |||
| 7bdddc7ae8 | |||
| aa8926a624 | |||
| be71e59be2 | |||
| 4d7753378f | |||
| 60257c4ef4 | |||
| 1e0b79bf62 | |||
| 6883434d0d | |||
| eda2193e64 | |||
| 99bf829c88 | |||
| 5feafe1b48 | |||
| c9292b7d04 | |||
| ae7e1a91c1 | |||
| 3e1887e0d1 | |||
| 474646db47 | |||
| 56f7b6c449 | |||
| 76b2b5f7e3 | |||
| e918d809eb | |||
| 7af059e543 | |||
| 897726e1ec | |||
| 8b98a2dd07 | |||
| cca75420f0 | |||
| 86c627ed1d | |||
| d55514e3a7 |
@@ -7,5 +7,5 @@ indent_size = 4
|
|||||||
indent_style = space
|
indent_style = space
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 130
|
max_line_length = 120
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -78,10 +75,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -148,10 +142,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(order.isActive.isTrue)
|
.where(order.isActive.isTrue)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
member.id,
|
member.id,
|
||||||
@@ -239,10 +230,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -263,10 +251,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -296,10 +281,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -319,10 +301,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -352,10 +331,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -375,10 +351,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -409,10 +382,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import java.time.LocalDateTime
|
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.FetchType
|
import javax.persistence.FetchType
|
||||||
import javax.persistence.JoinColumn
|
import javax.persistence.JoinColumn
|
||||||
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
|
|||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
data class CreatorSettlementRatio(
|
data class CreatorSettlementRatio(
|
||||||
var subsidy: Int,
|
val subsidy: Int,
|
||||||
var liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
var contentSettlementRatio: Int,
|
val contentSettlementRatio: Int,
|
||||||
var communitySettlementRatio: Int
|
val communitySettlementRatio: Int
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "member_id", nullable = false)
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
var member: Member? = null
|
var member: Member? = null
|
||||||
|
|
||||||
var deletedAt: LocalDateTime? = null
|
|
||||||
|
|
||||||
fun softDelete() {
|
|
||||||
this.deletedAt = LocalDateTime.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restore() {
|
|
||||||
this.deletedAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
|
|
||||||
this.subsidy = subsidy
|
|
||||||
this.liveSettlementRatio = live
|
|
||||||
this.contentSettlementRatio = content
|
|
||||||
this.communitySettlementRatio = community
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
@@ -28,14 +27,4 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
|
|||||||
limit = pageable.pageSize.toLong()
|
limit = pageable.pageSize.toLong()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@PostMapping("/update")
|
|
||||||
fun updateCreatorSettlementRatio(
|
|
||||||
@RequestBody request: CreateCreatorSettlementRatioRequest
|
|
||||||
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
|
|
||||||
|
|
||||||
@PostMapping("/delete/{memberId}")
|
|
||||||
fun deleteCreatorSettlementRatio(
|
|
||||||
@PathVariable memberId: Long
|
|
||||||
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
|
|
||||||
interface CreatorSettlementRatioRepository :
|
interface CreatorSettlementRatioRepository :
|
||||||
JpaRepository<CreatorSettlementRatio, Long>,
|
JpaRepository<CreatorSettlementRatio, Long>,
|
||||||
CreatorSettlementRatioQueryRepository {
|
CreatorSettlementRatioQueryRepository
|
||||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreatorSettlementRatioQueryRepository {
|
interface CreatorSettlementRatioQueryRepository {
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||||
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCreatorSettlementRatioItem(
|
QGetCreatorSettlementRatioItem(
|
||||||
member.id,
|
|
||||||
member.nickname,
|
member.nickname,
|
||||||
creatorSettlementRatio.subsidy,
|
creatorSettlementRatio.subsidy,
|
||||||
creatorSettlementRatio.liveSettlementRatio,
|
creatorSettlementRatio.liveSettlementRatio,
|
||||||
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
)
|
)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.innerJoin(creatorSettlementRatio.member, member)
|
.innerJoin(creatorSettlementRatio.member, member)
|
||||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
.orderBy(creatorSettlementRatio.id.asc())
|
.orderBy(creatorSettlementRatio.id.asc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(creatorSettlementRatio.id)
|
.select(creatorSettlementRatio.id)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class CreatorSettlementRatioService(
|
|||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
|
val creatorSettlementRatio = request.toEntity()
|
||||||
|
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
|
||||||
@@ -21,52 +23,10 @@ class CreatorSettlementRatioService(
|
|||||||
throw SodaException("잘못된 크리에이터 입니다.")
|
throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
val existing = repository.findByMemberId(request.memberId)
|
|
||||||
if (existing != null) {
|
|
||||||
// revive if soft-deleted, then update values
|
|
||||||
existing.restore()
|
|
||||||
existing.updateValues(
|
|
||||||
request.subsidy,
|
|
||||||
request.liveSettlementRatio,
|
|
||||||
request.contentSettlementRatio,
|
|
||||||
request.communitySettlementRatio
|
|
||||||
)
|
|
||||||
repository.save(existing)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val creatorSettlementRatio = request.toEntity()
|
|
||||||
creatorSettlementRatio.member = creator
|
creatorSettlementRatio.member = creator
|
||||||
repository.save(creatorSettlementRatio)
|
repository.save(creatorSettlementRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
|
||||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
|
||||||
if (creator.role != MemberRole.CREATOR) {
|
|
||||||
throw SodaException("잘못된 크리에이터 입니다.")
|
|
||||||
}
|
|
||||||
val existing = repository.findByMemberId(request.memberId)
|
|
||||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
|
||||||
existing.restore()
|
|
||||||
existing.updateValues(
|
|
||||||
request.subsidy,
|
|
||||||
request.liveSettlementRatio,
|
|
||||||
request.contentSettlementRatio,
|
|
||||||
request.communitySettlementRatio
|
|
||||||
)
|
|
||||||
repository.save(existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun deleteCreatorSettlementRatio(memberId: Long) {
|
|
||||||
val existing = repository.findByMemberId(memberId)
|
|
||||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
|
||||||
existing.softDelete()
|
|
||||||
repository.save(existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ data class GetCreatorSettlementRatioResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||||
val memberId: Long,
|
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val subsidy: Int,
|
val subsidy: Int,
|
||||||
val liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
data class AdminCanChargeRequest(
|
data class AdminCanChargeRequest(
|
||||||
val memberIds: List<Long>,
|
val memberId: Long,
|
||||||
val method: String,
|
val method: String,
|
||||||
val can: Int
|
val can: Int
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.can.CanResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
@@ -15,11 +13,6 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
@RequestMapping("/admin/can")
|
@RequestMapping("/admin/can")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
class AdminCanController(private val service: AdminCanService) {
|
class AdminCanController(private val service: AdminCanService) {
|
||||||
@GetMapping
|
|
||||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
|
||||||
return ApiResponse.ok(service.getCans())
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
|
||||||
import kr.co.vividnext.sodalive.can.Can
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
import kr.co.vividnext.sodalive.can.CanResponse
|
|
||||||
import kr.co.vividnext.sodalive.can.CanStatus
|
|
||||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
|
||||||
import kr.co.vividnext.sodalive.can.QCanResponse
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
|
|
||||||
interface AdminCanRepository : JpaRepository<Can, Long>, AdminCanQueryRepository
|
interface AdminCanRepository : JpaRepository<Can, Long>
|
||||||
|
|
||||||
interface AdminCanQueryRepository {
|
|
||||||
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
|
||||||
}
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
class AdminCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminCanQueryRepository {
|
|
||||||
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
|
||||||
return queryFactory
|
|
||||||
.select(
|
|
||||||
QCanResponse(
|
|
||||||
can1.id,
|
|
||||||
can1.title,
|
|
||||||
can1.can,
|
|
||||||
can1.rewardCan,
|
|
||||||
can1.price.intValue(),
|
|
||||||
can1.currency,
|
|
||||||
can1.price.stringValue()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.from(can1)
|
|
||||||
.where(can1.status.eq(status))
|
|
||||||
.orderBy(can1.currency.asc(), can1.price.asc())
|
|
||||||
.fetch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ package kr.co.vividnext.sodalive.admin.can
|
|||||||
import kr.co.vividnext.sodalive.can.Can
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
import kr.co.vividnext.sodalive.can.CanStatus
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class AdminCanRequest(
|
data class AdminCanRequest(
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val rewardCan: Int,
|
val rewardCan: Int,
|
||||||
val price: BigDecimal,
|
val price: Int
|
||||||
val currency: String
|
|
||||||
) {
|
) {
|
||||||
fun toEntity(): Can {
|
fun toEntity(): Can {
|
||||||
var title = "${can.moneyFormat()} 캔"
|
var title = "${can.moneyFormat()} 캔"
|
||||||
@@ -22,7 +20,6 @@ data class AdminCanRequest(
|
|||||||
can = can,
|
can = can,
|
||||||
rewardCan = rewardCan,
|
rewardCan = rewardCan,
|
||||||
price = price,
|
price = price,
|
||||||
currency = currency,
|
|
||||||
status = CanStatus.SALE
|
status = CanStatus.SALE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
||||||
import kr.co.vividnext.sodalive.can.CanResponse
|
|
||||||
import kr.co.vividnext.sodalive.can.CanStatus
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||||
@@ -21,10 +20,6 @@ class AdminCanService(
|
|||||||
private val chargeRepository: ChargeRepository,
|
private val chargeRepository: ChargeRepository,
|
||||||
private val memberRepository: AdminMemberRepository
|
private val memberRepository: AdminMemberRepository
|
||||||
) {
|
) {
|
||||||
fun getCans(): List<CanResponse> {
|
|
||||||
return repository.findAllByStatus(status = CanStatus.SALE)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun saveCan(request: AdminCanRequest) {
|
fun saveCan(request: AdminCanRequest) {
|
||||||
repository.save(request.toEntity())
|
repository.save(request.toEntity())
|
||||||
@@ -40,27 +35,22 @@ class AdminCanService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun charge(request: AdminCanChargeRequest) {
|
fun charge(request: AdminCanChargeRequest) {
|
||||||
|
val member = memberRepository.findByIdOrNull(request.memberId)
|
||||||
|
?: throw SodaException("잘못된 회원번호 입니다.")
|
||||||
|
|
||||||
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
|
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
|
||||||
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
|
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
|
||||||
|
|
||||||
val ids = request.memberIds.distinct()
|
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||||
if (ids.isEmpty()) throw SodaException("회원번호를 입력하세요.")
|
charge.title = "${request.can.moneyFormat()} 캔"
|
||||||
|
charge.member = member
|
||||||
|
|
||||||
val members = memberRepository.findAllById(ids).toList()
|
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||||
if (members.size != ids.size) throw SodaException("잘못된 회원번호 입니다.")
|
payment.method = request.method
|
||||||
|
charge.payment = payment
|
||||||
|
|
||||||
members.forEach { member ->
|
chargeRepository.save(charge)
|
||||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
|
||||||
charge.title = "${request.can.moneyFormat()} 캔"
|
|
||||||
charge.member = member
|
|
||||||
|
|
||||||
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
member.pgRewardCan += charge.rewardCan
|
||||||
payment.method = request.method
|
|
||||||
charge.payment = payment
|
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
|
||||||
|
|
||||||
member.pgRewardCan += charge.rewardCan
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
|
|||||||
@GetMapping("/detail")
|
@GetMapping("/detail")
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@RequestParam paymentGateway: PaymentGateway,
|
@RequestParam paymentGateway: PaymentGateway
|
||||||
@RequestParam(value = "currency", required = false) currency: String? = null
|
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
|
||||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import com.querydsl.core.BooleanBuilder
|
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||||
@@ -15,7 +14,7 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
|
||||||
val formattedDate = Expressions.stringTemplate(
|
val formattedDate = Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
Expressions.dateTimeTemplate(
|
Expressions.dateTimeTemplate(
|
||||||
@@ -27,16 +26,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
),
|
),
|
||||||
"%Y-%m-%d"
|
"%Y-%m-%d"
|
||||||
)
|
)
|
||||||
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
|
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetChargeStatusResponse(
|
QGetChargeStatusQueryDto(
|
||||||
formattedDate,
|
formattedDate,
|
||||||
payment.price.sum(),
|
payment.price.sum(),
|
||||||
|
can1.price.sum(),
|
||||||
payment.id.count(),
|
payment.id.count(),
|
||||||
payment.paymentGateway.stringValue(),
|
payment.paymentGateway
|
||||||
currency.coalesce("KRW")
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(payment)
|
.from(payment)
|
||||||
@@ -48,46 +46,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
)
|
)
|
||||||
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
|
.groupBy(formattedDate, payment.paymentGateway)
|
||||||
.orderBy(formattedDate.desc())
|
.orderBy(formattedDate.desc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChargeStatusSummary(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
|
||||||
val currency = Expressions.stringTemplate(
|
|
||||||
"substring({0}, length({0}) - 2, 3)",
|
|
||||||
payment.locale
|
|
||||||
).coalesce("KRW")
|
|
||||||
|
|
||||||
return queryFactory
|
|
||||||
.select(
|
|
||||||
QGetChargeStatusResponse(
|
|
||||||
Expressions.stringTemplate("'합계'"), // date
|
|
||||||
payment.price.sum(),
|
|
||||||
payment.id.count(),
|
|
||||||
Expressions.stringTemplate("''"),
|
|
||||||
currency
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.from(payment)
|
|
||||||
.innerJoin(payment.charge, charge)
|
|
||||||
.leftJoin(charge.can, can1)
|
|
||||||
.where(
|
|
||||||
charge.createdAt.goe(startDate)
|
|
||||||
.and(charge.createdAt.loe(endDate))
|
|
||||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
|
||||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
|
||||||
)
|
|
||||||
.groupBy(currency)
|
|
||||||
.orderBy(currency.asc())
|
|
||||||
.fetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
startDate: LocalDateTime,
|
startDate: LocalDateTime,
|
||||||
endDate: LocalDateTime,
|
endDate: LocalDateTime,
|
||||||
paymentGateway: PaymentGateway,
|
paymentGateway: PaymentGateway
|
||||||
currency: String? = null
|
|
||||||
): List<GetChargeStatusDetailQueryDto> {
|
): List<GetChargeStatusDetailQueryDto> {
|
||||||
val formattedDate = Expressions.stringTemplate(
|
val formattedDate = Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
@@ -100,20 +67,6 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
),
|
),
|
||||||
"%Y-%m-%d %H:%i:%s"
|
"%Y-%m-%d %H:%i:%s"
|
||||||
)
|
)
|
||||||
val currencyExpr = Expressions.stringTemplate(
|
|
||||||
"substring({0}, length({0}) - 2, 3)",
|
|
||||||
payment.locale
|
|
||||||
).coalesce("KRW")
|
|
||||||
val whereBuilder = BooleanBuilder()
|
|
||||||
whereBuilder.and(charge.createdAt.goe(startDate))
|
|
||||||
.and(charge.createdAt.loe(endDate))
|
|
||||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
|
||||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
|
||||||
.and(payment.paymentGateway.eq(paymentGateway))
|
|
||||||
|
|
||||||
if (currency != null) {
|
|
||||||
whereBuilder.and(currencyExpr.eq(currency))
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
@@ -122,7 +75,8 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
member.nickname,
|
member.nickname,
|
||||||
payment.method.coalesce(""),
|
payment.method.coalesce(""),
|
||||||
payment.price,
|
payment.price,
|
||||||
currencyExpr,
|
can1.price,
|
||||||
|
payment.locale.coalesce(""),
|
||||||
formattedDate
|
formattedDate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -130,7 +84,13 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
.innerJoin(charge.member, member)
|
.innerJoin(charge.member, member)
|
||||||
.innerJoin(charge.payment, payment)
|
.innerJoin(charge.payment, payment)
|
||||||
.leftJoin(charge.can, can1)
|
.leftJoin(charge.can, can1)
|
||||||
.where(whereBuilder)
|
.where(
|
||||||
|
charge.createdAt.goe(startDate)
|
||||||
|
.and(charge.createdAt.loe(endDate))
|
||||||
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
|
.and(payment.paymentGateway.eq(paymentGateway))
|
||||||
|
)
|
||||||
.orderBy(formattedDate.desc())
|
.orderBy(formattedDate.desc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,17 +20,48 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
|
var totalChargeAmount = 0
|
||||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
|
var totalChargeCount = 0L
|
||||||
chargeStatusList.addAll(0, summaryRows)
|
|
||||||
|
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
|
||||||
|
.asSequence()
|
||||||
|
.map {
|
||||||
|
val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) {
|
||||||
|
it.pgChargeAmount
|
||||||
|
} else {
|
||||||
|
it.appleChargeAmount.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
val chargeCount = it.chargeCount
|
||||||
|
|
||||||
|
totalChargeAmount += chargeAmount
|
||||||
|
totalChargeCount += chargeCount
|
||||||
|
|
||||||
|
GetChargeStatusResponse(
|
||||||
|
date = it.date,
|
||||||
|
chargeAmount = chargeAmount,
|
||||||
|
chargeCount = chargeCount,
|
||||||
|
pg = it.paymentGateWay.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toMutableList()
|
||||||
|
|
||||||
|
chargeStatusList.add(
|
||||||
|
0,
|
||||||
|
GetChargeStatusResponse(
|
||||||
|
date = "합계",
|
||||||
|
chargeAmount = totalChargeAmount,
|
||||||
|
chargeCount = totalChargeCount,
|
||||||
|
pg = ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return chargeStatusList.toList()
|
return chargeStatusList.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
startDateStr: String,
|
startDateStr: String,
|
||||||
paymentGateway: PaymentGateway,
|
paymentGateway: PaymentGateway
|
||||||
currency: String? = null
|
|
||||||
): List<GetChargeStatusDetailResponse> {
|
): List<GetChargeStatusDetailResponse> {
|
||||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||||
@@ -43,16 +74,18 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
|
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
|
||||||
|
.asSequence()
|
||||||
.map {
|
.map {
|
||||||
GetChargeStatusDetailResponse(
|
GetChargeStatusDetailResponse(
|
||||||
memberId = it.memberId,
|
memberId = it.memberId,
|
||||||
nickname = it.nickname,
|
nickname = it.nickname,
|
||||||
method = it.method,
|
method = it.method,
|
||||||
amount = it.amount,
|
amount = it.appleChargeAmount.toInt(),
|
||||||
locale = it.locale,
|
locale = it.locale,
|
||||||
datetime = it.datetime
|
datetime = it.datetime
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val method: String,
|
val method: String,
|
||||||
val amount: BigDecimal,
|
val appleChargeAmount: Double,
|
||||||
|
val pgChargeAmount: Int,
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: String
|
val datetime: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class GetChargeStatusDetailResponse(
|
data class GetChargeStatusDetailResponse(
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val method: String,
|
val method: String,
|
||||||
val amount: BigDecimal,
|
val amount: Int,
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: String
|
val datetime: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
|
||||||
|
data class GetChargeStatusQueryDto @QueryProjection constructor(
|
||||||
|
val date: String,
|
||||||
|
val appleChargeAmount: Double,
|
||||||
|
val pgChargeAmount: Int,
|
||||||
|
val chargeCount: Long,
|
||||||
|
val paymentGateWay: PaymentGateway
|
||||||
|
)
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
data class GetChargeStatusResponse(
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class GetChargeStatusResponse @QueryProjection constructor(
|
|
||||||
val date: String,
|
val date: String,
|
||||||
val chargeAmount: BigDecimal,
|
val chargeAmount: Int,
|
||||||
val chargeCount: Long,
|
val chargeCount: Long,
|
||||||
val pg: String,
|
val pg: String
|
||||||
val currency: String
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import org.springframework.data.domain.Pageable
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@RequestMapping("/admin/chat/calculate")
|
|
||||||
class AdminChatCalculateController(
|
|
||||||
private val service: AdminChatCalculateService
|
|
||||||
) {
|
|
||||||
@GetMapping("/characters")
|
|
||||||
fun getCharacterCalculate(
|
|
||||||
@RequestParam startDateStr: String,
|
|
||||||
@RequestParam endDateStr: String,
|
|
||||||
@RequestParam(required = false, defaultValue = "TOTAL_SALES_DESC") sort: ChatCharacterCalculateSort,
|
|
||||||
pageable: Pageable
|
|
||||||
) = ApiResponse.ok(
|
|
||||||
service.getCharacterCalculate(
|
|
||||||
startDateStr,
|
|
||||||
endDateStr,
|
|
||||||
sort,
|
|
||||||
pageable.offset,
|
|
||||||
pageable.pageSize
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
|
||||||
|
|
||||||
import com.querydsl.core.types.Projections
|
|
||||||
import com.querydsl.core.types.dsl.CaseBuilder
|
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
|
||||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
|
||||||
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
class AdminChatCalculateQueryRepository(
|
|
||||||
private val queryFactory: JPAQueryFactory,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
|
||||||
private val imageHost: String
|
|
||||||
) {
|
|
||||||
fun getCharacterCalculate(
|
|
||||||
startUtc: LocalDateTime,
|
|
||||||
endInclusiveUtc: LocalDateTime,
|
|
||||||
sort: ChatCharacterCalculateSort,
|
|
||||||
offset: Long,
|
|
||||||
limit: Long
|
|
||||||
): List<ChatCharacterCalculateQueryData> {
|
|
||||||
val imageCanExpr = CaseBuilder()
|
|
||||||
.`when`(useCan.canUsage.eq(CanUsage.CHARACTER_IMAGE_PURCHASE))
|
|
||||||
.then(useCan.can.add(useCan.rewardCan))
|
|
||||||
.otherwise(0)
|
|
||||||
|
|
||||||
val messageCanExpr = CaseBuilder()
|
|
||||||
.`when`(useCan.canUsage.eq(CanUsage.CHAT_MESSAGE_PURCHASE))
|
|
||||||
.then(useCan.can.add(useCan.rewardCan))
|
|
||||||
.otherwise(0)
|
|
||||||
|
|
||||||
val quotaCanExpr = CaseBuilder()
|
|
||||||
.`when`(useCan.canUsage.eq(CanUsage.CHAT_QUOTA_PURCHASE))
|
|
||||||
.then(useCan.can.add(useCan.rewardCan))
|
|
||||||
.otherwise(0)
|
|
||||||
|
|
||||||
val imageSum = imageCanExpr.sum()
|
|
||||||
val messageSum = messageCanExpr.sum()
|
|
||||||
val quotaSum = quotaCanExpr.sum()
|
|
||||||
val totalSum = imageSum.add(messageSum).add(quotaSum)
|
|
||||||
|
|
||||||
// 캐릭터 조인: 이미지 경로를 통한 캐릭터(c1) + characterId 직접 지정(c2)
|
|
||||||
val c1 = QChatCharacter("c1")
|
|
||||||
val c2 = QChatCharacter("c2")
|
|
||||||
|
|
||||||
val characterIdExpr = c1.id.coalesce(c2.id)
|
|
||||||
val characterNameAgg = Expressions.stringTemplate(
|
|
||||||
"coalesce(max({0}), max({1}), '')",
|
|
||||||
c1.name,
|
|
||||||
c2.name
|
|
||||||
)
|
|
||||||
val characterImagePathAgg = Expressions.stringTemplate(
|
|
||||||
"coalesce(max({0}), max({1}))",
|
|
||||||
c1.imagePath,
|
|
||||||
c2.imagePath
|
|
||||||
)
|
|
||||||
|
|
||||||
val query = queryFactory
|
|
||||||
.select(
|
|
||||||
Projections.constructor(
|
|
||||||
ChatCharacterCalculateQueryData::class.java,
|
|
||||||
characterIdExpr,
|
|
||||||
characterNameAgg,
|
|
||||||
characterImagePathAgg.prepend("/").prepend(imageHost),
|
|
||||||
imageSum,
|
|
||||||
messageSum,
|
|
||||||
quotaSum
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.from(useCan)
|
|
||||||
.leftJoin(useCan.characterImage, characterImage)
|
|
||||||
.leftJoin(characterImage.chatCharacter, c1)
|
|
||||||
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
|
||||||
.where(
|
|
||||||
useCan.isRefund.isFalse
|
|
||||||
.and(
|
|
||||||
useCan.canUsage.`in`(
|
|
||||||
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
|
||||||
CanUsage.CHAT_MESSAGE_PURCHASE,
|
|
||||||
CanUsage.CHAT_QUOTA_PURCHASE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.and(useCan.createdAt.goe(startUtc))
|
|
||||||
.and(useCan.createdAt.loe(endInclusiveUtc))
|
|
||||||
)
|
|
||||||
.groupBy(characterIdExpr)
|
|
||||||
|
|
||||||
when (sort) {
|
|
||||||
ChatCharacterCalculateSort.TOTAL_SALES_DESC ->
|
|
||||||
query.orderBy(totalSum.desc(), characterIdExpr.desc())
|
|
||||||
|
|
||||||
ChatCharacterCalculateSort.LATEST_DESC ->
|
|
||||||
query.orderBy(characterIdExpr.desc(), totalSum.desc())
|
|
||||||
}
|
|
||||||
|
|
||||||
return query
|
|
||||||
.offset(offset)
|
|
||||||
.limit(limit)
|
|
||||||
.fetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCharacterCalculateTotalCount(
|
|
||||||
startUtc: LocalDateTime,
|
|
||||||
endInclusiveUtc: LocalDateTime
|
|
||||||
): Int {
|
|
||||||
val c1 = QChatCharacter("c1")
|
|
||||||
val c2 = QChatCharacter("c2")
|
|
||||||
val characterIdExpr = c1.id.coalesce(c2.id)
|
|
||||||
|
|
||||||
return queryFactory
|
|
||||||
.select(characterIdExpr)
|
|
||||||
.from(useCan)
|
|
||||||
.leftJoin(useCan.characterImage, characterImage)
|
|
||||||
.leftJoin(characterImage.chatCharacter, c1)
|
|
||||||
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
|
||||||
.where(
|
|
||||||
useCan.isRefund.isFalse
|
|
||||||
.and(
|
|
||||||
useCan.canUsage.`in`(
|
|
||||||
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
|
||||||
CanUsage.CHAT_MESSAGE_PURCHASE,
|
|
||||||
CanUsage.CHAT_QUOTA_PURCHASE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.and(useCan.createdAt.goe(startUtc))
|
|
||||||
.and(useCan.createdAt.loe(endInclusiveUtc))
|
|
||||||
)
|
|
||||||
.groupBy(characterIdExpr)
|
|
||||||
.fetch()
|
|
||||||
.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class AdminChatCalculateService(
|
|
||||||
private val repository: AdminChatCalculateQueryRepository
|
|
||||||
) {
|
|
||||||
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
|
||||||
private val kstZone: ZoneId = ZoneId.of("Asia/Seoul")
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getCharacterCalculate(
|
|
||||||
startDateStr: String,
|
|
||||||
endDateStr: String,
|
|
||||||
sort: ChatCharacterCalculateSort,
|
|
||||||
offset: Long,
|
|
||||||
pageSize: Int
|
|
||||||
): ChatCharacterCalculateResponse {
|
|
||||||
// 날짜 유효성 검증 (KST 기준)
|
|
||||||
val startDate = LocalDate.parse(startDateStr, dateFormatter)
|
|
||||||
val endDate = LocalDate.parse(endDateStr, dateFormatter)
|
|
||||||
val todayKst = LocalDate.now(kstZone)
|
|
||||||
|
|
||||||
if (endDate.isAfter(todayKst)) {
|
|
||||||
throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.")
|
|
||||||
}
|
|
||||||
if (startDate.isAfter(endDate)) {
|
|
||||||
throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.")
|
|
||||||
}
|
|
||||||
if (endDate.isAfter(startDate.plusMonths(6))) {
|
|
||||||
throw SodaException("조회 가능 기간은 최대 6개월입니다.")
|
|
||||||
}
|
|
||||||
|
|
||||||
val startUtc = startDateStr.convertLocalDateTime()
|
|
||||||
val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
|
||||||
|
|
||||||
val totalCount = repository.getCharacterCalculateTotalCount(startUtc, endInclusiveUtc)
|
|
||||||
val rows = repository.getCharacterCalculate(startUtc, endInclusiveUtc, sort, offset, pageSize.toLong())
|
|
||||||
val items = rows.map { it.toItem() }
|
|
||||||
return ChatCharacterCalculateResponse(totalCount, items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
|
||||||
import java.math.BigDecimal
|
|
||||||
import java.math.RoundingMode
|
|
||||||
|
|
||||||
// 정렬 옵션
|
|
||||||
enum class ChatCharacterCalculateSort {
|
|
||||||
TOTAL_SALES_DESC,
|
|
||||||
LATEST_DESC
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryDSL 프로젝션용 DTO
|
|
||||||
data class ChatCharacterCalculateQueryData @QueryProjection constructor(
|
|
||||||
val characterId: Long,
|
|
||||||
val characterName: String,
|
|
||||||
val characterImagePath: String?,
|
|
||||||
val imagePurchaseCan: Int?,
|
|
||||||
val messagePurchaseCan: Int?,
|
|
||||||
val quotaPurchaseCan: Int?
|
|
||||||
)
|
|
||||||
|
|
||||||
// 응답 DTO (아이템)
|
|
||||||
data class ChatCharacterCalculateItem(
|
|
||||||
@JsonProperty("characterId") val characterId: Long,
|
|
||||||
@JsonProperty("characterImage") val characterImage: String?,
|
|
||||||
@JsonProperty("name") val name: String,
|
|
||||||
@JsonProperty("imagePurchaseCan") val imagePurchaseCan: Int,
|
|
||||||
@JsonProperty("messagePurchaseCan") val messagePurchaseCan: Int,
|
|
||||||
@JsonProperty("quotaPurchaseCan") val quotaPurchaseCan: Int,
|
|
||||||
@JsonProperty("totalCan") val totalCan: Int,
|
|
||||||
@JsonProperty("totalKrw") val totalKrw: Int,
|
|
||||||
@JsonProperty("settlementKrw") val settlementKrw: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
// 응답 DTO (전체)
|
|
||||||
data class ChatCharacterCalculateResponse(
|
|
||||||
@JsonProperty("totalCount") val totalCount: Int,
|
|
||||||
@JsonProperty("items") val items: List<ChatCharacterCalculateItem>
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem {
|
|
||||||
val image = imagePurchaseCan ?: 0
|
|
||||||
val message = messagePurchaseCan ?: 0
|
|
||||||
val quota = quotaPurchaseCan ?: 0
|
|
||||||
val total = image + message + quota
|
|
||||||
val totalKrw = BigDecimal(total).multiply(BigDecimal(100))
|
|
||||||
val settlement = totalKrw.multiply(BigDecimal("0.10")).setScale(0, RoundingMode.HALF_UP)
|
|
||||||
|
|
||||||
return ChatCharacterCalculateItem(
|
|
||||||
characterId = characterId,
|
|
||||||
characterImage = characterImagePath,
|
|
||||||
name = characterName,
|
|
||||||
imagePurchaseCan = image,
|
|
||||||
messagePurchaseCan = message,
|
|
||||||
quotaPurchaseCan = quota,
|
|
||||||
totalCan = total,
|
|
||||||
totalKrw = totalKrw.toInt(),
|
|
||||||
settlementKrw = settlement.toInt()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,23 +3,16 @@ package kr.co.vividnext.sodalive.admin.chat.character
|
|||||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
|
||||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
import org.springframework.http.HttpEntity
|
import org.springframework.http.HttpEntity
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
@@ -44,8 +37,6 @@ class AdminChatCharacterController(
|
|||||||
private val service: ChatCharacterService,
|
private val service: ChatCharacterService,
|
||||||
private val adminService: AdminChatCharacterService,
|
private val adminService: AdminChatCharacterService,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
private val originalWorkService: AdminOriginalWorkService,
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
|
||||||
|
|
||||||
@Value("\${weraser.api-key}")
|
@Value("\${weraser.api-key}")
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
@@ -77,26 +68,6 @@ class AdminChatCharacterController(
|
|||||||
ApiResponse.ok(response)
|
ApiResponse.ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 검색(관리자)
|
|
||||||
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
|
|
||||||
* - 페이징 지원: page, size 파라미터 사용
|
|
||||||
*/
|
|
||||||
@GetMapping("/search")
|
|
||||||
fun searchCharacters(
|
|
||||||
@RequestParam("searchTerm") searchTerm: String,
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageable = adminService.createDefaultPageRequest(page, size)
|
|
||||||
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
|
|
||||||
val response = ChatCharacterSearchListPageResponse(
|
|
||||||
totalCount = resultPage.totalElements,
|
|
||||||
content = resultPage.content
|
|
||||||
)
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐릭터 상세 정보 조회 API
|
* 캐릭터 상세 정보 조회 API
|
||||||
*
|
*
|
||||||
@@ -166,23 +137,6 @@ class AdminChatCharacterController(
|
|||||||
chatCharacter.imagePath = imagePath
|
chatCharacter.imagePath = imagePath
|
||||||
service.saveChatCharacter(chatCharacter)
|
service.saveChatCharacter(chatCharacter)
|
||||||
|
|
||||||
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
|
||||||
if (request.originalWorkId != null) {
|
|
||||||
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
|
||||||
// 언어 감지에 사용할 내용은 chatCharacter.description 만 사용한다.
|
|
||||||
if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) {
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
LanguageDetectEvent(
|
|
||||||
id = chatCharacter.id!!,
|
|
||||||
query = chatCharacter.description,
|
|
||||||
targetType = LanguageDetectTargetType.CHARACTER
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
ApiResponse.ok(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,8 +247,7 @@ class AdminChatCharacterController(
|
|||||||
val hasDbOnlyChanges =
|
val hasDbOnlyChanges =
|
||||||
request.originalTitle != null ||
|
request.originalTitle != null ||
|
||||||
request.originalLink != null ||
|
request.originalLink != null ||
|
||||||
request.characterType != null ||
|
request.characterType != null
|
||||||
request.originalWorkId != null
|
|
||||||
|
|
||||||
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
||||||
throw SodaException("변경된 데이터가 없습니다.")
|
throw SodaException("변경된 데이터가 없습니다.")
|
||||||
@@ -333,19 +286,6 @@ class AdminChatCharacterController(
|
|||||||
request = request
|
request = request
|
||||||
)
|
)
|
||||||
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
LanguageTranslationEvent(
|
|
||||||
id = request.id,
|
|
||||||
targetType = LanguageTranslationTargetType.CHARACTER
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
|
||||||
if (request.originalWorkId != null) {
|
|
||||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
|
||||||
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
ApiResponse.ok(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 캐릭터 상세 응답 DTO
|
|
||||||
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
|
|
||||||
*/
|
|
||||||
data class ChatCharacterDetailResponse(
|
data class ChatCharacterDetailResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val characterUUID: String,
|
val characterUUID: String,
|
||||||
@@ -28,8 +24,7 @@ data class ChatCharacterDetailResponse(
|
|||||||
val relationships: List<RelationshipResponse>,
|
val relationships: List<RelationshipResponse>,
|
||||||
val personalities: List<PersonalityResponse>,
|
val personalities: List<PersonalityResponse>,
|
||||||
val backgrounds: List<BackgroundResponse>,
|
val backgrounds: List<BackgroundResponse>,
|
||||||
val memories: List<MemoryResponse>,
|
val memories: List<MemoryResponse>
|
||||||
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||||
@@ -39,20 +34,6 @@ data class ChatCharacterDetailResponse(
|
|||||||
chatCharacter.imagePath ?: ""
|
chatCharacter.imagePath ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val ow = chatCharacter.originalWork
|
|
||||||
val originalWorkBrief = ow?.let {
|
|
||||||
val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) {
|
|
||||||
"$imageHost/${it.imagePath}"
|
|
||||||
} else {
|
|
||||||
it.imagePath
|
|
||||||
}
|
|
||||||
OriginalWorkBriefResponse(
|
|
||||||
id = it.id!!,
|
|
||||||
imageUrl = owImage,
|
|
||||||
title = it.title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChatCharacterDetailResponse(
|
return ChatCharacterDetailResponse(
|
||||||
id = chatCharacter.id!!,
|
id = chatCharacter.id!!,
|
||||||
characterUUID = chatCharacter.characterUUID,
|
characterUUID = chatCharacter.characterUUID,
|
||||||
@@ -90,8 +71,7 @@ data class ChatCharacterDetailResponse(
|
|||||||
},
|
},
|
||||||
memories = chatCharacter.memories.map {
|
memories = chatCharacter.memories.map {
|
||||||
MemoryResponse(it.title, it.content, it.emotion)
|
MemoryResponse(it.title, it.content, it.emotion)
|
||||||
},
|
}
|
||||||
originalWork = originalWorkBrief
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,12 +101,3 @@ data class RelationshipResponse(
|
|||||||
val relationshipType: String,
|
val relationshipType: String,
|
||||||
val currentStatus: String
|
val currentStatus: String
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
|
|
||||||
*/
|
|
||||||
data class OriginalWorkBriefResponse(
|
|
||||||
val id: Long,
|
|
||||||
val imageUrl: String?,
|
|
||||||
val title: String
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ data class ChatCharacterRegisterRequest(
|
|||||||
@JsonProperty("appearance") val appearance: String?,
|
@JsonProperty("appearance") val appearance: String?,
|
||||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
|
||||||
@JsonProperty("characterType") val characterType: String? = null,
|
@JsonProperty("characterType") val characterType: String? = null,
|
||||||
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
||||||
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
||||||
@@ -76,7 +75,6 @@ data class ChatCharacterUpdateRequest(
|
|||||||
@JsonProperty("appearance") val appearance: String? = null,
|
@JsonProperty("appearance") val appearance: String? = null,
|
||||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
|
||||||
@JsonProperty("characterType") val characterType: String? = null,
|
@JsonProperty("characterType") val characterType: String? = null,
|
||||||
@JsonProperty("isActive") val isActive: Boolean? = null,
|
@JsonProperty("isActive") val isActive: Boolean? = null,
|
||||||
@JsonProperty("tags") val tags: List<String>? = null,
|
@JsonProperty("tags") val tags: List<String>? = null,
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 검색 결과 페이지 응답 DTO
|
|
||||||
*/
|
|
||||||
data class ChatCharacterSearchListPageResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<ChatCharacterListResponse>
|
|
||||||
)
|
|
||||||
@@ -3,16 +3,16 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto
|
|||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 원작 연결된 캐릭터 결과 응답 DTO
|
* 캐릭터 검색 결과 응답 DTO
|
||||||
*/
|
*/
|
||||||
data class OriginalWorkChatCharacterResponse(
|
data class ChatCharacterSearchResponse(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val imagePath: String?
|
val imagePath: String?
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
|
fun from(character: ChatCharacter, imageHost: String): ChatCharacterSearchResponse {
|
||||||
return OriginalWorkChatCharacterResponse(
|
return ChatCharacterSearchResponse(
|
||||||
id = character.id!!,
|
id = character.id!!,
|
||||||
name = character.name,
|
name = character.name,
|
||||||
imagePath = character.imagePath?.let { "$imageHost/$it" }
|
imagePath = character.imagePath?.let { "$imageHost/$it" }
|
||||||
@@ -22,9 +22,9 @@ data class OriginalWorkChatCharacterResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 원작 연결된 캐릭터 결과 페이지 응답 DTO
|
* 캐릭터 검색 결과 페이지 응답 DTO
|
||||||
*/
|
*/
|
||||||
data class OriginalWorkChatCharacterListPageResponse(
|
data class ChatCharacterSearchListPageResponse(
|
||||||
val totalCount: Long,
|
val totalCount: Long,
|
||||||
val content: List<OriginalWorkChatCharacterResponse>
|
val content: List<ChatCharacterSearchResponse>
|
||||||
)
|
)
|
||||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.chat.character.service
|
|||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchResponse
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
@@ -64,15 +65,20 @@ class AdminChatCharacterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
|
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반)
|
||||||
|
*
|
||||||
|
* @param searchTerm 검색어
|
||||||
|
* @param pageable 페이징 정보
|
||||||
|
* @param imageHost 이미지 호스트 URL
|
||||||
|
* @return 검색된 캐릭터 목록 (페이징)
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun searchCharacters(
|
fun searchCharacters(
|
||||||
searchTerm: String,
|
searchTerm: String,
|
||||||
pageable: Pageable,
|
pageable: Pageable,
|
||||||
imageHost: String = ""
|
imageHost: String = ""
|
||||||
): Page<ChatCharacterListResponse> {
|
): Page<ChatCharacterSearchResponse> {
|
||||||
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
|
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
|
||||||
return characters.map { ChatCharacterListResponse.from(it, imageHost) }
|
return characters.map { ChatCharacterSearchResponse.from(it, imageHost) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.original
|
|
||||||
|
|
||||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterListPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RequestPart
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.multipart.MultipartFile
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작(오리지널 작품) 관리자 API
|
|
||||||
* - 원작 등록/수정/삭제
|
|
||||||
* - 원작과 캐릭터 연결(배정) 및 해제
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/admin/chat/original")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
class AdminOriginalWorkController(
|
|
||||||
private val originalWorkService: AdminOriginalWorkService,
|
|
||||||
private val s3Uploader: S3Uploader,
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
|
||||||
private val s3Bucket: String,
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
|
||||||
private val imageHost: String
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 등록
|
|
||||||
* - 이미지 파일과 JSON 요청을 멀티파트로 받는다.
|
|
||||||
*/
|
|
||||||
@PostMapping("/register")
|
|
||||||
fun register(
|
|
||||||
@RequestPart("image") image: MultipartFile,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java)
|
|
||||||
|
|
||||||
// 서비스 계층을 통해 원작을 생성
|
|
||||||
val saved = originalWorkService.createOriginalWork(request)
|
|
||||||
|
|
||||||
// 이미지 업로드 후 이미지 경로 업데이트
|
|
||||||
val imagePath = uploadImage(saved.id!!, image)
|
|
||||||
originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath)
|
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 수정
|
|
||||||
* - 이미지가 있으면 교체, 없으면 유지
|
|
||||||
*/
|
|
||||||
@PutMapping("/update")
|
|
||||||
fun update(
|
|
||||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java)
|
|
||||||
|
|
||||||
// 이미지가 전달된 경우 먼저 업로드하여 경로를 생성
|
|
||||||
val imagePath = if (image != null && !image.isEmpty) {
|
|
||||||
uploadImage(request.id, image)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
originalWorkService.updateOriginalWork(request, imagePath)
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 삭제
|
|
||||||
*/
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
fun delete(@PathVariable id: Long) = run {
|
|
||||||
originalWorkService.deleteOriginalWork(id)
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 목록(페이징)
|
|
||||||
*/
|
|
||||||
@GetMapping("/list")
|
|
||||||
fun list(
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageRes = originalWorkService.getOriginalWorkPage(page, size)
|
|
||||||
val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) }
|
|
||||||
ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 검색(관리자)
|
|
||||||
* - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외
|
|
||||||
* - 페이징 제거: 전체 목록 반환
|
|
||||||
*/
|
|
||||||
@GetMapping("/search")
|
|
||||||
fun search(
|
|
||||||
@RequestParam("searchTerm") searchTerm: String
|
|
||||||
) = run {
|
|
||||||
val list = originalWorkService.searchOriginalWorksAll(searchTerm)
|
|
||||||
val content = list.map { OriginalWorkResponse.from(it, imageHost) }
|
|
||||||
ApiResponse.ok(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 상세
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
fun detail(@PathVariable id: Long) = run {
|
|
||||||
ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작에 기존 캐릭터들을 배정
|
|
||||||
* - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 이 원작으로 설정
|
|
||||||
*/
|
|
||||||
@PostMapping("/{id}/assign-characters")
|
|
||||||
fun assignCharacters(
|
|
||||||
@PathVariable id: Long,
|
|
||||||
@RequestBody body: OriginalWorkAssignCharactersRequest
|
|
||||||
) = run {
|
|
||||||
originalWorkService.assignCharacters(id, body.characterIds)
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작에서 캐릭터들 해제
|
|
||||||
* - 캐릭터들의 originalWork를 null로 설정
|
|
||||||
*/
|
|
||||||
@PostMapping("/{id}/unassign-characters")
|
|
||||||
fun unassignCharacters(
|
|
||||||
@PathVariable id: Long,
|
|
||||||
@RequestBody body: OriginalWorkAssignCharactersRequest
|
|
||||||
) = run {
|
|
||||||
originalWorkService.unassignCharacters(id, body.characterIds)
|
|
||||||
ApiResponse.ok(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회
|
|
||||||
* - 활성 캐릭터만 포함
|
|
||||||
* - 응답 항목: 캐릭터 이미지(URL), 이름
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}/characters")
|
|
||||||
fun listCharactersOfOriginal(
|
|
||||||
@PathVariable id: Long,
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
|
|
||||||
val content = pageRes.content.map { OriginalWorkChatCharacterResponse.from(it, imageHost) }
|
|
||||||
ApiResponse.ok(
|
|
||||||
OriginalWorkChatCharacterListPageResponse(
|
|
||||||
totalCount = pageRes.totalElements,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 이미지 업로드 공통 처리 */
|
|
||||||
private fun uploadImage(originalWorkId: Long, image: MultipartFile): String {
|
|
||||||
try {
|
|
||||||
val metadata = ObjectMetadata()
|
|
||||||
metadata.contentLength = image.size
|
|
||||||
return s3Uploader.upload(
|
|
||||||
inputStream = image.inputStream,
|
|
||||||
bucket = s3Bucket,
|
|
||||||
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
|
|
||||||
metadata = metadata
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.chat.original.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 등록 요청 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkRegisterRequest(
|
|
||||||
@JsonProperty("title") val title: String,
|
|
||||||
@JsonProperty("contentType") val contentType: String,
|
|
||||||
@JsonProperty("category") val category: String,
|
|
||||||
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
|
||||||
@JsonProperty("description") val description: String = "",
|
|
||||||
@JsonProperty("originalWork") val originalWork: String? = null,
|
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
|
||||||
@JsonProperty("writer") val writer: String? = null,
|
|
||||||
@JsonProperty("studio") val studio: String? = null,
|
|
||||||
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
|
||||||
@JsonProperty("tags") val tags: List<String>? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 수정 요청 DTO (부분 수정 가능)
|
|
||||||
*/
|
|
||||||
data class OriginalWorkUpdateRequest(
|
|
||||||
@JsonProperty("id") val id: Long,
|
|
||||||
@JsonProperty("title") val title: String? = null,
|
|
||||||
@JsonProperty("contentType") val contentType: String? = null,
|
|
||||||
@JsonProperty("category") val category: String? = null,
|
|
||||||
@JsonProperty("isAdult") val isAdult: Boolean? = null,
|
|
||||||
@JsonProperty("description") val description: String? = null,
|
|
||||||
@JsonProperty("originalWork") val originalWork: String? = null,
|
|
||||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
|
||||||
@JsonProperty("writer") val writer: String? = null,
|
|
||||||
@JsonProperty("studio") val studio: String? = null,
|
|
||||||
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
|
||||||
@JsonProperty("tags") val tags: List<String>? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 상세/목록 응답 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkResponse(
|
|
||||||
val id: Long,
|
|
||||||
val title: String,
|
|
||||||
val contentType: String,
|
|
||||||
val category: String,
|
|
||||||
val isAdult: Boolean,
|
|
||||||
val description: String,
|
|
||||||
val originalWork: String?,
|
|
||||||
val originalLink: String?,
|
|
||||||
val writer: String?,
|
|
||||||
val studio: String?,
|
|
||||||
val originalLinks: List<String>,
|
|
||||||
val tags: List<String>,
|
|
||||||
val imageUrl: String?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkResponse {
|
|
||||||
val fullImagePath = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
|
||||||
"$imageHost/${entity.imagePath}"
|
|
||||||
} else {
|
|
||||||
entity.imagePath
|
|
||||||
}
|
|
||||||
return OriginalWorkResponse(
|
|
||||||
id = entity.id!!,
|
|
||||||
title = entity.title,
|
|
||||||
contentType = entity.contentType,
|
|
||||||
category = entity.category,
|
|
||||||
isAdult = entity.isAdult,
|
|
||||||
description = entity.description,
|
|
||||||
originalWork = entity.originalWork,
|
|
||||||
originalLink = entity.originalLink,
|
|
||||||
writer = entity.writer,
|
|
||||||
studio = entity.studio,
|
|
||||||
originalLinks = entity.originalLinks.map { it.url },
|
|
||||||
tags = entity.tagMappings.map { it.tag.tag },
|
|
||||||
imageUrl = fullImagePath
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class OriginalWorkPageResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<OriginalWorkResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작-캐릭터 연결/해제 요청 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkAssignCharactersRequest(
|
|
||||||
@JsonProperty("characterIds") val characterIds: List<Long>
|
|
||||||
)
|
|
||||||
@@ -1,276 +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 kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
|
||||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
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,
|
|
||||||
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher
|
|
||||||
) {
|
|
||||||
|
|
||||||
/** 원작 등록 (중복 제목 방지 포함) */
|
|
||||||
@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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val originalWork = originalWorkRepository.save(entity)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 저장이 완료된 후
|
|
||||||
* originalWork의
|
|
||||||
*
|
|
||||||
* languageCode == null이면 언어 감지 이벤트 호출
|
|
||||||
* languageCode != null이면 번역 이벤트 호출
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
if (originalWork.languageCode == null) {
|
|
||||||
val papagoQuery = listOf(
|
|
||||||
originalWork.title,
|
|
||||||
originalWork.contentType,
|
|
||||||
originalWork.category,
|
|
||||||
originalWork.description
|
|
||||||
)
|
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
.joinToString(" ")
|
|
||||||
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
LanguageDetectEvent(
|
|
||||||
id = originalWork.id!!,
|
|
||||||
query = papagoQuery,
|
|
||||||
targetType = LanguageDetectTargetType.ORIGINAL_WORK
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
LanguageTranslationEvent(
|
|
||||||
id = originalWork.id!!,
|
|
||||||
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalWork
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
|
||||||
@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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 번역 이벤트 호출
|
|
||||||
*/
|
|
||||||
if (
|
|
||||||
request.title != null ||
|
|
||||||
request.contentType != null ||
|
|
||||||
request.category != null ||
|
|
||||||
request.description != null ||
|
|
||||||
request.tags != null
|
|
||||||
) {
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
LanguageTranslationEvent(
|
|
||||||
id = ow.id!!,
|
|
||||||
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
@@ -21,9 +19,4 @@ class AdminContentSeriesController(private val service: AdminContentSeriesServic
|
|||||||
fun searchSeriesList(
|
fun searchSeriesList(
|
||||||
@RequestParam(value = "search_word") searchWord: String
|
@RequestParam(value = "search_word") searchWord: String
|
||||||
) = ApiResponse.ok(service.searchSeriesList(searchWord))
|
) = ApiResponse.ok(service.searchSeriesList(searchWord))
|
||||||
|
|
||||||
@PutMapping
|
|
||||||
fun modifySeries(
|
|
||||||
@RequestBody request: AdminModifySeriesRequest
|
|
||||||
) = ApiResponse.ok(service.modifySeries(request), "시리즈가 수정되었습니다.")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.content.series
|
package kr.co.vividnext.sodalive.admin.content.series
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AdminContentSeriesService(
|
class AdminContentSeriesService(private val repository: AdminContentSeriesRepository) {
|
||||||
private val repository: AdminContentSeriesRepository,
|
|
||||||
private val genreRepository: AdminContentSeriesGenreRepository
|
|
||||||
) {
|
|
||||||
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
|
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
|
||||||
val totalCount = repository.getSeriesTotalCount()
|
val totalCount = repository.getSeriesTotalCount()
|
||||||
val items = repository.getSeriesList(
|
val items = repository.getSeriesList(
|
||||||
@@ -19,53 +12,10 @@ class AdminContentSeriesService(
|
|||||||
limit = pageable.pageSize.toLong()
|
limit = pageable.pageSize.toLong()
|
||||||
)
|
)
|
||||||
|
|
||||||
if (items.isNotEmpty()) {
|
|
||||||
val ids = items.map { it.id }
|
|
||||||
val seriesList = repository.findAllById(ids)
|
|
||||||
val seriesMap = seriesList.associateBy { it.id }
|
|
||||||
|
|
||||||
items.forEach { item ->
|
|
||||||
val s = seriesMap[item.id]
|
|
||||||
if (s != null) {
|
|
||||||
item.publishedDaysOfWeek = s.publishedDaysOfWeek.toList().sortedBy { it.ordinal }
|
|
||||||
item.isOriginal = s.isOriginal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetAdminSeriesListResponse(totalCount, items)
|
return GetAdminSeriesListResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
|
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
|
||||||
return repository.searchSeriesList(searchWord)
|
return repository.searchSeriesList(searchWord)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun modifySeries(request: AdminModifySeriesRequest) {
|
|
||||||
val series = repository.findByIdAndActiveTrue(request.seriesId)
|
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
|
||||||
|
|
||||||
if (request.publishedDaysOfWeek != null) {
|
|
||||||
val days = request.publishedDaysOfWeek
|
|
||||||
if (days.contains(SeriesPublishedDaysOfWeek.RANDOM) && days.size > 1) {
|
|
||||||
throw SodaException("랜덤과 연재요일 동시에 선택할 수 없습니다.")
|
|
||||||
}
|
|
||||||
series.publishedDaysOfWeek.clear()
|
|
||||||
series.publishedDaysOfWeek.addAll(days)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.genreId != null) {
|
|
||||||
val genre = genreRepository.findActiveSeriesGenreById(request.genreId)
|
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
|
||||||
series.genre = genre
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.isOriginal != null) {
|
|
||||||
series.isOriginal = request.isOriginal
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.isAdult != null) {
|
|
||||||
series.isAdult = request.isAdult
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.content.series
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
|
||||||
|
|
||||||
data class AdminModifySeriesRequest(
|
|
||||||
val seriesId: Long,
|
|
||||||
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>?,
|
|
||||||
val genreId: Long?,
|
|
||||||
val isOriginal: Boolean?,
|
|
||||||
val isAdult: Boolean?
|
|
||||||
)
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.content.series
|
package kr.co.vividnext.sodalive.admin.content.series
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
|
||||||
|
|
||||||
data class GetAdminSeriesListResponse(
|
data class GetAdminSeriesListResponse(
|
||||||
val totalCount: Int,
|
val totalCount: Int,
|
||||||
@@ -18,10 +17,7 @@ data class GetAdminSeriesListItem @QueryProjection constructor(
|
|||||||
val numberOfWorks: Long,
|
val numberOfWorks: Long,
|
||||||
val state: String,
|
val state: String,
|
||||||
val isAdult: Boolean
|
val isAdult: Boolean
|
||||||
) {
|
)
|
||||||
var publishedDaysOfWeek: List<SeriesPublishedDaysOfWeek> = emptyList()
|
|
||||||
var isOriginal: Boolean = false
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
|
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.content.series.banner
|
|
||||||
|
|
||||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import kr.co.vividnext.sodalive.admin.content.banner.UpdateBannerOrdersRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerListPageResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest
|
|
||||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
|
|
||||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpdateRequest
|
|
||||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.data.domain.PageRequest
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RequestPart
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import org.springframework.web.multipart.MultipartFile
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/admin/audio-content/series/banner")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
class AdminContentSeriesBannerController(
|
|
||||||
private val bannerService: ContentSeriesBannerService,
|
|
||||||
private val s3Uploader: S3Uploader,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
|
||||||
private val s3Bucket: String,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
|
||||||
private val imageHost: String
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* 활성화된 배너 목록 조회 API
|
|
||||||
*/
|
|
||||||
@GetMapping("/list")
|
|
||||||
fun getBannerList(
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int
|
|
||||||
) = run {
|
|
||||||
val pageable = PageRequest.of(page, size)
|
|
||||||
val banners = bannerService.getActiveBanners(pageable)
|
|
||||||
val response = SeriesBannerListPageResponse(
|
|
||||||
totalCount = banners.totalElements,
|
|
||||||
content = banners.content.map { SeriesBannerResponse.from(it, imageHost) }
|
|
||||||
)
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배너 상세 조회 API
|
|
||||||
*/
|
|
||||||
@GetMapping("/{bannerId}")
|
|
||||||
fun getBannerDetail(@PathVariable bannerId: Long) = run {
|
|
||||||
val banner = bannerService.getBannerById(bannerId)
|
|
||||||
val response = SeriesBannerResponse.from(banner, imageHost)
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배너 등록 API
|
|
||||||
*/
|
|
||||||
@PostMapping("/register")
|
|
||||||
fun registerBanner(
|
|
||||||
@RequestPart("image") image: MultipartFile,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java)
|
|
||||||
|
|
||||||
val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "")
|
|
||||||
val imagePath = saveImage(banner.id!!, image)
|
|
||||||
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
|
|
||||||
val response = SeriesBannerResponse.from(updatedBanner, imageHost)
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배너 수정 API
|
|
||||||
*/
|
|
||||||
@PutMapping("/update")
|
|
||||||
fun updateBanner(
|
|
||||||
@RequestPart("image") image: MultipartFile,
|
|
||||||
@RequestPart("request") requestString: String
|
|
||||||
) = run {
|
|
||||||
val objectMapper = ObjectMapper()
|
|
||||||
val request = objectMapper.readValue(requestString, SeriesBannerUpdateRequest::class.java)
|
|
||||||
// 배너 존재 확인
|
|
||||||
bannerService.getBannerById(request.bannerId)
|
|
||||||
val imagePath = saveImage(request.bannerId, image)
|
|
||||||
val updated = bannerService.updateBanner(
|
|
||||||
bannerId = request.bannerId,
|
|
||||||
imagePath = imagePath,
|
|
||||||
seriesId = request.seriesId
|
|
||||||
)
|
|
||||||
val response = SeriesBannerResponse.from(updated, imageHost)
|
|
||||||
ApiResponse.ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배너 삭제 API (소프트 삭제)
|
|
||||||
*/
|
|
||||||
@DeleteMapping("/{bannerId}")
|
|
||||||
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
|
||||||
bannerService.deleteBanner(bannerId)
|
|
||||||
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배너 정렬 순서 일괄 변경 API
|
|
||||||
*/
|
|
||||||
@PutMapping("/orders")
|
|
||||||
fun updateBannerOrders(
|
|
||||||
@RequestBody request: UpdateBannerOrdersRequest
|
|
||||||
) = run {
|
|
||||||
bannerService.updateBannerOrders(request.ids)
|
|
||||||
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
|
||||||
try {
|
|
||||||
val metadata = ObjectMetadata()
|
|
||||||
metadata.contentLength = image.size
|
|
||||||
val fileName = generateFileName("series-banner")
|
|
||||||
return s3Uploader.upload(
|
|
||||||
inputStream = image.inputStream,
|
|
||||||
bucket = s3Bucket,
|
|
||||||
filePath = "series_banner/$bannerId/$fileName",
|
|
||||||
metadata = metadata
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.content.series.banner.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
|
|
||||||
|
|
||||||
// 시리즈 배너 등록 요청 DTO
|
|
||||||
data class SeriesBannerRegisterRequest(
|
|
||||||
@JsonProperty("seriesId") val seriesId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
// 시리즈 배너 수정 요청 DTO
|
|
||||||
data class SeriesBannerUpdateRequest(
|
|
||||||
@JsonProperty("bannerId") val bannerId: Long,
|
|
||||||
@JsonProperty("seriesId") val seriesId: Long? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
// 시리즈 배너 응답 DTO
|
|
||||||
data class SeriesBannerResponse(
|
|
||||||
val id: Long,
|
|
||||||
val imagePath: String,
|
|
||||||
val seriesId: Long,
|
|
||||||
val seriesTitle: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(banner: SeriesBanner, imageHost: String): SeriesBannerResponse {
|
|
||||||
return SeriesBannerResponse(
|
|
||||||
id = banner.id!!,
|
|
||||||
imagePath = "$imageHost/${banner.imagePath}",
|
|
||||||
seriesId = banner.series.id!!,
|
|
||||||
seriesTitle = banner.series.title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시리즈 배너 목록 페이지 응답 DTO
|
|
||||||
data class SeriesBannerListPageResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<SeriesBannerResponse>
|
|
||||||
)
|
|
||||||
@@ -8,7 +8,6 @@ interface AdminContentSeriesGenreRepository : JpaRepository<SeriesGenre, Long>,
|
|||||||
|
|
||||||
interface AdminContentSeriesGenreQueryRepository {
|
interface AdminContentSeriesGenreQueryRepository {
|
||||||
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
|
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
|
||||||
fun findActiveSeriesGenreById(id: Long): SeriesGenre?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AdminContentSeriesGenreQueryRepositoryImpl(
|
class AdminContentSeriesGenreQueryRepositoryImpl(
|
||||||
@@ -22,14 +21,4 @@ class AdminContentSeriesGenreQueryRepositoryImpl(
|
|||||||
.orderBy(seriesGenre.orders.asc())
|
.orderBy(seriesGenre.orders.asc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findActiveSeriesGenreById(id: Long): SeriesGenre? {
|
|
||||||
return queryFactory
|
|
||||||
.selectFrom(seriesGenre)
|
|
||||||
.where(
|
|
||||||
seriesGenre.id.eq(id)
|
|
||||||
.and(seriesGenre.isActive.isTrue)
|
|
||||||
)
|
|
||||||
.fetchFirst()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -21,8 +18,6 @@ class AdminContentThemeService(
|
|||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val repository: AdminContentThemeRepository,
|
private val repository: AdminContentThemeRepository,
|
||||||
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val bucket: String
|
private val bucket: String
|
||||||
) {
|
) {
|
||||||
@@ -42,14 +37,7 @@ class AdminContentThemeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun createTheme(theme: String, imagePath: String) {
|
fun createTheme(theme: String, imagePath: String) {
|
||||||
val savedTheme = repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||||
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
LanguageTranslationEvent(
|
|
||||||
id = savedTheme.id!!,
|
|
||||||
targetType = LanguageTranslationTargetType.CONTENT_THEME
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun themeExistCheck(request: CreateContentThemeRequest) {
|
fun themeExistCheck(request: CreateContentThemeRequest) {
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ class AdminMemberController(private val service: AdminMemberService) {
|
|||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = ApiResponse.ok(service.searchMember(searchWord, pageable))
|
) = ApiResponse.ok(service.searchMember(searchWord, pageable))
|
||||||
|
|
||||||
@GetMapping("/search-by-nickname")
|
|
||||||
fun searchMemberByNickname(
|
|
||||||
@RequestParam(value = "search_word") searchWord: String,
|
|
||||||
@RequestParam(value = "size", required = false) size: Int?
|
|
||||||
) = ApiResponse.ok(service.searchMemberByNickname(searchWord = searchWord, size = size ?: 20))
|
|
||||||
|
|
||||||
@GetMapping("/creator/all/list")
|
@GetMapping("/creator/all/list")
|
||||||
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
|
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ interface AdminMemberQueryRepository {
|
|||||||
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
|
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
|
||||||
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
|
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
|
||||||
fun findByIdAndActive(memberId: Long): Member?
|
fun findByIdAndActive(memberId: Long): Member?
|
||||||
fun searchMemberByNickname(searchWord: String, limit: Long = 20): List<AdminSimpleMemberResponse>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
|
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
|
||||||
@@ -122,22 +121,4 @@ class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
|
|||||||
.orderBy(member.id.desc())
|
.orderBy(member.id.desc())
|
||||||
.fetchFirst()
|
.fetchFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMemberByNickname(searchWord: String, limit: Long): List<AdminSimpleMemberResponse> {
|
|
||||||
return queryFactory
|
|
||||||
.select(
|
|
||||||
QAdminSimpleMemberResponse(
|
|
||||||
member.id,
|
|
||||||
member.nickname
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.from(member)
|
|
||||||
.where(
|
|
||||||
member.nickname.contains(searchWord)
|
|
||||||
.and(member.isActive.isTrue)
|
|
||||||
)
|
|
||||||
.orderBy(member.id.desc())
|
|
||||||
.limit(limit)
|
|
||||||
.fetch()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,12 +145,6 @@ class AdminMemberService(
|
|||||||
return repository.getCreatorAllList()
|
return repository.getCreatorAllList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun searchMemberByNickname(searchWord: String, size: Int = 20): List<AdminSimpleMemberResponse> {
|
|
||||||
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
|
||||||
val limit = if (size <= 0) 20 else size
|
|
||||||
return repository.searchMemberByNickname(searchWord = searchWord, limit = limit.toLong())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun resetPassword(request: ResetPasswordRequest) {
|
fun resetPassword(request: ResetPasswordRequest) {
|
||||||
val member = repository.findByIdAndActive(memberId = request.memberId)
|
val member = repository.findByIdAndActive(memberId = request.memberId)
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.member
|
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자용 간단 회원 응답 DTO
|
|
||||||
* 닉네임 검색 결과로 사용되며 charge 등에서 memberId 선택에 활용된다.
|
|
||||||
*/
|
|
||||||
data class AdminSimpleMemberResponse @QueryProjection constructor(
|
|
||||||
val id: Long,
|
|
||||||
val nickname: String
|
|
||||||
)
|
|
||||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.statistics.ad
|
|||||||
import com.querydsl.core.types.dsl.CaseBuilder
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
import com.querydsl.core.types.dsl.DateTimePath
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.core.types.dsl.NumberExpression
|
||||||
import com.querydsl.core.types.dsl.StringTemplate
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||||
@@ -66,7 +67,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val firstPaymentTotalAmount = CaseBuilder()
|
val firstPaymentTotalAmount = CaseBuilder()
|
||||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(0.toBigDecimal())
|
.otherwise(Expressions.constant(0.0))
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
val repeatPaymentCount = CaseBuilder()
|
val repeatPaymentCount = CaseBuilder()
|
||||||
@@ -78,7 +79,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val repeatPaymentTotalAmount = CaseBuilder()
|
val repeatPaymentTotalAmount = CaseBuilder()
|
||||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(0.toBigDecimal())
|
.otherwise(Expressions.constant(0.0))
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
val allPaymentCount = CaseBuilder()
|
val allPaymentCount = CaseBuilder()
|
||||||
@@ -96,7 +97,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||||
)
|
)
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(0.toBigDecimal())
|
.otherwise(Expressions.constant(0.0))
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
@@ -110,11 +111,11 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
loginCount,
|
loginCount,
|
||||||
signUpCount,
|
signUpCount,
|
||||||
firstPaymentCount,
|
firstPaymentCount,
|
||||||
firstPaymentTotalAmount,
|
roundedValueDecimalPlaces2(firstPaymentTotalAmount),
|
||||||
repeatPaymentCount,
|
repeatPaymentCount,
|
||||||
repeatPaymentTotalAmount,
|
roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
|
||||||
allPaymentCount,
|
allPaymentCount,
|
||||||
allPaymentTotalAmount
|
roundedValueDecimalPlaces2(allPaymentTotalAmount)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(adTrackingHistory)
|
.from(adTrackingHistory)
|
||||||
@@ -147,4 +148,13 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
"%Y-%m-%d"
|
"%Y-%m-%d"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun roundedValueDecimalPlaces2(valueExpression: NumberExpression<Double>): NumberExpression<Double> {
|
||||||
|
return Expressions.numberTemplate(
|
||||||
|
Double::class.java,
|
||||||
|
"ROUND({0}, {1})",
|
||||||
|
valueExpression,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.statistics.ad
|
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class GetAdminAdStatisticsResponse(
|
data class GetAdminAdStatisticsResponse(
|
||||||
val totalCount: Int,
|
val totalCount: Int,
|
||||||
@@ -17,9 +16,9 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
|
|||||||
val loginCount: Int,
|
val loginCount: Int,
|
||||||
val signUpCount: Int,
|
val signUpCount: Int,
|
||||||
val firstPaymentCount: Int,
|
val firstPaymentCount: Int,
|
||||||
val firstPaymentTotalAmount: BigDecimal,
|
val firstPaymentTotalAmount: Double,
|
||||||
val repeatPaymentCount: Int,
|
val repeatPaymentCount: Int,
|
||||||
val repeatPaymentTotalAmount: BigDecimal,
|
val repeatPaymentTotalAmount: Double,
|
||||||
val allPaymentCount: Int,
|
val allPaymentCount: Int,
|
||||||
val allPaymentTotalAmount: BigDecimal
|
val allPaymentTotalAmount: Double
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.api.home
|
package kr.co.vividnext.sodalive.api.home
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.audition.GetAuditionListItem
|
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.AudioContentMainItem
|
||||||
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
|
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
|
||||||
@@ -22,11 +21,8 @@ data class GetHomeResponse(
|
|||||||
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
|
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
|
||||||
val auditionList: List<GetAuditionListItem>,
|
val auditionList: List<GetAuditionListItem>,
|
||||||
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
|
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
|
||||||
val popularCharacters: List<Character>,
|
|
||||||
val contentRanking: List<GetAudioContentRankingItem>,
|
val contentRanking: List<GetAudioContentRankingItem>,
|
||||||
val recommendChannelList: List<RecommendChannelResponse>,
|
val recommendChannelList: List<RecommendChannelResponse>,
|
||||||
val freeContentList: List<AudioContentMainItem>,
|
val freeContentList: List<AudioContentMainItem>,
|
||||||
val pointAvailableContentList: List<AudioContentMainItem>,
|
|
||||||
val recommendContentList: List<AudioContentMainItem>,
|
|
||||||
val curationList: List<GetContentCurationResponse>
|
val curationList: List<GetContentCurationResponse>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
@@ -64,44 +63,4 @@ class HomeController(private val service: HomeService) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 추천 콘텐츠만 새로고침하기 위한 엔드포인트
|
|
||||||
@GetMapping("/recommend-contents")
|
|
||||||
fun getRecommendContents(
|
|
||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
|
||||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
|
||||||
) = run {
|
|
||||||
ApiResponse.ok(
|
|
||||||
service.getRecommendContentList(
|
|
||||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
|
||||||
contentType = contentType ?: ContentType.ALL,
|
|
||||||
member = member
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 콘텐츠 랭킹 엔드포인트
|
|
||||||
@GetMapping("/content-ranking")
|
|
||||||
fun getContentRanking(
|
|
||||||
@RequestParam("sort", required = false) sort: ContentRankingSortType? = null,
|
|
||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
|
||||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
|
||||||
@RequestParam("offset", required = false) offset: Long? = null,
|
|
||||||
@RequestParam("limit", required = false) limit: Long? = null,
|
|
||||||
@RequestParam("theme", required = false) theme: String? = null,
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
|
||||||
) = run {
|
|
||||||
ApiResponse.ok(
|
|
||||||
service.getContentRankingBySort(
|
|
||||||
sort = sort ?: ContentRankingSortType.REVENUE,
|
|
||||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
|
||||||
contentType = contentType ?: ContentType.ALL,
|
|
||||||
offset = offset,
|
|
||||||
limit = limit,
|
|
||||||
theme = theme,
|
|
||||||
member = member
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,22 @@
|
|||||||
package kr.co.vividnext.sodalive.api.home
|
package kr.co.vividnext.sodalive.api.home
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.audition.AuditionService
|
import kr.co.vividnext.sodalive.audition.AuditionService
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
||||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
||||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||||
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
||||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
|
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
import kr.co.vividnext.sodalive.event.GetEventResponse
|
import kr.co.vividnext.sodalive.event.GetEventResponse
|
||||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberService
|
import kr.co.vividnext.sodalive.member.MemberService
|
||||||
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
|
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
|
||||||
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
|
|
||||||
import kr.co.vividnext.sodalive.rank.RankingRepository
|
import kr.co.vividnext.sodalive.rank.RankingRepository
|
||||||
import kr.co.vividnext.sodalive.rank.RankingService
|
import kr.co.vividnext.sodalive.rank.RankingService
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
@@ -47,25 +39,13 @@ class HomeService(
|
|||||||
private val contentThemeService: AudioContentThemeService,
|
private val contentThemeService: AudioContentThemeService,
|
||||||
private val recommendChannelService: RecommendChannelQueryService,
|
private val recommendChannelService: RecommendChannelQueryService,
|
||||||
|
|
||||||
private val characterService: ChatCharacterService,
|
|
||||||
private val rankingService: RankingService,
|
private val rankingService: RankingService,
|
||||||
private val rankingRepository: RankingRepository,
|
private val rankingRepository: RankingRepository,
|
||||||
private val explorerQueryRepository: ExplorerQueryRepository,
|
private val explorerQueryRepository: ExplorerQueryRepository,
|
||||||
|
|
||||||
private val contentTranslationRepository: ContentTranslationRepository,
|
|
||||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
|
||||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
|
||||||
|
|
||||||
private val langContext: LangContext,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
companion object {
|
|
||||||
private const val RECOMMEND_TARGET_SIZE = 30
|
|
||||||
private const val RECOMMEND_MAX_ATTEMPTS = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchData(
|
fun fetchData(
|
||||||
timezone: String,
|
timezone: String,
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
@@ -122,8 +102,6 @@ class HomeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
|
|
||||||
|
|
||||||
val eventBannerList = GetEventResponse(
|
val eventBannerList = GetEventResponse(
|
||||||
totalCount = 0,
|
totalCount = 0,
|
||||||
eventList = emptyList()
|
eventList = emptyList()
|
||||||
@@ -135,28 +113,19 @@ class HomeService(
|
|||||||
isAdult = isAdult
|
isAdult = isAdult
|
||||||
)
|
)
|
||||||
|
|
||||||
// 오직 보이스온에서만
|
|
||||||
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType,
|
contentType = contentType
|
||||||
orderByRandom = true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList)
|
|
||||||
|
|
||||||
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
||||||
|
|
||||||
// 요일별 시리즈
|
|
||||||
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
||||||
)
|
)
|
||||||
val translatedDayOfWeekSeriesList = getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
|
|
||||||
|
|
||||||
// 인기 캐릭터 조회
|
|
||||||
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
|
|
||||||
|
|
||||||
val currentDateTime = LocalDateTime.now()
|
val currentDateTime = LocalDateTime.now()
|
||||||
val startDate = currentDateTime
|
val startDate = currentDateTime
|
||||||
@@ -174,26 +143,10 @@ class HomeService(
|
|||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
startDate = startDate.minusDays(1),
|
startDate = startDate.minusDays(1),
|
||||||
endDate = endDate,
|
endDate = endDate,
|
||||||
sort = ContentRankingSortType.REVENUE
|
sortType = "매출"
|
||||||
)
|
)
|
||||||
|
|
||||||
val contentRankingContentIds = contentRanking.map { it.contentId }
|
// TODO 오디오 북
|
||||||
val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) {
|
|
||||||
val translations = contentTranslationRepository
|
|
||||||
.findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code)
|
|
||||||
.associateBy { it.contentId }
|
|
||||||
|
|
||||||
contentRanking.map { item ->
|
|
||||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
|
||||||
if (translatedTitle.isNullOrBlank()) {
|
|
||||||
item
|
|
||||||
} else {
|
|
||||||
item.copy(title = translatedTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
contentRanking
|
|
||||||
}
|
|
||||||
|
|
||||||
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
@@ -201,40 +154,6 @@ class HomeService(
|
|||||||
contentType = contentType
|
contentType = contentType
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* recommendChannelList의 콘텐츠 번역 데이터 조회
|
|
||||||
*
|
|
||||||
* languageCode != null
|
|
||||||
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
|
|
||||||
*
|
|
||||||
* 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다
|
|
||||||
*/
|
|
||||||
val channelContentIds = recommendChannelList
|
|
||||||
.flatMap { it.contentList }
|
|
||||||
.map { it.contentId }
|
|
||||||
.distinct()
|
|
||||||
|
|
||||||
val translatedRecommendChannelList = if (channelContentIds.isNotEmpty()) {
|
|
||||||
val translations = contentTranslationRepository
|
|
||||||
.findByContentIdInAndLocale(contentIds = channelContentIds, locale = langContext.lang.code)
|
|
||||||
.associateBy { it.contentId }
|
|
||||||
|
|
||||||
recommendChannelList.map { channel ->
|
|
||||||
val translatedContentList = channel.contentList.map { item ->
|
|
||||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
|
||||||
if (translatedTitle.isNullOrBlank()) {
|
|
||||||
item
|
|
||||||
} else {
|
|
||||||
item.copy(title = translatedTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.copy(contentList = translatedContentList)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
recommendChannelList
|
|
||||||
}
|
|
||||||
|
|
||||||
val freeContentList = contentService.getLatestContentByTheme(
|
val freeContentList = contentService.getLatestContentByTheme(
|
||||||
theme = contentThemeService.getActiveThemeOfContent(
|
theme = contentThemeService.getActiveThemeOfContent(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
@@ -243,8 +162,7 @@ class HomeService(
|
|||||||
),
|
),
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
isFree = true,
|
isFree = true,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult
|
||||||
orderByRandom = true
|
|
||||||
).filter {
|
).filter {
|
||||||
if (memberId != null) {
|
if (memberId != null) {
|
||||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||||
@@ -253,26 +171,6 @@ class HomeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList)
|
|
||||||
|
|
||||||
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
|
|
||||||
val pointAvailableContentList = contentService.getLatestContentByTheme(
|
|
||||||
theme = emptyList(),
|
|
||||||
contentType = contentType,
|
|
||||||
isFree = false,
|
|
||||||
isAdult = isAdult,
|
|
||||||
orderByRandom = true,
|
|
||||||
isPointAvailableOnly = true
|
|
||||||
).filter {
|
|
||||||
if (memberId != null) {
|
|
||||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
|
|
||||||
|
|
||||||
val curationList = curationService.getContentCurationList(
|
val curationList = curationService.getContentCurationList(
|
||||||
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
@@ -284,22 +182,15 @@ class HomeService(
|
|||||||
liveList = liveList,
|
liveList = liveList,
|
||||||
creatorRanking = creatorRanking,
|
creatorRanking = creatorRanking,
|
||||||
latestContentThemeList = latestContentThemeList,
|
latestContentThemeList = latestContentThemeList,
|
||||||
latestContentList = translatedLatestContentList,
|
latestContentList = latestContentList,
|
||||||
bannerList = bannerList,
|
bannerList = bannerList,
|
||||||
eventBannerList = eventBannerList,
|
eventBannerList = eventBannerList,
|
||||||
originalAudioDramaList = translatedOriginalAudioDramaList,
|
originalAudioDramaList = originalAudioDramaList,
|
||||||
auditionList = auditionList,
|
auditionList = auditionList,
|
||||||
dayOfWeekSeriesList = translatedDayOfWeekSeriesList,
|
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
||||||
popularCharacters = translatedPopularCharacters,
|
contentRanking = contentRanking,
|
||||||
contentRanking = translatedContentRanking,
|
recommendChannelList = recommendChannelList,
|
||||||
recommendChannelList = translatedRecommendChannelList,
|
freeContentList = freeContentList,
|
||||||
freeContentList = translatedFreeContentList,
|
|
||||||
pointAvailableContentList = translatedPointAvailableContentList,
|
|
||||||
recommendContentList = getRecommendContentList(
|
|
||||||
isAdultContentVisible = isAdultContentVisible,
|
|
||||||
contentType = contentType,
|
|
||||||
member = member
|
|
||||||
),
|
|
||||||
curationList = curationList
|
curationList = curationList
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -323,7 +214,7 @@ class HomeService(
|
|||||||
listOf(theme)
|
listOf(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
val contentList = contentService.getLatestContentByTheme(
|
return contentService.getLatestContentByTheme(
|
||||||
theme = themeList,
|
theme = themeList,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
isFree = false,
|
isFree = false,
|
||||||
@@ -335,8 +226,6 @@ class HomeService(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return getTranslatedContentList(contentList = contentList)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDayOfWeekSeriesList(
|
fun getDayOfWeekSeriesList(
|
||||||
@@ -348,48 +237,12 @@ class HomeService(
|
|||||||
val memberId = member?.id
|
val memberId = member?.id
|
||||||
val isAdult = member?.auth != null && isAdultContentVisible
|
val isAdult = member?.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
return seriesService.getDayOfWeekSeriesList(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
dayOfWeek = dayOfWeek
|
dayOfWeek = dayOfWeek
|
||||||
)
|
)
|
||||||
|
|
||||||
return getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getContentRankingBySort(
|
|
||||||
sort: ContentRankingSortType,
|
|
||||||
isAdultContentVisible: Boolean,
|
|
||||||
contentType: ContentType,
|
|
||||||
offset: Long?,
|
|
||||||
limit: Long?,
|
|
||||||
theme: String?,
|
|
||||||
member: Member?
|
|
||||||
): List<GetAudioContentRankingItem> {
|
|
||||||
val memberId = member?.id
|
|
||||||
val isAdult = member?.auth != null && isAdultContentVisible
|
|
||||||
|
|
||||||
val currentDateTime = LocalDateTime.now()
|
|
||||||
val startDate = currentDateTime
|
|
||||||
.withHour(15)
|
|
||||||
.withMinute(0)
|
|
||||||
.withSecond(0)
|
|
||||||
.minusWeeks(1)
|
|
||||||
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
|
||||||
val endDate = startDate.plusDays(6)
|
|
||||||
|
|
||||||
return rankingService.getContentRanking(
|
|
||||||
memberId = memberId,
|
|
||||||
isAdult = isAdult,
|
|
||||||
contentType = contentType,
|
|
||||||
startDate = startDate.minusDays(1),
|
|
||||||
endDate = endDate,
|
|
||||||
offset = offset ?: 0,
|
|
||||||
limit = limit ?: 12,
|
|
||||||
sort = sort,
|
|
||||||
theme = theme ?: ""
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek {
|
private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek {
|
||||||
@@ -409,154 +262,4 @@ class HomeService(
|
|||||||
|
|
||||||
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
|
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
|
||||||
}
|
}
|
||||||
|
|
||||||
// 추천 콘텐츠 조회 로직은 변경 가능성을 고려하여 별도 메서드로 추출한다.
|
|
||||||
fun getRecommendContentList(
|
|
||||||
isAdultContentVisible: Boolean,
|
|
||||||
contentType: ContentType,
|
|
||||||
member: Member?
|
|
||||||
): List<AudioContentMainItem> {
|
|
||||||
val memberId = member?.id
|
|
||||||
val isAdult = member?.auth != null && isAdultContentVisible
|
|
||||||
|
|
||||||
// Set + List 조합으로 중복 제거 및 순서 보존, 각 시도마다 limit=60으로 조회
|
|
||||||
val seen = HashSet<Long>(RECOMMEND_TARGET_SIZE * 2)
|
|
||||||
val result = ArrayList<AudioContentMainItem>(RECOMMEND_TARGET_SIZE)
|
|
||||||
var attempt = 0
|
|
||||||
while (attempt < RECOMMEND_MAX_ATTEMPTS && result.size < RECOMMEND_TARGET_SIZE) {
|
|
||||||
attempt += 1
|
|
||||||
val batch = contentService.getLatestContentByTheme(
|
|
||||||
theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회
|
|
||||||
contentType = contentType,
|
|
||||||
offset = 0,
|
|
||||||
limit = (RECOMMEND_TARGET_SIZE * RECOMMEND_MAX_ATTEMPTS).toLong(), // 60개 조회
|
|
||||||
isFree = false,
|
|
||||||
isAdult = isAdult,
|
|
||||||
orderByRandom = true
|
|
||||||
).filter {
|
|
||||||
if (memberId != null) {
|
|
||||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (item in batch) {
|
|
||||||
if (result.size >= RECOMMEND_TARGET_SIZE) break
|
|
||||||
if (seen.add(item.contentId)) {
|
|
||||||
result.add(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return getTranslatedContentList(contentList = result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
|
||||||
*
|
|
||||||
* 처리 절차:
|
|
||||||
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
|
||||||
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
|
||||||
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
|
|
||||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
|
||||||
*
|
|
||||||
* 성능:
|
|
||||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
|
||||||
*
|
|
||||||
* @param contentList 번역 대상 AudioContentMainItem 목록
|
|
||||||
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
|
|
||||||
*/
|
|
||||||
private fun getTranslatedContentList(contentList: List<AudioContentMainItem>): List<AudioContentMainItem> {
|
|
||||||
val contentIds = contentList.map { it.contentId }
|
|
||||||
|
|
||||||
return if (contentIds.isNotEmpty()) {
|
|
||||||
val translations = contentTranslationRepository
|
|
||||||
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
|
||||||
.associateBy { it.contentId }
|
|
||||||
|
|
||||||
contentList.map { item ->
|
|
||||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
|
||||||
if (translatedTitle.isNullOrBlank()) {
|
|
||||||
item
|
|
||||||
} else {
|
|
||||||
item.copy(title = translatedTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
contentList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
|
||||||
*
|
|
||||||
* 처리 절차:
|
|
||||||
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
|
||||||
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
|
||||||
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
|
|
||||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
|
||||||
*
|
|
||||||
* 성능:
|
|
||||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
|
||||||
*
|
|
||||||
* @param seriesList 번역 대상 SeriesListItem 목록
|
|
||||||
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
|
|
||||||
*/
|
|
||||||
private fun getTranslatedSeriesList(
|
|
||||||
seriesList: List<GetSeriesListResponse.SeriesListItem>
|
|
||||||
): List<GetSeriesListResponse.SeriesListItem> {
|
|
||||||
val seriesIds = seriesList.map { it.seriesId }
|
|
||||||
|
|
||||||
return if (seriesIds.isNotEmpty()) {
|
|
||||||
val translations = seriesTranslationRepository
|
|
||||||
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
|
|
||||||
.associateBy { it.seriesId }
|
|
||||||
|
|
||||||
seriesList.map { item ->
|
|
||||||
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
|
|
||||||
if (translatedTitle.isNullOrBlank()) {
|
|
||||||
item
|
|
||||||
} else {
|
|
||||||
item.copy(title = translatedTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
seriesList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
|
||||||
*
|
|
||||||
* 처리 절차:
|
|
||||||
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
|
|
||||||
* 번역 데이터를 한 번에 조회한다.
|
|
||||||
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
|
|
||||||
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
|
|
||||||
*
|
|
||||||
* @param aiCharacterList 번역 대상 캐릭터 목록
|
|
||||||
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
|
|
||||||
*/
|
|
||||||
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
|
|
||||||
val characterIds = aiCharacterList.map { it.characterId }
|
|
||||||
|
|
||||||
return if (characterIds.isNotEmpty()) {
|
|
||||||
val translations = aiCharacterTranslationRepository
|
|
||||||
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
|
||||||
.associateBy { it.characterId }
|
|
||||||
|
|
||||||
aiCharacterList.map { character ->
|
|
||||||
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
|
||||||
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
|
|
||||||
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
|
|
||||||
character
|
|
||||||
} else {
|
|
||||||
character.copy(name = translatedName, description = translatedDesc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
aiCharacterList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.can
|
package kr.co.vividnext.sodalive.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import java.math.BigDecimal
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.EnumType
|
import javax.persistence.EnumType
|
||||||
import javax.persistence.Enumerated
|
import javax.persistence.Enumerated
|
||||||
@@ -12,10 +10,7 @@ data class Can(
|
|||||||
var title: String,
|
var title: String,
|
||||||
var can: Int,
|
var can: Int,
|
||||||
var rewardCan: Int,
|
var rewardCan: Int,
|
||||||
@Column(precision = 10, scale = 4, nullable = false)
|
var price: Int,
|
||||||
var price: BigDecimal,
|
|
||||||
@Column(length = 3, nullable = false, columnDefinition = "CHAR(3)")
|
|
||||||
var currency: String,
|
|
||||||
@Enumerated(value = EnumType.STRING)
|
@Enumerated(value = EnumType.STRING)
|
||||||
var status: CanStatus
|
var status: CanStatus
|
||||||
) : BaseEntity()
|
) : BaseEntity()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.can
|
package kr.co.vividnext.sodalive.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.common.GeoCountry
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
@@ -10,15 +9,13 @@ import org.springframework.web.bind.annotation.GetMapping
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import javax.servlet.http.HttpServletRequest
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/can")
|
@RequestMapping("/can")
|
||||||
class CanController(private val service: CanService) {
|
class CanController(private val service: CanService) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getCans(request: HttpServletRequest): ApiResponse<List<CanResponse>> {
|
fun getCans(): ApiResponse<List<CanResponse>> {
|
||||||
val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER
|
return ApiResponse.ok(service.getCans())
|
||||||
return ApiResponse.ok(service.getCans(geoCountry))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository
|
|||||||
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
|
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
|
||||||
|
|
||||||
interface CanQueryRepository {
|
interface CanQueryRepository {
|
||||||
fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse>
|
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
||||||
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
|
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
|
||||||
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
|
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
|
||||||
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
|
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
|
||||||
@@ -32,7 +32,7 @@ interface CanQueryRepository {
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
|
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
|
||||||
override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> {
|
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QCanResponse(
|
QCanResponse(
|
||||||
@@ -40,16 +40,11 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
|
|||||||
can1.title,
|
can1.title,
|
||||||
can1.can,
|
can1.can,
|
||||||
can1.rewardCan,
|
can1.rewardCan,
|
||||||
can1.price.intValue(),
|
can1.price
|
||||||
can1.currency,
|
|
||||||
can1.price.stringValue()
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(can1)
|
.from(can1)
|
||||||
.where(
|
.where(can1.status.eq(status))
|
||||||
can1.status.eq(status),
|
|
||||||
can1.currency.eq(currency)
|
|
||||||
)
|
|
||||||
.orderBy(can1.can.asc())
|
.orderBy(can1.can.asc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
@@ -69,13 +64,11 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
|
|||||||
val chargeStatusCondition = when (container) {
|
val chargeStatusCondition = when (container) {
|
||||||
"aos" -> {
|
"aos" -> {
|
||||||
charge.payment.paymentGateway.eq(PaymentGateway.PG)
|
charge.payment.paymentGateway.eq(PaymentGateway.PG)
|
||||||
.or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
|
||||||
.or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
.or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
||||||
}
|
}
|
||||||
|
|
||||||
"ios" -> {
|
"ios" -> {
|
||||||
charge.payment.paymentGateway.eq(PaymentGateway.PG)
|
charge.payment.paymentGateway.eq(PaymentGateway.PG)
|
||||||
.or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
|
||||||
.or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
.or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,5 @@ data class CanResponse @QueryProjection constructor(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val rewardCan: Int,
|
val rewardCan: Int,
|
||||||
val price: Int,
|
val price: Int
|
||||||
val currency: String,
|
|
||||||
val priceStr: String
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.can
|
|||||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
import kr.co.vividnext.sodalive.common.GeoCountry
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -12,12 +11,8 @@ import java.time.format.DateTimeFormatter
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class CanService(private val repository: CanRepository) {
|
class CanService(private val repository: CanRepository) {
|
||||||
fun getCans(geoCountry: GeoCountry): List<CanResponse> {
|
fun getCans(): List<CanResponse> {
|
||||||
val currency = when (geoCountry) {
|
return repository.findAllByStatus(status = CanStatus.SALE)
|
||||||
GeoCountry.KR -> "KRW"
|
|
||||||
else -> "USD"
|
|
||||||
}
|
|
||||||
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
|
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
|
||||||
@@ -40,7 +35,6 @@ class CanService(private val repository: CanRepository) {
|
|||||||
"aos" -> {
|
"aos" -> {
|
||||||
it.useCanCalculates.any { useCanCalculate ->
|
it.useCanCalculates.any { useCanCalculate ->
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
|
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
|
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,14 +42,12 @@ class CanService(private val repository: CanRepository) {
|
|||||||
"ios" -> {
|
"ios" -> {
|
||||||
it.useCanCalculates.any { useCanCalculate ->
|
it.useCanCalculates.any { useCanCalculate ->
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
|
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
|
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> it.useCanCalculates.any { useCanCalculate ->
|
else -> it.useCanCalculates.any { useCanCalculate ->
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
useCanCalculate.paymentGateway == PaymentGateway.PG
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.can.charge
|
package kr.co.vividnext.sodalive.can.charge
|
||||||
|
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class ChargeCompleteResponse(
|
data class ChargeCompleteResponse(
|
||||||
val price: BigDecimal,
|
val price: Double,
|
||||||
val currencyCode: String,
|
val currencyCode: String,
|
||||||
val isFirstCharged: Boolean
|
val isFirstCharged: Boolean
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,77 +6,20 @@ import kr.co.vividnext.sodalive.common.SodaException
|
|||||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||||
import kr.co.vividnext.sodalive.marketing.AdTrackingService
|
import kr.co.vividnext.sodalive.marketing.AdTrackingService
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import org.springframework.web.server.ResponseStatusException
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import javax.servlet.http.HttpServletRequest
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/charge")
|
@RequestMapping("/charge")
|
||||||
class ChargeController(
|
class ChargeController(
|
||||||
private val service: ChargeService,
|
private val service: ChargeService,
|
||||||
private val trackingService: AdTrackingService,
|
private val trackingService: AdTrackingService
|
||||||
|
|
||||||
@Value("\${payverse.inbound-ip}")
|
|
||||||
private val payverseInboundIp: String
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@PostMapping("/payverse")
|
|
||||||
fun payverseCharge(
|
|
||||||
@RequestBody request: PayverseChargeRequest,
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
|
||||||
) = run {
|
|
||||||
if (member == null) {
|
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(service.payverseCharge(member, request))
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/payverse/verify")
|
|
||||||
fun payverseVerify(
|
|
||||||
@RequestBody verifyRequest: PayverseVerifyRequest,
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
|
||||||
) = run {
|
|
||||||
if (member == null) {
|
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
|
|
||||||
trackingCharge(member, response)
|
|
||||||
ApiResponse.ok(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payverse Webhook 엔드포인트 (payverseVerify 아래)
|
|
||||||
@PostMapping("/payverse/webhook")
|
|
||||||
fun payverseWebhook(
|
|
||||||
@RequestBody request: PayverseWebhookRequest,
|
|
||||||
servletRequest: HttpServletRequest
|
|
||||||
): PayverseWebhookResponse {
|
|
||||||
val header = servletRequest.getHeader("X-Forwarded-For")
|
|
||||||
val remoteIp = if (header.isNullOrEmpty()) {
|
|
||||||
servletRequest.remoteAddr
|
|
||||||
} else {
|
|
||||||
header.split(",")[0].trim() // 첫 번째 값이 클라이언트 IP
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remoteIp != payverseInboundIp) {
|
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
|
||||||
}
|
|
||||||
|
|
||||||
val success = service.payverseWebhook(request)
|
|
||||||
if (!success) {
|
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
|
||||||
}
|
|
||||||
return PayverseWebhookResponse(receiveResult = "SUCCESS")
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun charge(
|
fun charge(
|
||||||
@RequestBody chargeRequest: ChargeRequest,
|
@RequestBody chargeRequest: ChargeRequest,
|
||||||
@@ -168,7 +111,8 @@ class ChargeController(
|
|||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
chargeId = chargeId,
|
chargeId = chargeId,
|
||||||
productId = request.productId,
|
productId = request.productId,
|
||||||
purchaseToken = request.purchaseToken
|
purchaseToken = request.purchaseToken,
|
||||||
|
paymentGateway = request.paymentGateway
|
||||||
)
|
)
|
||||||
|
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.can.charge
|
|||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
|
data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway)
|
||||||
|
|
||||||
@@ -21,14 +20,14 @@ data class VerifyResult(
|
|||||||
val method: String,
|
val method: String,
|
||||||
val pg: String,
|
val pg: String,
|
||||||
val status: Int,
|
val status: Int,
|
||||||
val price: BigDecimal
|
val price: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AppleChargeRequest(
|
data class AppleChargeRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val chargeCan: Int,
|
val chargeCan: Int,
|
||||||
val paymentGateway: PaymentGateway,
|
val paymentGateway: PaymentGateway,
|
||||||
var price: BigDecimal? = null,
|
var price: Double? = null,
|
||||||
var locale: String? = null
|
var locale: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,53 +38,9 @@ data class AppleVerifyResponse(val status: Int)
|
|||||||
data class GoogleChargeRequest(
|
data class GoogleChargeRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val chargeCan: Int,
|
val chargeCan: Int,
|
||||||
val price: BigDecimal,
|
val price: Double,
|
||||||
val currencyCode: String,
|
val currencyCode: String,
|
||||||
val productId: String,
|
val productId: String,
|
||||||
val purchaseToken: String,
|
val purchaseToken: String,
|
||||||
val paymentGateway: PaymentGateway
|
val paymentGateway: PaymentGateway
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PayverseChargeRequest(
|
|
||||||
val canId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayverseChargeResponse(
|
|
||||||
val chargeId: Long,
|
|
||||||
val payloadJson: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayverseVerifyRequest(
|
|
||||||
val transactionId: String,
|
|
||||||
val orderId: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayverseVerifyResponse(
|
|
||||||
val resultStatus: String,
|
|
||||||
val tid: String,
|
|
||||||
val schemeGroup: String,
|
|
||||||
val schemeCode: String,
|
|
||||||
val transactionType: String,
|
|
||||||
val transactionStatus: String,
|
|
||||||
val transactionMessage: String,
|
|
||||||
val orderId: String,
|
|
||||||
val customerId: String,
|
|
||||||
val requestCurrency: String,
|
|
||||||
val requestAmount: BigDecimal
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayverseWebhookRequest(
|
|
||||||
val type: String,
|
|
||||||
val mid: String,
|
|
||||||
val tid: String,
|
|
||||||
val schemeGroup: String,
|
|
||||||
val schemeCode: String,
|
|
||||||
val orderId: String,
|
|
||||||
val requestCurrency: String,
|
|
||||||
val requestAmount: BigDecimal,
|
|
||||||
val resultStatus: String,
|
|
||||||
val approvalDay: String,
|
|
||||||
val sign: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PayverseWebhookResponse(val receiveResult: String)
|
|
||||||
|
|||||||
@@ -113,18 +113,15 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha
|
|||||||
val paymentGatewayCondition = when (container) {
|
val paymentGatewayCondition = when (container) {
|
||||||
"aos" -> {
|
"aos" -> {
|
||||||
payment.paymentGateway.eq(PaymentGateway.PG)
|
payment.paymentGateway.eq(PaymentGateway.PG)
|
||||||
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
|
||||||
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
||||||
}
|
}
|
||||||
|
|
||||||
"ios" -> {
|
"ios" -> {
|
||||||
payment.paymentGateway.eq(PaymentGateway.PG)
|
payment.paymentGateway.eq(PaymentGateway.PG)
|
||||||
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
|
||||||
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> payment.paymentGateway.eq(PaymentGateway.PG)
|
else -> payment.paymentGateway.eq(PaymentGateway.PG)
|
||||||
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return paymentGatewayCondition.or(payment.paymentGateway.eq(PaymentGateway.POINT_CLICK_AD))
|
return paymentGatewayCondition.or(payment.paymentGateway.eq(PaymentGateway.POINT_CLICK_AD))
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.apache.commons.codec.digest.DigestUtils
|
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -35,7 +34,6 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -65,112 +63,9 @@ class ChargeService(
|
|||||||
@Value("\${apple.iap-verify-sandbox-url}")
|
@Value("\${apple.iap-verify-sandbox-url}")
|
||||||
private val appleInAppVerifySandBoxUrl: String,
|
private val appleInAppVerifySandBoxUrl: String,
|
||||||
@Value("\${apple.iap-verify-url}")
|
@Value("\${apple.iap-verify-url}")
|
||||||
private val appleInAppVerifyUrl: String,
|
private val appleInAppVerifyUrl: String
|
||||||
|
|
||||||
@Value("\${payverse.mid}")
|
|
||||||
private val payverseMid: String,
|
|
||||||
@Value("\${payverse.client-key}")
|
|
||||||
private val payverseClientKey: String,
|
|
||||||
@Value("\${payverse.secret-key}")
|
|
||||||
private val payverseSecretKey: String,
|
|
||||||
|
|
||||||
@Value("\${payverse.usd-mid}")
|
|
||||||
private val payverseUsdMid: String,
|
|
||||||
@Value("\${payverse.usd-client-key}")
|
|
||||||
private val payverseUsdClientKey: String,
|
|
||||||
@Value("\${payverse.usd-secret-key}")
|
|
||||||
private val payverseUsdSecretKey: String,
|
|
||||||
|
|
||||||
@Value("\${payverse.host}")
|
|
||||||
private val payverseHost: String,
|
|
||||||
|
|
||||||
@Value("\${server.env}")
|
|
||||||
private val serverEnv: String
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Transactional
|
|
||||||
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
|
@Transactional
|
||||||
fun chargeByCoupon(couponNumber: String, member: Member): String {
|
fun chargeByCoupon(couponNumber: String, member: Member): String {
|
||||||
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
||||||
@@ -231,177 +126,6 @@ class ChargeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun payverseCharge(member: Member, request: PayverseChargeRequest): PayverseChargeResponse {
|
|
||||||
val can = canRepository.findByIdOrNull(request.canId)
|
|
||||||
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
|
|
||||||
|
|
||||||
val requestCurrency = can.currency
|
|
||||||
val isKrw = requestCurrency == "KRW"
|
|
||||||
val mid = if (isKrw) {
|
|
||||||
payverseMid
|
|
||||||
} else {
|
|
||||||
payverseUsdMid
|
|
||||||
}
|
|
||||||
val clientKey = if (isKrw) {
|
|
||||||
payverseClientKey
|
|
||||||
} else {
|
|
||||||
payverseUsdClientKey
|
|
||||||
}
|
|
||||||
val secretKey = if (isKrw) {
|
|
||||||
payverseSecretKey
|
|
||||||
} else {
|
|
||||||
payverseUsdSecretKey
|
|
||||||
}
|
|
||||||
|
|
||||||
val charge = Charge(can.can, can.rewardCan)
|
|
||||||
charge.title = can.title
|
|
||||||
charge.member = member
|
|
||||||
charge.can = can
|
|
||||||
|
|
||||||
val payment = Payment(paymentGateway = PaymentGateway.PAYVERSE)
|
|
||||||
payment.price = can.price
|
|
||||||
charge.payment = payment
|
|
||||||
|
|
||||||
val savedCharge = chargeRepository.save(charge)
|
|
||||||
|
|
||||||
val chargeId = savedCharge.id!!
|
|
||||||
val amount = BigDecimal(
|
|
||||||
savedCharge.payment!!.price
|
|
||||||
.setScale(4, RoundingMode.HALF_UP)
|
|
||||||
.stripTrailingZeros()
|
|
||||||
.toPlainString()
|
|
||||||
)
|
|
||||||
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
|
||||||
val sign = DigestUtils.sha512Hex(
|
|
||||||
String.format(
|
|
||||||
"||%s||%s||%s||%s||%s||",
|
|
||||||
secretKey,
|
|
||||||
mid,
|
|
||||||
chargeId,
|
|
||||||
amount,
|
|
||||||
reqDate
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val customerId = "${serverEnv}_user_${member.id!!}"
|
|
||||||
|
|
||||||
val payload = linkedMapOf(
|
|
||||||
"mid" to mid,
|
|
||||||
"clientKey" to clientKey,
|
|
||||||
"orderId" to chargeId.toString(),
|
|
||||||
"customerId" to customerId,
|
|
||||||
"productName" to can.title,
|
|
||||||
"requestCurrency" to requestCurrency,
|
|
||||||
"requestAmount" to amount,
|
|
||||||
"reqDate" to reqDate,
|
|
||||||
"sign" to sign
|
|
||||||
)
|
|
||||||
val payloadJson = objectMapper.writeValueAsString(payload)
|
|
||||||
|
|
||||||
return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
|
|
||||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
|
||||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
|
||||||
val member = memberRepository.findByIdOrNull(memberId)
|
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
|
||||||
|
|
||||||
val isKrw = charge.can?.currency == "KRW"
|
|
||||||
val mid = if (isKrw) {
|
|
||||||
payverseMid
|
|
||||||
} else {
|
|
||||||
payverseUsdMid
|
|
||||||
}
|
|
||||||
val clientKey = if (isKrw) {
|
|
||||||
payverseClientKey
|
|
||||||
} else {
|
|
||||||
payverseUsdClientKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// 결제수단 확인
|
|
||||||
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
|
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 결제 상태에 따른 분기 처리
|
|
||||||
when (charge.payment?.status) {
|
|
||||||
PaymentStatus.REQUEST -> {
|
|
||||||
try {
|
|
||||||
val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}"
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.addHeader("mid", mid)
|
|
||||||
.addHeader("clientKey", clientKey)
|
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val response = okHttpClient.newCall(request).execute()
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
|
||||||
}
|
|
||||||
|
|
||||||
val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.")
|
|
||||||
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
|
|
||||||
|
|
||||||
val customerId = "${serverEnv}_user_${member.id!!}"
|
|
||||||
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
|
|
||||||
verifyResponse.transactionStatus == "SUCCESS" &&
|
|
||||||
verifyResponse.orderId.toLongOrNull() == charge.id &&
|
|
||||||
verifyResponse.customerId == customerId &&
|
|
||||||
verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0
|
|
||||||
|
|
||||||
if (isSuccess) {
|
|
||||||
// verify 함수의 232~248 라인과 동일 처리
|
|
||||||
charge.payment?.receiptId = verifyResponse.tid
|
|
||||||
val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") {
|
|
||||||
mapPayverseSchemeToMethodByCode(verifyResponse.schemeCode)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
charge.payment?.method = mappedMethod ?: verifyResponse.schemeCode
|
|
||||||
charge.payment?.status = PaymentStatus.COMPLETE
|
|
||||||
// 통화코드 설정
|
|
||||||
charge.payment?.locale = verifyResponse.requestCurrency
|
|
||||||
|
|
||||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
|
||||||
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
ChargeSpringEvent(
|
|
||||||
chargeId = charge.id!!,
|
|
||||||
memberId = member.id!!
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
|
||||||
price = charge.payment!!.price,
|
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PaymentStatus.COMPLETE -> {
|
|
||||||
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
|
|
||||||
return ChargeCompleteResponse(
|
|
||||||
price = charge.payment!!.price,
|
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun charge(member: Member, request: ChargeRequest): ChargeResponse {
|
fun charge(member: Member, request: ChargeRequest): ChargeResponse {
|
||||||
val can = canRepository.findByIdOrNull(request.canId)
|
val can = canRepository.findByIdOrNull(request.canId)
|
||||||
@@ -413,7 +137,7 @@ class ChargeService(
|
|||||||
charge.can = can
|
charge.can = can
|
||||||
|
|
||||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||||
payment.price = can.price
|
payment.price = can.price.toDouble()
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -452,14 +176,14 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = charge.payment!!.price,
|
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (e: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -484,7 +208,7 @@ class ChargeService(
|
|||||||
VerifyResult::class.java
|
VerifyResult::class.java
|
||||||
)
|
)
|
||||||
|
|
||||||
if (verifyResult.status == 1) {
|
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
|
||||||
charge.payment?.receiptId = verifyResult.receiptId
|
charge.payment?.receiptId = verifyResult.receiptId
|
||||||
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
|
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
|
||||||
"${verifyResult.pg}-${verifyResult.method}"
|
"${verifyResult.pg}-${verifyResult.method}"
|
||||||
@@ -502,14 +226,14 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = charge.payment!!.price,
|
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (e: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -527,7 +251,7 @@ class ChargeService(
|
|||||||
payment.price = if (request.price != null) {
|
payment.price = if (request.price != null) {
|
||||||
request.price!!
|
request.price!!
|
||||||
} else {
|
} else {
|
||||||
0.toBigDecimal()
|
0.toDouble()
|
||||||
}
|
}
|
||||||
|
|
||||||
payment.locale = request.locale
|
payment.locale = request.locale
|
||||||
@@ -562,7 +286,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = charge.payment!!.price,
|
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -579,7 +303,7 @@ class ChargeService(
|
|||||||
member: Member,
|
member: Member,
|
||||||
title: String,
|
title: String,
|
||||||
chargeCan: Int,
|
chargeCan: Int,
|
||||||
price: BigDecimal,
|
price: Double,
|
||||||
currencyCode: String,
|
currencyCode: String,
|
||||||
productId: String,
|
productId: String,
|
||||||
purchaseToken: String,
|
purchaseToken: String,
|
||||||
@@ -607,7 +331,8 @@ class ChargeService(
|
|||||||
memberId: Long,
|
memberId: Long,
|
||||||
chargeId: Long,
|
chargeId: Long,
|
||||||
productId: String,
|
productId: String,
|
||||||
purchaseToken: String
|
purchaseToken: String,
|
||||||
|
paymentGateway: PaymentGateway
|
||||||
): ChargeCompleteResponse {
|
): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
||||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
@@ -629,7 +354,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = charge.payment!!.price,
|
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -711,13 +436,4 @@ class ChargeService(
|
|||||||
throw SodaException("결제를 완료하지 못했습니다.")
|
throw SodaException("결제를 완료하지 못했습니다.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환
|
|
||||||
private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? {
|
|
||||||
val cardCodes = setOf(
|
|
||||||
"041", "044", "361", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "381",
|
|
||||||
"218", "071", "002", "089", "045", "050", "048", "090", "092"
|
|
||||||
)
|
|
||||||
return if (schemeCode != null && cardCodes.contains(schemeCode)) "카드" else null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package kr.co.vividnext.sodalive.can.charge.temp
|
package kr.co.vividnext.sodalive.can.charge.temp
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class ChargeTempRequest(
|
data class ChargeTempRequest(
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val price: BigDecimal,
|
val price: Int,
|
||||||
val paymentGateway: PaymentGateway
|
val paymentGateway: PaymentGateway
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class ChargeTempService(
|
|||||||
charge.member = member
|
charge.member = member
|
||||||
|
|
||||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||||
payment.price = request.price
|
payment.price = request.price.toDouble()
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -66,7 +66,7 @@ class ChargeTempService(
|
|||||||
VerifyResult::class.java
|
VerifyResult::class.java
|
||||||
)
|
)
|
||||||
|
|
||||||
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price) {
|
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) {
|
||||||
charge.payment?.receiptId = verifyResult.receiptId
|
charge.payment?.receiptId = verifyResult.receiptId
|
||||||
charge.payment?.method = verifyResult.method
|
charge.payment?.method = verifyResult.method
|
||||||
charge.payment?.status = PaymentStatus.COMPLETE
|
charge.payment?.status = PaymentStatus.COMPLETE
|
||||||
@@ -74,7 +74,7 @@ class ChargeTempService(
|
|||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (e: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ class CanPaymentService(
|
|||||||
useCanRepository.save(useCan)
|
useCanRepository.save(useCan)
|
||||||
|
|
||||||
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||||
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
|
||||||
setUseCanCalculate(
|
setUseCanCalculate(
|
||||||
recipientId,
|
recipientId,
|
||||||
useRewardCan,
|
useRewardCan,
|
||||||
@@ -380,7 +379,6 @@ class CanPaymentService(
|
|||||||
useCanRepository.save(useCan)
|
useCanRepository.save(useCan)
|
||||||
|
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
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.POINT_CLICK_AD)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||||
@@ -430,7 +428,6 @@ class CanPaymentService(
|
|||||||
useCanRepository.save(useCan)
|
useCanRepository.save(useCan)
|
||||||
|
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
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.POINT_CLICK_AD)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.can.payment
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import java.math.BigDecimal
|
|
||||||
import javax.persistence.Column
|
import javax.persistence.Column
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.EnumType
|
import javax.persistence.EnumType
|
||||||
@@ -26,8 +25,7 @@ data class Payment(
|
|||||||
var receiptId: String? = null
|
var receiptId: String? = null
|
||||||
var method: String? = null
|
var method: String? = null
|
||||||
|
|
||||||
@Column(precision = 10, scale = 4, nullable = false)
|
var price: Double = 0.toDouble()
|
||||||
var price: BigDecimal = 0.toBigDecimal()
|
|
||||||
var locale: String? = null
|
var locale: String? = null
|
||||||
var orderId: String? = null
|
var orderId: String? = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package kr.co.vividnext.sodalive.can.payment
|
package kr.co.vividnext.sodalive.can.payment
|
||||||
|
|
||||||
enum class PaymentGateway {
|
enum class PaymentGateway {
|
||||||
PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
|
PG, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character
|
package kr.co.vividnext.sodalive.chat.character
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
import javax.persistence.CascadeType
|
import javax.persistence.CascadeType
|
||||||
import javax.persistence.Column
|
import javax.persistence.Column
|
||||||
@@ -8,8 +7,6 @@ import javax.persistence.Entity
|
|||||||
import javax.persistence.EnumType
|
import javax.persistence.EnumType
|
||||||
import javax.persistence.Enumerated
|
import javax.persistence.Enumerated
|
||||||
import javax.persistence.FetchType
|
import javax.persistence.FetchType
|
||||||
import javax.persistence.JoinColumn
|
|
||||||
import javax.persistence.ManyToOne
|
|
||||||
import javax.persistence.OneToMany
|
import javax.persistence.OneToMany
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -22,8 +19,6 @@ class ChatCharacter(
|
|||||||
// 캐릭터 한 줄 소개
|
// 캐릭터 한 줄 소개
|
||||||
var description: String,
|
var description: String,
|
||||||
|
|
||||||
var languageCode: String? = null,
|
|
||||||
|
|
||||||
// AI 시스템 프롬프트
|
// AI 시스템 프롬프트
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var systemPrompt: String,
|
var systemPrompt: String,
|
||||||
@@ -49,19 +44,14 @@ class ChatCharacter(
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
var appearance: String? = null,
|
var appearance: String? = null,
|
||||||
|
|
||||||
// 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
// 원작 (optional)
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var originalTitle: String? = null,
|
var originalTitle: String? = null,
|
||||||
|
|
||||||
// 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지)
|
// 원작 링크 (optional)
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var originalLink: String? = null,
|
var originalLink: String? = null,
|
||||||
|
|
||||||
// 연관 원작 (한 캐릭터는 하나의 원작에만 속함)
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "original_work_id")
|
|
||||||
var originalWork: OriginalWork? = null,
|
|
||||||
|
|
||||||
// 캐릭터 유형
|
// 캐릭터 유형
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import javax.persistence.Table
|
|||||||
data class CharacterComment(
|
data class CharacterComment(
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var comment: String,
|
var comment: String,
|
||||||
var languageCode: String?,
|
|
||||||
var isActive: Boolean = true
|
var isActive: Boolean = true
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class CharacterCommentController(
|
|||||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||||
|
|
||||||
val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode)
|
val id = service.addReply(characterId, commentId, member, request.comment)
|
||||||
ApiResponse.ok(id)
|
ApiResponse.ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ package kr.co.vividnext.sodalive.chat.character.comment
|
|||||||
|
|
||||||
// Request DTOs
|
// Request DTOs
|
||||||
data class CreateCharacterCommentRequest(
|
data class CreateCharacterCommentRequest(
|
||||||
val comment: String,
|
val comment: String
|
||||||
val languageCode: String? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response DTOs
|
// Response DTOs
|
||||||
@@ -21,8 +20,7 @@ data class CharacterCommentResponse(
|
|||||||
val memberNickname: String,
|
val memberNickname: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val replyCount: Int,
|
val replyCount: Int,
|
||||||
val comment: String,
|
val comment: String
|
||||||
val languageCode: String?
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 답글 Response 단건(목록 원소)
|
// 답글 Response 단건(목록 원소)
|
||||||
@@ -37,8 +35,7 @@ data class CharacterReplyResponse(
|
|||||||
val memberProfileImage: String,
|
val memberProfileImage: String,
|
||||||
val memberNickname: String,
|
val memberNickname: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val comment: String,
|
val comment: String
|
||||||
val languageCode: String?
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 댓글의 답글 조회 Response 컨테이너
|
// 댓글의 답글 조회 Response 컨테이너
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package kr.co.vividnext.sodalive.chat.character.comment
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
|
||||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -15,8 +12,7 @@ import java.time.ZoneId
|
|||||||
class CharacterCommentService(
|
class CharacterCommentService(
|
||||||
private val chatCharacterRepository: ChatCharacterRepository,
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
private val commentRepository: CharacterCommentRepository,
|
private val commentRepository: CharacterCommentRepository,
|
||||||
private val reportRepository: CharacterCommentReportRepository,
|
private val reportRepository: CharacterCommentReportRepository
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private fun profileUrl(imageHost: String, profileImage: String?): String {
|
private fun profileUrl(imageHost: String, profileImage: String?): String {
|
||||||
@@ -44,8 +40,7 @@ class CharacterCommentService(
|
|||||||
memberNickname = member.nickname,
|
memberNickname = member.nickname,
|
||||||
createdAt = toEpochMilli(entity.createdAt),
|
createdAt = toEpochMilli(entity.createdAt),
|
||||||
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
|
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
|
||||||
comment = entity.comment,
|
comment = entity.comment
|
||||||
languageCode = entity.languageCode
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,44 +52,25 @@ class CharacterCommentService(
|
|||||||
memberProfileImage = profileUrl(imageHost, member.profileImage),
|
memberProfileImage = profileUrl(imageHost, member.profileImage),
|
||||||
memberNickname = member.nickname,
|
memberNickname = member.nickname,
|
||||||
createdAt = toEpochMilli(entity.createdAt),
|
createdAt = toEpochMilli(entity.createdAt),
|
||||||
comment = entity.comment,
|
comment = entity.comment
|
||||||
languageCode = entity.languageCode
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long {
|
fun addComment(characterId: Long, member: Member, text: String): Long {
|
||||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||||
|
|
||||||
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
val entity = CharacterComment(comment = text)
|
||||||
entity.chatCharacter = character
|
entity.chatCharacter = character
|
||||||
entity.member = member
|
entity.member = member
|
||||||
commentRepository.save(entity)
|
commentRepository.save(entity)
|
||||||
|
|
||||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
|
||||||
if (languageCode.isNullOrBlank()) {
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
LanguageDetectEvent(
|
|
||||||
id = entity.id!!,
|
|
||||||
query = text,
|
|
||||||
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entity.id!!
|
return entity.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun addReply(
|
fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): Long {
|
||||||
characterId: Long,
|
|
||||||
parentCommentId: Long,
|
|
||||||
member: Member,
|
|
||||||
text: String,
|
|
||||||
languageCode: String? = null
|
|
||||||
): Long {
|
|
||||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||||
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
||||||
@@ -102,23 +78,11 @@ class CharacterCommentService(
|
|||||||
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
|
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
|
||||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||||
|
|
||||||
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
val entity = CharacterComment(comment = text)
|
||||||
entity.chatCharacter = character
|
entity.chatCharacter = character
|
||||||
entity.member = member
|
entity.member = member
|
||||||
entity.parent = parent
|
entity.parent = parent
|
||||||
commentRepository.save(entity)
|
commentRepository.save(entity)
|
||||||
|
|
||||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
|
||||||
if (languageCode.isNullOrBlank()) {
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
LanguageDetectEvent(
|
|
||||||
id = entity.id!!,
|
|
||||||
query = text,
|
|
||||||
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entity.id!!
|
return entity.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.controller
|
package kr.co.vividnext.sodalive.chat.character.controller
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
|
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
|
||||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
|
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
|
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
|
||||||
@@ -11,21 +10,11 @@ import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse
|
|||||||
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
|
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
|
import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
|
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
|
|
||||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
@@ -33,7 +22,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -43,12 +31,7 @@ class ChatCharacterController(
|
|||||||
private val bannerService: ChatCharacterBannerService,
|
private val bannerService: ChatCharacterBannerService,
|
||||||
private val chatRoomService: ChatRoomService,
|
private val chatRoomService: ChatRoomService,
|
||||||
private val characterCommentService: CharacterCommentService,
|
private val characterCommentService: CharacterCommentService,
|
||||||
private val curationQueryService: CharacterCurationQueryService,
|
private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService,
|
||||||
|
|
||||||
private val translationService: PapagoTranslationService,
|
|
||||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
|
||||||
|
|
||||||
private val langContext: LangContext,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
@@ -81,39 +64,27 @@ class ChatCharacterController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val characterIds = recentCharacters.map { it.characterId }
|
// 인기 캐릭터 조회 (현재는 빈 리스트)
|
||||||
val translatedRecentCharacters = if (characterIds.isNotEmpty()) {
|
|
||||||
val translations = aiCharacterTranslationRepository
|
|
||||||
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
|
||||||
.associateBy { it.characterId }
|
|
||||||
|
|
||||||
recentCharacters.map { character ->
|
|
||||||
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
|
||||||
if (translatedName.isNullOrBlank()) {
|
|
||||||
character
|
|
||||||
} else {
|
|
||||||
character.copy(name = translatedName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
recentCharacters
|
|
||||||
}
|
|
||||||
|
|
||||||
// 인기 캐릭터 조회
|
|
||||||
val popularCharacters = service.getPopularCharacters()
|
val popularCharacters = service.getPopularCharacters()
|
||||||
|
.map {
|
||||||
|
Character(
|
||||||
|
characterId = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 최근 등록된 캐릭터 리스트 조회
|
// 최신 캐릭터 조회 (최대 10개)
|
||||||
val newCharacters = service.getRecentCharactersPage(
|
val newCharacters = service.getNewCharacters(50)
|
||||||
page = 0,
|
.map {
|
||||||
size = 50
|
Character(
|
||||||
).content
|
characterId = it.id!!,
|
||||||
|
name = it.name,
|
||||||
// 추천 캐릭터 조회
|
description = it.description,
|
||||||
// 최근 대화한 캐릭터를 제외한 랜덤 30개 조회
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
// Controller에서는 호출만
|
)
|
||||||
// 세부로직은 추후에 변경될 수 있으므로 Service에 별도로 생성
|
}
|
||||||
val excludeIds = recentCharacters.map { it.characterId }
|
|
||||||
val recommendCharacters = service.getRecommendCharacters(excludeIds, 30)
|
|
||||||
|
|
||||||
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
||||||
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
||||||
@@ -126,8 +97,7 @@ class ChatCharacterController(
|
|||||||
characterId = it.id!!,
|
characterId = it.id!!,
|
||||||
name = it.name,
|
name = it.name,
|
||||||
description = it.description,
|
description = it.description,
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
new = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -137,10 +107,9 @@ class ChatCharacterController(
|
|||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
CharacterMainResponse(
|
CharacterMainResponse(
|
||||||
banners = banners,
|
banners = banners,
|
||||||
recentCharacters = translatedRecentCharacters,
|
recentCharacters = recentCharacters,
|
||||||
popularCharacters = getTranslatedAiCharacterList(popularCharacters),
|
popularCharacters = popularCharacters,
|
||||||
newCharacters = getTranslatedAiCharacterList(newCharacters),
|
newCharacters = newCharacters,
|
||||||
recommendCharacters = getTranslatedAiCharacterList(recommendCharacters),
|
|
||||||
curationSections = curationSections
|
curationSections = curationSections
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -182,118 +151,6 @@ class ChatCharacterController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var translated: TranslatedAiCharacterDetail? = null
|
|
||||||
if (langContext.lang.code != character.languageCode) {
|
|
||||||
val existing = aiCharacterTranslationRepository
|
|
||||||
.findByCharacterIdAndLocale(character.id!!, langContext.lang.code)
|
|
||||||
|
|
||||||
if (existing != null) {
|
|
||||||
val payload = existing.renderedPayload
|
|
||||||
translated = TranslatedAiCharacterDetail(
|
|
||||||
name = payload.name,
|
|
||||||
description = payload.description,
|
|
||||||
gender = payload.gender,
|
|
||||||
personality = TranslatedAiCharacterPersonality(
|
|
||||||
trait = payload.personalityTrait,
|
|
||||||
description = payload.personalityDescription
|
|
||||||
).takeIf {
|
|
||||||
(it.trait?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
|
|
||||||
},
|
|
||||||
background = TranslatedAiCharacterBackground(
|
|
||||||
topic = payload.backgroundTopic,
|
|
||||||
description = payload.backgroundDescription
|
|
||||||
).takeIf {
|
|
||||||
(it.topic?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
|
|
||||||
},
|
|
||||||
tags = payload.tags
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val texts = mutableListOf<String>()
|
|
||||||
texts.add(character.name)
|
|
||||||
texts.add(character.description)
|
|
||||||
texts.add(character.gender ?: "")
|
|
||||||
|
|
||||||
val hasPersonality = personality != null
|
|
||||||
if (hasPersonality) {
|
|
||||||
texts.add(personality!!.trait)
|
|
||||||
texts.add(personality.description)
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasBackground = background != null
|
|
||||||
if (hasBackground) {
|
|
||||||
texts.add(background!!.topic)
|
|
||||||
texts.add(background.description)
|
|
||||||
}
|
|
||||||
|
|
||||||
texts.add(tags)
|
|
||||||
|
|
||||||
val sourceLanguage = character.languageCode ?: "ko"
|
|
||||||
|
|
||||||
val response = translationService.translate(
|
|
||||||
request = TranslateRequest(
|
|
||||||
texts = texts,
|
|
||||||
sourceLanguage = sourceLanguage,
|
|
||||||
targetLanguage = langContext.lang.code
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val translatedTexts = response.translatedText
|
|
||||||
if (translatedTexts.size == texts.size) {
|
|
||||||
var index = 0
|
|
||||||
|
|
||||||
val translatedName = translatedTexts[index++]
|
|
||||||
val translatedDescription = translatedTexts[index++]
|
|
||||||
val translatedGender = translatedTexts[index++]
|
|
||||||
|
|
||||||
var translatedPersonality: TranslatedAiCharacterPersonality? = null
|
|
||||||
if (hasPersonality) {
|
|
||||||
translatedPersonality = TranslatedAiCharacterPersonality(
|
|
||||||
trait = translatedTexts[index++],
|
|
||||||
description = translatedTexts[index++]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var translatedBackground: TranslatedAiCharacterBackground? = null
|
|
||||||
if (hasBackground) {
|
|
||||||
translatedBackground = TranslatedAiCharacterBackground(
|
|
||||||
topic = translatedTexts[index++],
|
|
||||||
description = translatedTexts[index++]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val translatedTags = translatedTexts[index]
|
|
||||||
|
|
||||||
val payload = AiCharacterTranslationRenderedPayload(
|
|
||||||
name = translatedName,
|
|
||||||
description = translatedDescription,
|
|
||||||
gender = translatedGender,
|
|
||||||
personalityTrait = translatedPersonality?.trait ?: "",
|
|
||||||
personalityDescription = translatedPersonality?.description ?: "",
|
|
||||||
backgroundTopic = translatedBackground?.topic ?: "",
|
|
||||||
backgroundDescription = translatedBackground?.description ?: "",
|
|
||||||
tags = translatedTags
|
|
||||||
)
|
|
||||||
|
|
||||||
val entity = AiCharacterTranslation(
|
|
||||||
characterId = character.id!!,
|
|
||||||
locale = langContext.lang.code,
|
|
||||||
renderedPayload = payload
|
|
||||||
)
|
|
||||||
|
|
||||||
aiCharacterTranslationRepository.save(entity)
|
|
||||||
|
|
||||||
translated = TranslatedAiCharacterDetail(
|
|
||||||
name = translatedName,
|
|
||||||
description = translatedDescription,
|
|
||||||
gender = translatedGender,
|
|
||||||
personality = translatedPersonality,
|
|
||||||
background = translatedBackground,
|
|
||||||
tags = translatedTags
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
|
// 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
|
||||||
val others = service.getOtherCharactersBySharedTags(characterId, 10)
|
val others = service.getOtherCharactersBySharedTags(characterId, 10)
|
||||||
.map { other ->
|
.map { other ->
|
||||||
@@ -308,35 +165,6 @@ class ChatCharacterController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 다른 캐릭터 이름, 태그 번역 데이터 조회
|
|
||||||
*
|
|
||||||
* languageCode != null
|
|
||||||
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
|
|
||||||
*
|
|
||||||
* 한 번에 조회하고 characterId 매핑하여 others 캐릭터 이름과 tags 번역 데이터로 변경한다
|
|
||||||
*/
|
|
||||||
val characterIds = others.map { it.characterId }
|
|
||||||
val translatedOthers = if (characterIds.isNotEmpty()) {
|
|
||||||
val translations = aiCharacterTranslationRepository
|
|
||||||
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
|
||||||
.associateBy { it.characterId }
|
|
||||||
|
|
||||||
others.map { other ->
|
|
||||||
val payload = translations[other.characterId]?.renderedPayload
|
|
||||||
val translatedName = payload?.name
|
|
||||||
val translatedTags = payload?.tags
|
|
||||||
|
|
||||||
if (translatedName.isNullOrBlank() || translatedTags.isNullOrBlank()) {
|
|
||||||
other
|
|
||||||
} else {
|
|
||||||
other.copy(name = translatedName, tags = translatedTags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
others
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최신 댓글 1개 조회
|
// 최신 댓글 1개 조회
|
||||||
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
|
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
|
||||||
|
|
||||||
@@ -346,7 +174,6 @@ class ChatCharacterController(
|
|||||||
characterId = character.id!!,
|
characterId = character.id!!,
|
||||||
name = character.name,
|
name = character.name,
|
||||||
description = character.description,
|
description = character.description,
|
||||||
languageCode = character.languageCode,
|
|
||||||
mbti = character.mbti,
|
mbti = character.mbti,
|
||||||
gender = character.gender,
|
gender = character.gender,
|
||||||
age = character.age,
|
age = character.age,
|
||||||
@@ -357,94 +184,10 @@ class ChatCharacterController(
|
|||||||
originalTitle = character.originalTitle,
|
originalTitle = character.originalTitle,
|
||||||
originalLink = character.originalLink,
|
originalLink = character.originalLink,
|
||||||
characterType = character.characterType,
|
characterType = character.characterType,
|
||||||
others = translatedOthers,
|
others = others,
|
||||||
latestComment = latestComment,
|
latestComment = latestComment,
|
||||||
totalComments = characterCommentService.getTotalCommentCount(character.id!!),
|
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
|
||||||
translated = translated
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 등록된 캐릭터 전체보기
|
|
||||||
* - 기준: 2주 이내 등록된 캐릭터만 페이징 조회
|
|
||||||
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
|
|
||||||
*/
|
|
||||||
@GetMapping("/recent")
|
|
||||||
fun getRecentCharacters(
|
|
||||||
@RequestParam("page", required = false) page: Int?
|
|
||||||
): ApiResponse<RecentCharactersResponse> = run {
|
|
||||||
val characterPage = service.getRecentCharactersPage(
|
|
||||||
page = page ?: 0,
|
|
||||||
size = 20
|
|
||||||
)
|
|
||||||
|
|
||||||
val translatedCharacterPage = RecentCharactersResponse(
|
|
||||||
totalCount = characterPage.totalCount,
|
|
||||||
content = getTranslatedAiCharacterList(characterPage.content)
|
|
||||||
)
|
|
||||||
|
|
||||||
ApiResponse.ok(translatedCharacterPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 추천 캐릭터 새로고침 API
|
|
||||||
* - 최근 대화한 캐릭터를 제외하고 랜덤 20개 반환
|
|
||||||
* - 비회원 또는 본인인증되지 않은 경우: 최근 대화 목록 없음 → 전체 활성 캐릭터 중 랜덤 20개
|
|
||||||
*/
|
|
||||||
@GetMapping("/recommend")
|
|
||||||
fun getRecommendCharacters(
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
|
||||||
) = run {
|
|
||||||
val recent = if (member == null || member.auth == null) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
chatRoomService
|
|
||||||
.listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려
|
|
||||||
.map { it.characterId }
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(
|
|
||||||
getTranslatedAiCharacterList(
|
|
||||||
service.getRecommendCharacters(
|
|
||||||
recent,
|
|
||||||
20
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
|
||||||
*
|
|
||||||
* 처리 절차:
|
|
||||||
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
|
|
||||||
* 번역 데이터를 한 번에 조회한다.
|
|
||||||
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
|
|
||||||
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
|
|
||||||
*
|
|
||||||
* @param aiCharacterList 번역 대상 캐릭터 목록
|
|
||||||
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
|
|
||||||
*/
|
|
||||||
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
|
|
||||||
val characterIds = aiCharacterList.map { it.characterId }
|
|
||||||
|
|
||||||
return if (characterIds.isNotEmpty()) {
|
|
||||||
val translations = aiCharacterTranslationRepository
|
|
||||||
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
|
||||||
.associateBy { it.characterId }
|
|
||||||
|
|
||||||
aiCharacterList.map { character ->
|
|
||||||
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
|
||||||
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
|
|
||||||
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
|
|
||||||
character
|
|
||||||
} else {
|
|
||||||
character.copy(name = translatedName, description = translatedDesc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
aiCharacterList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ package kr.co.vividnext.sodalive.chat.character.dto
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
|
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
|
||||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
|
|
||||||
|
|
||||||
data class CharacterDetailResponse(
|
data class CharacterDetailResponse(
|
||||||
val characterId: Long,
|
val characterId: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
val languageCode: String?,
|
|
||||||
val mbti: String?,
|
val mbti: String?,
|
||||||
val gender: String?,
|
val gender: String?,
|
||||||
val age: Int?,
|
val age: Int?,
|
||||||
@@ -21,8 +19,7 @@ data class CharacterDetailResponse(
|
|||||||
val characterType: CharacterType,
|
val characterType: CharacterType,
|
||||||
val others: List<OtherCharacter>,
|
val others: List<OtherCharacter>,
|
||||||
val latestComment: CharacterCommentResponse?,
|
val latestComment: CharacterCommentResponse?,
|
||||||
val totalComments: Int,
|
val totalComments: Int
|
||||||
val translated: TranslatedAiCharacterDetail?
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OtherCharacter(
|
data class OtherCharacter(
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.dto
|
package kr.co.vividnext.sodalive.chat.character.dto
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
|
|
||||||
data class CharacterMainResponse(
|
data class CharacterMainResponse(
|
||||||
val banners: List<CharacterBannerResponse>,
|
val banners: List<CharacterBannerResponse>,
|
||||||
val recentCharacters: List<RecentCharacter>,
|
val recentCharacters: List<RecentCharacter>,
|
||||||
val popularCharacters: List<Character>,
|
val popularCharacters: List<Character>,
|
||||||
val newCharacters: List<Character>,
|
val newCharacters: List<Character>,
|
||||||
val recommendCharacters: List<Character>,
|
|
||||||
val curationSections: List<CurationSection>
|
val curationSections: List<CurationSection>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,11 +15,10 @@ data class CurationSection(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class Character(
|
data class Character(
|
||||||
@JsonProperty("characterId") val characterId: Long,
|
val characterId: Long,
|
||||||
@JsonProperty("name") val name: String,
|
val name: String,
|
||||||
@JsonProperty("description") val description: String,
|
val description: String,
|
||||||
@JsonProperty("imageUrl") val imageUrl: String,
|
val imageUrl: String
|
||||||
@JsonProperty("isNew") val new: Boolean
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RecentCharacter(
|
data class RecentCharacter(
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.dto
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 등록된 캐릭터 전체보기 페이지 응답 DTO
|
|
||||||
*/
|
|
||||||
data class RecentCharactersResponse(
|
|
||||||
val totalCount: Long,
|
|
||||||
val content: List<Character>
|
|
||||||
)
|
|
||||||
@@ -8,9 +8,7 @@ import org.springframework.data.domain.Page
|
|||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
import org.springframework.data.repository.query.Param
|
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository {
|
interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository {
|
||||||
@@ -28,21 +26,6 @@ interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, Charac
|
|||||||
"WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true"
|
"WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true"
|
||||||
)
|
)
|
||||||
fun findMaxSortOrderByCharacterId(characterId: Long): Int
|
fun findMaxSortOrderByCharacterId(characterId: Long): Int
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
select distinct c.id
|
|
||||||
from CharacterImage ci
|
|
||||||
join ci.chatCharacter c
|
|
||||||
where ci.isActive = true
|
|
||||||
and ci.createdAt >= :since
|
|
||||||
and c.id in :characterIds
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun findCharacterIdsWithRecentImages(
|
|
||||||
@Param("characterIds") characterIds: List<Long>,
|
|
||||||
@Param("since") since: LocalDateTime
|
|
||||||
): List<Long>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CharacterImageQueryRepository {
|
interface CharacterImageQueryRepository {
|
||||||
|
|||||||
@@ -10,29 +10,17 @@ import org.springframework.stereotype.Repository
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||||
|
fun findByCharacterUUID(characterUUID: String): ChatCharacter?
|
||||||
fun findByName(name: String): ChatCharacter?
|
fun findByName(name: String): ChatCharacter?
|
||||||
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
||||||
fun findByOriginalWorkIdAndIsActiveTrue(originalWorkId: Long, pageable: Pageable): Page<ChatCharacter>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회
|
* 활성화된 캐릭터를 생성일 기준 내림차순으로 조회
|
||||||
*/
|
*/
|
||||||
@Query(
|
fun findByIsActiveTrueOrderByCreatedAtDesc(pageable: Pageable): List<ChatCharacter>
|
||||||
"""
|
|
||||||
SELECT c FROM ChatCharacter c
|
|
||||||
WHERE c.isActive = true AND c.createdAt >= :since
|
|
||||||
ORDER BY c.createdAt DESC
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun findRecentSince(@Param("since") since: java.time.LocalDateTime, pageable: Pageable): Page<ChatCharacter>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2주 이내(파라미터 since 이상) 활성 캐릭터 개수
|
* 이름, 설명, MBTI, 태그로 캐릭터 검색
|
||||||
*/
|
|
||||||
fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이름, 설명, MBTI, 태그로 캐릭터 검색 - 페이징
|
|
||||||
*/
|
*/
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
@@ -74,29 +62,5 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
|||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
): List<ChatCharacter>
|
): List<ChatCharacter>
|
||||||
|
|
||||||
/**
|
|
||||||
* 활성 캐릭터 무작위 조회
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT c FROM ChatCharacter c
|
|
||||||
WHERE c.isActive = true
|
|
||||||
ORDER BY function('RAND')
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun findRandomActive(pageable: Pageable): List<ChatCharacter>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 제외할 캐릭터를 뺀 활성 캐릭터 무작위 조회
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT c FROM ChatCharacter c
|
|
||||||
WHERE c.isActive = true AND c.id NOT IN :excludeIds
|
|
||||||
ORDER BY function('RAND')
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter>
|
|
||||||
|
|
||||||
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
|
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,14 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal
|
|||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.cache.annotation.Cacheable
|
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.data.domain.Sort
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ChatCharacterService(
|
class ChatCharacterService(
|
||||||
@@ -33,170 +26,24 @@ class ChatCharacterService(
|
|||||||
private val tagRepository: ChatCharacterTagRepository,
|
private val tagRepository: ChatCharacterTagRepository,
|
||||||
private val valueRepository: ChatCharacterValueRepository,
|
private val valueRepository: ChatCharacterValueRepository,
|
||||||
private val hobbyRepository: ChatCharacterHobbyRepository,
|
private val hobbyRepository: ChatCharacterHobbyRepository,
|
||||||
private val goalRepository: ChatCharacterGoalRepository,
|
private val goalRepository: ChatCharacterGoalRepository
|
||||||
private val popularCharacterQuery: PopularCharacterQuery,
|
|
||||||
private val imageRepository: CharacterImageRepository,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
|
||||||
private val imageHost: String
|
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회
|
||||||
|
* 현재는 채팅방 구현 전이므로 빈 리스트 반환
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getRecommendCharacters(excludeCharacterIds: List<Long> = emptyList(), limit: Int = 20): List<Character> {
|
fun getPopularCharacters(): List<ChatCharacter> {
|
||||||
val safeLimit = if (limit <= 0) 20 else if (limit > 50) 50 else limit
|
// 채팅방 구현 전이므로 빈 리스트 반환
|
||||||
val chars = if (excludeCharacterIds.isNotEmpty()) {
|
return emptyList()
|
||||||
chatCharacterRepository.findRandomActiveExcluding(excludeCharacterIds, PageRequest.of(0, safeLimit))
|
|
||||||
} else {
|
|
||||||
chatCharacterRepository.findRandomActive(PageRequest.of(0, safeLimit))
|
|
||||||
}
|
|
||||||
|
|
||||||
val recentSet = if (chars.isNotEmpty()) {
|
|
||||||
imageRepository
|
|
||||||
.findCharacterIdsWithRecentImages(
|
|
||||||
chars.map { it.id!! },
|
|
||||||
LocalDateTime.now().minusDays(3)
|
|
||||||
)
|
|
||||||
.toSet()
|
|
||||||
} else {
|
|
||||||
emptySet()
|
|
||||||
}
|
|
||||||
|
|
||||||
return chars.map {
|
|
||||||
Character(
|
|
||||||
characterId = it.id!!,
|
|
||||||
name = it.name,
|
|
||||||
description = it.description,
|
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
|
||||||
new = recentSet.contains(it.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회
|
* 최근 등록된 캐릭터 목록 조회 (최대 10개)
|
||||||
* Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용
|
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@Cacheable(
|
fun getNewCharacters(limit: Int = 10): List<ChatCharacter> {
|
||||||
cacheNames = ["popularCharacters_24h"],
|
return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit))
|
||||||
key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-character').cacheKey"
|
|
||||||
)
|
|
||||||
fun getPopularCharacters(limit: Long = 20): List<Character> {
|
|
||||||
val window = RankingWindowCalculator.now("popular-character")
|
|
||||||
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
|
|
||||||
val list = loadCharactersInOrder(topIds)
|
|
||||||
|
|
||||||
val recentSet = if (list.isNotEmpty()) {
|
|
||||||
imageRepository
|
|
||||||
.findCharacterIdsWithRecentImages(
|
|
||||||
list.map { it.id!! },
|
|
||||||
LocalDateTime.now().minusDays(3)
|
|
||||||
)
|
|
||||||
.toSet()
|
|
||||||
} else {
|
|
||||||
emptySet()
|
|
||||||
}
|
|
||||||
|
|
||||||
return list.map {
|
|
||||||
Character(
|
|
||||||
characterId = it.id!!,
|
|
||||||
name = it.name,
|
|
||||||
description = it.description,
|
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
|
||||||
new = recentSet.contains(it.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadCharactersInOrder(ids: List<Long>): List<ChatCharacter> {
|
|
||||||
if (ids.isEmpty()) return emptyList()
|
|
||||||
val list = chatCharacterRepository.findAllById(ids)
|
|
||||||
val map = list.associateBy { it.id }
|
|
||||||
return ids.mapNotNull { map[it] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 등록된 캐릭터 전체보기 (페이징) - 전체 개수 포함
|
|
||||||
* - 기준: 현재 시각 기준 2주 이내 생성된 활성 캐릭터
|
|
||||||
* - 2주 이내 캐릭터가 0개라면: totalCount=20, 첫 페이지는 최근 등록 활성 캐릭터 20개, 그 외 페이지는 빈 리스트
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getRecentCharactersPage(page: Int = 0, size: Int = 20): RecentCharactersResponse {
|
|
||||||
val safePage = if (page < 0) 0 else page
|
|
||||||
val safeSize = when {
|
|
||||||
size <= 0 -> 20
|
|
||||||
size > 50 -> 50 // 과도한 page size 방지
|
|
||||||
else -> size
|
|
||||||
}
|
|
||||||
val since = LocalDateTime.now().minusWeeks(2)
|
|
||||||
|
|
||||||
val totalRecent = chatCharacterRepository.countByIsActiveTrueAndCreatedAtGreaterThanEqual(since)
|
|
||||||
if (totalRecent == 0L) {
|
|
||||||
if (safePage > 0) {
|
|
||||||
return RecentCharactersResponse(
|
|
||||||
totalCount = 20,
|
|
||||||
content = emptyList()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val chars = chatCharacterRepository.findByIsActiveTrue(
|
|
||||||
PageRequest.of(0, 20, Sort.by("createdAt").descending())
|
|
||||||
).content
|
|
||||||
|
|
||||||
val recentSet = if (chars.isNotEmpty()) {
|
|
||||||
imageRepository
|
|
||||||
.findCharacterIdsWithRecentImages(
|
|
||||||
chars.map { it.id!! },
|
|
||||||
LocalDateTime.now().minusDays(3)
|
|
||||||
)
|
|
||||||
.toSet()
|
|
||||||
} else {
|
|
||||||
emptySet()
|
|
||||||
}
|
|
||||||
|
|
||||||
val content = chars.map {
|
|
||||||
Character(
|
|
||||||
characterId = it.id!!,
|
|
||||||
name = it.name,
|
|
||||||
description = it.description,
|
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
|
||||||
new = recentSet.contains(it.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return RecentCharactersResponse(
|
|
||||||
totalCount = 20,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val chars = chatCharacterRepository.findRecentSince(
|
|
||||||
since,
|
|
||||||
PageRequest.of(safePage, safeSize)
|
|
||||||
).content
|
|
||||||
|
|
||||||
val recentSet = if (chars.isNotEmpty()) {
|
|
||||||
imageRepository
|
|
||||||
.findCharacterIdsWithRecentImages(
|
|
||||||
chars.map { it.id!! },
|
|
||||||
LocalDateTime.now().minusDays(3)
|
|
||||||
)
|
|
||||||
.toSet()
|
|
||||||
} else {
|
|
||||||
emptySet()
|
|
||||||
}
|
|
||||||
|
|
||||||
val content = chars.map {
|
|
||||||
Character(
|
|
||||||
characterId = it.id!!,
|
|
||||||
name = it.name,
|
|
||||||
description = it.description,
|
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
|
||||||
new = recentSet.contains(it.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return RecentCharactersResponse(
|
|
||||||
totalCount = totalRecent,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.service
|
|
||||||
|
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
|
|
||||||
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
|
||||||
import kr.co.vividnext.sodalive.chat.room.QChatMessage
|
|
||||||
import kr.co.vividnext.sodalive.chat.room.QChatParticipant
|
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
class PopularCharacterQuery(
|
|
||||||
private val queryFactory: JPAQueryFactory
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* 집계 기준: "채팅방 전체 메시지 수"로 캐릭터 인기 집계
|
|
||||||
* - 메시지 작성자(pMsg)가 누가 되었든 해당 방의 소유 캐릭터(p=CHARACTER)의 id로 그룹핑
|
|
||||||
* - 시간 종료 경계는 배타적(<) 비교로 단순화
|
|
||||||
*/
|
|
||||||
fun findPopularCharacterIds(
|
|
||||||
windowStart: Instant,
|
|
||||||
endExclusive: Instant,
|
|
||||||
limit: Long
|
|
||||||
): List<Long> {
|
|
||||||
val m = QChatMessage.chatMessage
|
|
||||||
val p = QChatParticipant.chatParticipant
|
|
||||||
val c = QChatCharacter.chatCharacter
|
|
||||||
|
|
||||||
val start = LocalDateTime.ofInstant(windowStart, ZoneOffset.UTC)
|
|
||||||
val end = LocalDateTime.ofInstant(endExclusive, ZoneOffset.UTC)
|
|
||||||
|
|
||||||
return queryFactory
|
|
||||||
.select(c.id)
|
|
||||||
.from(m)
|
|
||||||
// 방의 캐릭터 소유자 참가자(p=CHARACTER)를 통해 캐릭터 기준으로 그룹핑
|
|
||||||
.join(p).on(
|
|
||||||
p.chatRoom.id.eq(m.chatRoom.id)
|
|
||||||
.and(p.participantType.eq(ParticipantType.CHARACTER))
|
|
||||||
)
|
|
||||||
.join(c).on(c.id.eq(p.character.id))
|
|
||||||
.where(
|
|
||||||
m.createdAt.goe(start)
|
|
||||||
.and(m.createdAt.lt(end)) // 배타적 종료
|
|
||||||
.and(m.isActive.isTrue)
|
|
||||||
.and(c.isActive.isTrue)
|
|
||||||
)
|
|
||||||
.groupBy(c.id)
|
|
||||||
.orderBy(m.id.count().desc())
|
|
||||||
.limit(limit)
|
|
||||||
.fetch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.service
|
|
||||||
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import java.time.ZonedDateTime
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UTC 20:00:00을 경계로 집계 윈도우와 캐시 키를 계산한다.
|
|
||||||
*/
|
|
||||||
data class RankingWindow(
|
|
||||||
val windowStart: Instant,
|
|
||||||
val windowEnd: Instant,
|
|
||||||
val nextBoundary: Instant,
|
|
||||||
val cacheKey: String
|
|
||||||
)
|
|
||||||
|
|
||||||
object RankingWindowCalculator {
|
|
||||||
private val ZONE: ZoneId = ZoneOffset.UTC
|
|
||||||
private const val BOUNDARY_HOUR = 20 // 20:00:00 UTC
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun now(prefix: String = "popular-character"): RankingWindow {
|
|
||||||
val now = ZonedDateTime.now(ZONE)
|
|
||||||
val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE)
|
|
||||||
|
|
||||||
// 일일 순위는 "전날" 완료 구간을 보여주기 위해, 언제든 직전 경계까지만 집계한다.
|
|
||||||
// 예) 2025-09-14 20:00:00 직후에도 [2025-09-13 20:00, 2025-09-14 20:00) 윈도우를 사용
|
|
||||||
val lastBoundary = if (now.isBefore(todayBoundary)) {
|
|
||||||
// 아직 오늘 20:00 이전이면, 직전 경계는 어제 20:00
|
|
||||||
todayBoundary.minusDays(1)
|
|
||||||
} else {
|
|
||||||
// 오늘 20:00을 지났거나 같으면, 직전 경계는 오늘 20:00
|
|
||||||
todayBoundary
|
|
||||||
}
|
|
||||||
|
|
||||||
val start = lastBoundary.minusDays(1)
|
|
||||||
val endExclusive = lastBoundary
|
|
||||||
|
|
||||||
val windowStart = start.toInstant()
|
|
||||||
val windowEnd = endExclusive.minusSeconds(1).toInstant() // [start, end]
|
|
||||||
val cacheKey = "$prefix:${windowStart.epochSecond}"
|
|
||||||
// nextBoundary 필드는 기존 시그니처 유지를 위해 endExclusive(=lastBoundary)를 그대로 전달한다.
|
|
||||||
return RankingWindow(windowStart, windowEnd, endExclusive.toInstant(), cacheKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.translate
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.AttributeConverter
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Convert
|
|
||||||
import javax.persistence.Converter
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.Table
|
|
||||||
import javax.persistence.UniqueConstraint
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(
|
|
||||||
uniqueConstraints = [
|
|
||||||
UniqueConstraint(columnNames = ["characterId", "locale"])
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class AiCharacterTranslation(
|
|
||||||
val characterId: Long,
|
|
||||||
val locale: String,
|
|
||||||
|
|
||||||
@Column(columnDefinition = "json")
|
|
||||||
@Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class)
|
|
||||||
var renderedPayload: AiCharacterTranslationRenderedPayload
|
|
||||||
) : BaseEntity()
|
|
||||||
|
|
||||||
data class AiCharacterTranslationRenderedPayload(
|
|
||||||
val name: String,
|
|
||||||
val description: String,
|
|
||||||
val gender: String,
|
|
||||||
val personalityTrait: String,
|
|
||||||
val personalityDescription: String,
|
|
||||||
val backgroundTopic: String,
|
|
||||||
val backgroundDescription: String,
|
|
||||||
val tags: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Converter(autoApply = false)
|
|
||||||
class AiCharacterTranslationRenderedPayloadConverter :
|
|
||||||
AttributeConverter<AiCharacterTranslationRenderedPayload, String> {
|
|
||||||
|
|
||||||
override fun convertToDatabaseColumn(attribute: AiCharacterTranslationRenderedPayload?): String {
|
|
||||||
if (attribute == null) return "{}"
|
|
||||||
return objectMapper.writeValueAsString(attribute)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun convertToEntityAttribute(dbData: String?): AiCharacterTranslationRenderedPayload {
|
|
||||||
if (dbData.isNullOrBlank()) {
|
|
||||||
return AiCharacterTranslationRenderedPayload(
|
|
||||||
name = "",
|
|
||||||
description = "",
|
|
||||||
gender = "",
|
|
||||||
personalityTrait = "",
|
|
||||||
personalityDescription = "",
|
|
||||||
backgroundTopic = "",
|
|
||||||
backgroundDescription = "",
|
|
||||||
tags = ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return objectMapper.readValue(dbData)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val objectMapper = jacksonObjectMapper()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class TranslatedAiCharacterDetail(
|
|
||||||
val name: String?,
|
|
||||||
val description: String?,
|
|
||||||
val gender: String?,
|
|
||||||
val personality: TranslatedAiCharacterPersonality?,
|
|
||||||
val background: TranslatedAiCharacterBackground?,
|
|
||||||
val tags: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TranslatedAiCharacterPersonality(
|
|
||||||
val trait: String?,
|
|
||||||
val description: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TranslatedAiCharacterBackground(
|
|
||||||
val topic: String?,
|
|
||||||
val description: String?
|
|
||||||
)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.translate
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
|
||||||
|
|
||||||
interface AiCharacterTranslationRepository : JpaRepository<AiCharacterTranslation, Long> {
|
|
||||||
fun findByCharacterIdAndLocale(characterId: Long, locale: String): AiCharacterTranslation?
|
|
||||||
|
|
||||||
fun findByCharacterIdInAndLocale(characterIds: List<Long>, locale: String): List<AiCharacterTranslation>
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.CascadeType
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.OneToMany
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작(오리지널 작품) 엔티티
|
|
||||||
* - 캐릭터를 원작별로 묶기 위한 기준 엔티티
|
|
||||||
* - 각 필드는 운영에서 관리자가 입력/수정한다.
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
class OriginalWork(
|
|
||||||
/** 원작 제목 */
|
|
||||||
@Column(nullable = false)
|
|
||||||
var title: String,
|
|
||||||
|
|
||||||
/** 콘텐츠 타입 (예: 웹소설, 웹툰 등) */
|
|
||||||
@Column(nullable = false)
|
|
||||||
var contentType: String,
|
|
||||||
|
|
||||||
/** 카테고리/장르 (예: 로맨스, 판타지 등) */
|
|
||||||
@Column(nullable = false)
|
|
||||||
var category: String,
|
|
||||||
|
|
||||||
/** 19금 여부 */
|
|
||||||
@Column(nullable = false)
|
|
||||||
var isAdult: Boolean = false,
|
|
||||||
|
|
||||||
/** 작품 소개 */
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
var description: String = "",
|
|
||||||
|
|
||||||
/** 언어 코드 */
|
|
||||||
@Column(nullable = true)
|
|
||||||
var languageCode: String? = null,
|
|
||||||
|
|
||||||
/** 원천 원작 */
|
|
||||||
@Column(nullable = true)
|
|
||||||
var originalWork: String? = null,
|
|
||||||
|
|
||||||
/** 원천 원작 링크(단일) */
|
|
||||||
@Column(nullable = true)
|
|
||||||
var originalLink: String? = null,
|
|
||||||
|
|
||||||
/** 작가 */
|
|
||||||
@Column(nullable = true)
|
|
||||||
var writer: String? = null,
|
|
||||||
|
|
||||||
/** 제작사 */
|
|
||||||
@Column(nullable = true)
|
|
||||||
var studio: String? = null
|
|
||||||
) : BaseEntity() {
|
|
||||||
/** 원작 대표 이미지 S3 경로 */
|
|
||||||
var imagePath: String? = null
|
|
||||||
|
|
||||||
/** 소프트 삭제 여부 (true면 삭제된 것으로 간주) */
|
|
||||||
var isDeleted: Boolean = false
|
|
||||||
|
|
||||||
/** 원작 링크들 (1:N) */
|
|
||||||
@OneToMany(mappedBy = "originalWork", cascade = [CascadeType.ALL], orphanRemoval = true)
|
|
||||||
var originalLinks: MutableList<OriginalWorkLink> = mutableListOf()
|
|
||||||
|
|
||||||
/** 원작 태그 매핑들 (1:N) */
|
|
||||||
@OneToMany(mappedBy = "originalWork", cascade = [CascadeType.ALL], orphanRemoval = true)
|
|
||||||
var tagMappings: MutableList<OriginalWorkTagMapping> = mutableListOf()
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.FetchType
|
|
||||||
import javax.persistence.JoinColumn
|
|
||||||
import javax.persistence.ManyToOne
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 원본 링크 엔티티
|
|
||||||
* - 하나의 원작(OriginalWork)에 여러 개의 링크가 연결될 수 있음 (1:N)
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
class OriginalWorkLink(
|
|
||||||
@Column(nullable = false)
|
|
||||||
var url: String,
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "original_work_id")
|
|
||||||
var originalWork: OriginalWork? = null
|
|
||||||
) : BaseEntity()
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original
|
|
||||||
|
|
||||||
import org.springframework.data.domain.Page
|
|
||||||
import org.springframework.data.domain.Pageable
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
|
||||||
import org.springframework.data.jpa.repository.Query
|
|
||||||
import org.springframework.data.repository.query.Param
|
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
import java.util.Optional
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
interface OriginalWorkRepository : JpaRepository<OriginalWork, Long> {
|
|
||||||
fun findByTitleAndIsDeletedFalse(title: String): OriginalWork?
|
|
||||||
fun findByIdAndIsDeletedFalse(id: Long): Optional<OriginalWork>
|
|
||||||
fun findByIsDeletedFalse(pageable: Pageable): Page<OriginalWork>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 제목/콘텐츠타입/카테고리 기준 부분 검색 (소프트 삭제 제외) - 무페이징 전체 목록
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT ow FROM OriginalWork ow
|
|
||||||
WHERE ow.isDeleted = false AND (
|
|
||||||
LOWER(ow.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
|
|
||||||
LOWER(ow.contentType) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
|
|
||||||
LOWER(ow.category) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
|
|
||||||
)
|
|
||||||
ORDER BY ow.createdAt DESC
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun searchNoPaging(
|
|
||||||
@Param("searchTerm") searchTerm: String
|
|
||||||
): List<OriginalWork>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앱용 원작 목록 조회 (페이징)
|
|
||||||
* - 소프트 삭제 제외
|
|
||||||
* - includeAdult=false이면 19금 제외
|
|
||||||
* - 활성 캐릭터가 하나라도 연결된 원작만 조회
|
|
||||||
*/
|
|
||||||
@Query(
|
|
||||||
value = """
|
|
||||||
SELECT ow FROM OriginalWork ow
|
|
||||||
WHERE ow.isDeleted = false
|
|
||||||
AND (:includeAdult = true OR ow.isAdult = false)
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM ChatCharacter c
|
|
||||||
WHERE c.originalWork = ow AND c.isActive = true
|
|
||||||
)
|
|
||||||
ORDER BY ow.createdAt DESC
|
|
||||||
""",
|
|
||||||
countQuery = """
|
|
||||||
SELECT COUNT(ow) FROM OriginalWork ow
|
|
||||||
WHERE ow.isDeleted = false
|
|
||||||
AND (:includeAdult = true OR ow.isAdult = false)
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM ChatCharacter c
|
|
||||||
WHERE c.originalWork = ow AND c.isActive = true
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun findAllForAppPage(@Param("includeAdult") includeAdult: Boolean, pageable: Pageable): Page<OriginalWork>
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.OneToMany
|
|
||||||
import javax.persistence.Table
|
|
||||||
import javax.persistence.UniqueConstraint
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 태그 엔티티 (작품/시리즈 태그와 분리)
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["tag"])])
|
|
||||||
class OriginalWorkTag(
|
|
||||||
@Column(nullable = false)
|
|
||||||
val tag: String
|
|
||||||
) : BaseEntity() {
|
|
||||||
@OneToMany(mappedBy = "tag")
|
|
||||||
var tagMappings: MutableList<OriginalWorkTagMapping> = mutableListOf()
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.FetchType
|
|
||||||
import javax.persistence.JoinColumn
|
|
||||||
import javax.persistence.ManyToOne
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OriginalWork 와 OriginalWorkTag 매핑 엔티티
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
class OriginalWorkTagMapping(
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "original_work_id")
|
|
||||||
val originalWork: OriginalWork,
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "tag_id")
|
|
||||||
val tag: OriginalWorkTag
|
|
||||||
) : BaseEntity()
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original.controller
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkTranslationService
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앱용 원작(오리지널 작품) 공개 API
|
|
||||||
* 1) 목록: 로그인 불필요, 미인증 사용자는 19금 제외, 활성 캐릭터 연결된 원작만 노출
|
|
||||||
* 2) 상세: 로그인 + 본인인증 필수
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/chat/original")
|
|
||||||
class OriginalWorkController(
|
|
||||||
private val queryService: OriginalWorkQueryService,
|
|
||||||
private val characterImageRepository: CharacterImageRepository,
|
|
||||||
|
|
||||||
private val langContext: LangContext,
|
|
||||||
|
|
||||||
private val originalWorkTranslationService: OriginalWorkTranslationService,
|
|
||||||
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
|
||||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
|
||||||
private val imageHost: String
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 목록 (페이징)
|
|
||||||
* - 로그인 불필요
|
|
||||||
* - 본인인증하지 않은 경우 19금 제외
|
|
||||||
* - 활성 캐릭터가 하나라도 연결된 원작만 노출
|
|
||||||
* - 요청: page(기본 0), size(기본 20)
|
|
||||||
* - 반환: totalCount + [imageUrl, title, contentType]
|
|
||||||
*/
|
|
||||||
@GetMapping("/list")
|
|
||||||
fun list(
|
|
||||||
@RequestParam(defaultValue = "0") page: Int,
|
|
||||||
@RequestParam(defaultValue = "20") size: Int,
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
|
||||||
) = run {
|
|
||||||
val includeAdult = member?.auth != null
|
|
||||||
val pageRes = queryService.listForAppPage(includeAdult, page, size)
|
|
||||||
val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 목록의 제목과 콘텐츠 타입을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
|
||||||
*
|
|
||||||
* 처리 절차:
|
|
||||||
* - 입력된 원작들의 originalWorkId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
|
||||||
* originalWorkTranslationRepository 번역 데이터를 한 번에 조회한다.
|
|
||||||
* - 각 항목에 대해 번역된 제목과 콘텐츠 타입이 존재하고 비어있지 않으면 title과 contentType을 번역 값으로 교체한다.
|
|
||||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
|
||||||
*
|
|
||||||
* 성능:
|
|
||||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
|
||||||
*/
|
|
||||||
val translatedContent = run {
|
|
||||||
if (content.isEmpty()) {
|
|
||||||
content
|
|
||||||
} else {
|
|
||||||
val ids = content.map { it.id }.toSet()
|
|
||||||
val locale = langContext.lang.code
|
|
||||||
val translations = originalWorkTranslationRepository
|
|
||||||
.findByOriginalWorkIdInAndLocale(ids, locale)
|
|
||||||
.associateBy { it.originalWorkId }
|
|
||||||
|
|
||||||
content.map { item ->
|
|
||||||
val payload = translations[item.id]?.renderedPayload
|
|
||||||
if (payload != null) {
|
|
||||||
val newTitle = payload.title.trim()
|
|
||||||
val newContentType = payload.contentType.trim()
|
|
||||||
val hasTitle = newTitle.isNotEmpty()
|
|
||||||
val hasContentType = newContentType.isNotEmpty()
|
|
||||||
if (hasTitle || hasContentType) {
|
|
||||||
item.copy(
|
|
||||||
title = if (hasTitle) newTitle else item.title,
|
|
||||||
contentType = if (hasContentType) newContentType else item.contentType
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
item
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(
|
|
||||||
OriginalWorkListResponse(
|
|
||||||
totalCount = pageRes.totalElements,
|
|
||||||
content = translatedContent
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 상세
|
|
||||||
* - 로그인 및 본인인증 필수
|
|
||||||
* - 반환: 이미지, 제목, 콘텐츠 타입, 카테고리, 19금 여부, 작품 소개, 원작 링크
|
|
||||||
* - 해당 원작의 활성 캐릭터 리스트 [imageUrl, name, description]
|
|
||||||
* - 캐릭터는 페이징 적용: 첫 페이지 20개
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
fun detail(
|
|
||||||
@PathVariable id: Long,
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
|
||||||
) = run {
|
|
||||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
|
||||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
|
||||||
|
|
||||||
val ow = queryService.getOriginalWork(id)
|
|
||||||
val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content
|
|
||||||
|
|
||||||
val recentSet = if (chars.isNotEmpty()) {
|
|
||||||
characterImageRepository
|
|
||||||
.findCharacterIdsWithRecentImages(
|
|
||||||
chars.map { it.id!! },
|
|
||||||
LocalDateTime.now().minusDays(3)
|
|
||||||
)
|
|
||||||
.toSet()
|
|
||||||
} else {
|
|
||||||
emptySet()
|
|
||||||
}
|
|
||||||
|
|
||||||
val translatedOriginal = originalWorkTranslationService.ensureTranslated(
|
|
||||||
originalWork = ow,
|
|
||||||
targetLocale = langContext.lang.code
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐릭터 리스트의 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)를 현재 언어(locale)에 맞춰 일괄 번역한다.
|
|
||||||
*
|
|
||||||
* 처리 절차:
|
|
||||||
* - 입력된 콘텐츠들의 characterId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
|
||||||
* AiCharacterTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
|
||||||
* - 각 항목에 대해 번역된 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)가 존재하고 비어있지 않으면 번역 값으로 교체한다.
|
|
||||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
|
||||||
*
|
|
||||||
* 성능:
|
|
||||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
|
||||||
*/
|
|
||||||
val translatedCharacters = run {
|
|
||||||
if (chars.isEmpty()) {
|
|
||||||
emptyList<Character>()
|
|
||||||
} else {
|
|
||||||
val ids = chars.mapNotNull { it.id }
|
|
||||||
val translations = aiCharacterTranslationRepository
|
|
||||||
.findByCharacterIdInAndLocale(ids, langContext.lang.code)
|
|
||||||
.associateBy { it.characterId }
|
|
||||||
|
|
||||||
chars.map<ChatCharacter, Character> {
|
|
||||||
val path = it.imagePath ?: "profile/default-profile.png"
|
|
||||||
val tr = translations[it.id!!]?.renderedPayload
|
|
||||||
val newName = tr?.name?.trim().orEmpty()
|
|
||||||
val newDesc = tr?.description?.trim().orEmpty()
|
|
||||||
val hasName = newName.isNotEmpty()
|
|
||||||
val hasDesc = newDesc.isNotEmpty()
|
|
||||||
Character(
|
|
||||||
characterId = it.id!!,
|
|
||||||
name = if (hasName) newName else it.name,
|
|
||||||
description = if (hasDesc) newDesc else it.description,
|
|
||||||
imageUrl = "$imageHost/$path",
|
|
||||||
new = recentSet.contains(it.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ApiResponse.ok(
|
|
||||||
OriginalWorkDetailResponse.from(
|
|
||||||
ow,
|
|
||||||
imageHost,
|
|
||||||
translatedCharacters,
|
|
||||||
translated = translatedOriginal
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original.dto
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앱용 원작 목록 아이템 응답 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkListItemResponse(
|
|
||||||
@JsonProperty("id") val id: Long,
|
|
||||||
@JsonProperty("imageUrl") val imageUrl: String?,
|
|
||||||
@JsonProperty("title") val title: String,
|
|
||||||
@JsonProperty("contentType") val contentType: String
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkListItemResponse {
|
|
||||||
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
|
||||||
"$imageHost/${entity.imagePath}"
|
|
||||||
} else {
|
|
||||||
entity.imagePath
|
|
||||||
}
|
|
||||||
return OriginalWorkListItemResponse(
|
|
||||||
id = entity.id!!,
|
|
||||||
imageUrl = fullImage,
|
|
||||||
title = entity.title,
|
|
||||||
contentType = entity.contentType
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앱용 원작 목록 응답 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkListResponse(
|
|
||||||
@JsonProperty("totalCount") val totalCount: Long,
|
|
||||||
@JsonProperty("content") val content: List<OriginalWorkListItemResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앱용 원작 상세 응답 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkDetailResponse(
|
|
||||||
@JsonProperty("imageUrl") val imageUrl: String?,
|
|
||||||
@JsonProperty("title") val title: String,
|
|
||||||
@JsonProperty("contentType") val contentType: String,
|
|
||||||
@JsonProperty("category") val category: String,
|
|
||||||
@JsonProperty("isAdult") val isAdult: Boolean,
|
|
||||||
@JsonProperty("description") val description: String,
|
|
||||||
@JsonProperty("originalWork") val originalWork: String?,
|
|
||||||
@JsonProperty("originalLink") val originalLink: String?,
|
|
||||||
@JsonProperty("writer") val writer: String?,
|
|
||||||
@JsonProperty("studio") val studio: String?,
|
|
||||||
@JsonProperty("originalLinks") val originalLinks: List<String>,
|
|
||||||
@JsonProperty("tags") val tags: List<String>,
|
|
||||||
@JsonProperty("characters") val characters: List<Character>,
|
|
||||||
@JsonProperty("translated") val translated: TranslatedOriginalWork?
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(
|
|
||||||
entity: OriginalWork,
|
|
||||||
imageHost: String = "",
|
|
||||||
characters: List<Character>,
|
|
||||||
translated: TranslatedOriginalWork?
|
|
||||||
): OriginalWorkDetailResponse {
|
|
||||||
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
|
||||||
"$imageHost/${entity.imagePath}"
|
|
||||||
} else {
|
|
||||||
entity.imagePath
|
|
||||||
}
|
|
||||||
return OriginalWorkDetailResponse(
|
|
||||||
imageUrl = fullImage,
|
|
||||||
title = entity.title,
|
|
||||||
contentType = entity.contentType,
|
|
||||||
category = entity.category,
|
|
||||||
isAdult = entity.isAdult,
|
|
||||||
description = entity.description,
|
|
||||||
originalWork = entity.originalWork,
|
|
||||||
originalLink = entity.originalLink,
|
|
||||||
writer = entity.writer,
|
|
||||||
studio = entity.studio,
|
|
||||||
originalLinks = entity.originalLinks.map { it.url },
|
|
||||||
tags = entity.tagMappings.map { it.tag.tag },
|
|
||||||
characters = characters,
|
|
||||||
translated = translated
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앱용: 원작별 활성 캐릭터 페이징 응답 DTO
|
|
||||||
*/
|
|
||||||
data class OriginalWorkCharactersPageResponse(
|
|
||||||
@JsonProperty("totalCount") val totalCount: Long,
|
|
||||||
@JsonProperty("content") val content: List<Character>
|
|
||||||
)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original.repository
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
interface OriginalWorkTagRepository : JpaRepository<OriginalWorkTag, Long> {
|
|
||||||
fun findByTag(tag: String): OriginalWorkTag?
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original.service
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import org.springframework.data.domain.Page
|
|
||||||
import org.springframework.data.domain.PageRequest
|
|
||||||
import org.springframework.data.domain.Sort
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 앱 사용자용 원작(오리지널 작품) 조회 서비스
|
|
||||||
* - 목록/상세 조회 전용
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
class OriginalWorkQueryService(
|
|
||||||
private val originalWorkRepository: OriginalWorkRepository,
|
|
||||||
private val chatCharacterRepository: ChatCharacterRepository
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* 앱용 원작 목록 조회 (페이징)
|
|
||||||
* @param includeAdult true면 19금 포함, false면 제외
|
|
||||||
* @param page 페이지 번호(0부터)
|
|
||||||
* @param size 페이지 크기(기본 20, 최대 50)
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun listForAppPage(includeAdult: Boolean, page: Int = 0, size: Int = 20): Page<OriginalWork> {
|
|
||||||
val safePage = if (page < 0) 0 else page
|
|
||||||
val safeSize = when {
|
|
||||||
size <= 0 -> 20
|
|
||||||
size > 50 -> 50
|
|
||||||
else -> size
|
|
||||||
}
|
|
||||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
|
||||||
return originalWorkRepository.findAllForAppPage(includeAdult, pageable)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작 상세 조회 (소프트 삭제 제외)
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getOriginalWork(id: Long): OriginalWork {
|
|
||||||
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순)
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
fun getActiveCharactersPage(originalWorkId: Long, page: Int = 0, size: Int = 20): Page<ChatCharacter> {
|
|
||||||
// 원작 존재 및 소프트 삭제 여부 확인
|
|
||||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
|
||||||
|
|
||||||
val safePage = if (page < 0) 0 else page
|
|
||||||
val safeSize = when {
|
|
||||||
size <= 0 -> 20
|
|
||||||
size > 20 -> 20
|
|
||||||
else -> size
|
|
||||||
}
|
|
||||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
|
||||||
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original.service
|
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
|
||||||
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import org.springframework.stereotype.Service
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
@Service
|
|
||||||
class OriginalWorkTranslationService(
|
|
||||||
private val translationRepository: OriginalWorkTranslationRepository,
|
|
||||||
private val papagoTranslationService: PapagoTranslationService
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val log = LoggerFactory.getLogger(javaClass)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다.
|
|
||||||
* - 기존 번역이 있으면 그대로 사용
|
|
||||||
* - 없으면 파파고 번역 수행 후 저장
|
|
||||||
* - 실패/불필요 시 null 반환
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
fun ensureTranslated(originalWork: OriginalWork, targetLocale: String): TranslatedOriginalWork? {
|
|
||||||
val source = originalWork.languageCode?.lowercase()
|
|
||||||
val target = targetLocale.lowercase()
|
|
||||||
|
|
||||||
if (source.isNullOrBlank() || source == target) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기존 번역 조회
|
|
||||||
val existed = translationRepository.findByOriginalWorkIdAndLocale(originalWork.id!!, target)
|
|
||||||
val existedPayload = existed?.renderedPayload
|
|
||||||
if (existedPayload != null) {
|
|
||||||
val t = existedPayload.title.trim()
|
|
||||||
val ct = existedPayload.contentType.trim()
|
|
||||||
val cat = existedPayload.category.trim()
|
|
||||||
val desc = existedPayload.description.trim()
|
|
||||||
val tags = existedPayload.tags
|
|
||||||
val hasAny = t.isNotEmpty() || ct.isNotEmpty() || cat.isNotEmpty() || desc.isNotEmpty() || tags.isNotEmpty()
|
|
||||||
if (hasAny) {
|
|
||||||
return TranslatedOriginalWork(
|
|
||||||
title = t,
|
|
||||||
contentType = ct,
|
|
||||||
category = cat,
|
|
||||||
description = desc,
|
|
||||||
tags = tags
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 파파고 번역 수행
|
|
||||||
return try {
|
|
||||||
val tags = originalWork.tagMappings.map { it.tag.tag }.filter { it.isNotBlank() }
|
|
||||||
val texts = buildList {
|
|
||||||
add(originalWork.title)
|
|
||||||
add(originalWork.contentType)
|
|
||||||
add(originalWork.category)
|
|
||||||
add(originalWork.description)
|
|
||||||
addAll(tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = papagoTranslationService.translate(
|
|
||||||
TranslateRequest(
|
|
||||||
texts = texts,
|
|
||||||
sourceLanguage = source,
|
|
||||||
targetLanguage = target
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val out = response.translatedText
|
|
||||||
if (out.isEmpty()) return null
|
|
||||||
|
|
||||||
// 앞 4개는 필드, 나머지는 태그
|
|
||||||
val title = out.getOrNull(0)?.trim().orEmpty()
|
|
||||||
val contentType = out.getOrNull(1)?.trim().orEmpty()
|
|
||||||
val category = out.getOrNull(2)?.trim().orEmpty()
|
|
||||||
val description = out.getOrNull(3)?.trim().orEmpty()
|
|
||||||
val translatedTags = if (out.size > 4) {
|
|
||||||
out.drop(4).map { it.trim() }.filter { it.isNotEmpty() }
|
|
||||||
} else {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasAny = title.isNotEmpty() || contentType.isNotEmpty() ||
|
|
||||||
category.isNotEmpty() || description.isNotEmpty() || translatedTags.isNotEmpty()
|
|
||||||
if (!hasAny) return null
|
|
||||||
|
|
||||||
val payload = OriginalWorkTranslationPayload(
|
|
||||||
title = title,
|
|
||||||
contentType = contentType,
|
|
||||||
category = category,
|
|
||||||
description = description,
|
|
||||||
tags = translatedTags
|
|
||||||
)
|
|
||||||
|
|
||||||
val entity = existed?.apply { this.renderedPayload = payload }
|
|
||||||
?: OriginalWorkTranslation(
|
|
||||||
originalWorkId = originalWork.id!!,
|
|
||||||
locale = target,
|
|
||||||
renderedPayload = payload
|
|
||||||
)
|
|
||||||
|
|
||||||
translationRepository.save(entity)
|
|
||||||
|
|
||||||
TranslatedOriginalWork(
|
|
||||||
title = title,
|
|
||||||
contentType = contentType,
|
|
||||||
category = category,
|
|
||||||
description = description,
|
|
||||||
tags = translatedTags
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warn("Failed to translate OriginalWork(id={}) from {} to {}: {}", originalWork.id, source, target, e.message)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original.translation
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
|
||||||
import javax.persistence.AttributeConverter
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Convert
|
|
||||||
import javax.persistence.Converter
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.Table
|
|
||||||
import javax.persistence.UniqueConstraint
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(
|
|
||||||
uniqueConstraints = [
|
|
||||||
UniqueConstraint(columnNames = ["original_work_id", "locale"])
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class OriginalWorkTranslation(
|
|
||||||
@Column(name = "original_work_id")
|
|
||||||
val originalWorkId: Long,
|
|
||||||
@Column(name = "locale")
|
|
||||||
val locale: String,
|
|
||||||
|
|
||||||
@Column(columnDefinition = "json")
|
|
||||||
@Convert(converter = OriginalWorkTranslationPayloadConverter::class)
|
|
||||||
var renderedPayload: OriginalWorkTranslationPayload
|
|
||||||
) : BaseEntity()
|
|
||||||
|
|
||||||
data class OriginalWorkTranslationPayload(
|
|
||||||
val title: String,
|
|
||||||
val contentType: String,
|
|
||||||
val category: String,
|
|
||||||
val description: String,
|
|
||||||
val tags: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TranslatedOriginalWork(
|
|
||||||
val title: String,
|
|
||||||
val contentType: String,
|
|
||||||
val category: String,
|
|
||||||
val description: String,
|
|
||||||
val tags: List<String>
|
|
||||||
)
|
|
||||||
|
|
||||||
@Converter(autoApply = false)
|
|
||||||
class OriginalWorkTranslationPayloadConverter : AttributeConverter<OriginalWorkTranslationPayload, String> {
|
|
||||||
|
|
||||||
override fun convertToDatabaseColumn(attribute: OriginalWorkTranslationPayload?): String {
|
|
||||||
if (attribute == null) return "{}"
|
|
||||||
return objectMapper.writeValueAsString(attribute)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun convertToEntityAttribute(dbData: String?): OriginalWorkTranslationPayload {
|
|
||||||
if (dbData.isNullOrBlank()) {
|
|
||||||
return OriginalWorkTranslationPayload(
|
|
||||||
title = "",
|
|
||||||
contentType = "",
|
|
||||||
category = "",
|
|
||||||
description = "",
|
|
||||||
tags = emptyList()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
val node = objectMapper.readTree(dbData)
|
|
||||||
val title = node.get("title")?.asText() ?: ""
|
|
||||||
val contentType = node.get("contentType")?.asText() ?: ""
|
|
||||||
val category = node.get("category")?.asText() ?: ""
|
|
||||||
val description = node.get("description")?.asText() ?: ""
|
|
||||||
val tagsNode = node.get("tags")
|
|
||||||
val tags: List<String> = when {
|
|
||||||
tagsNode == null || tagsNode.isNull -> emptyList()
|
|
||||||
tagsNode.isArray -> tagsNode.mapNotNull { it.asText(null) }.filter { it.isNotBlank() }
|
|
||||||
tagsNode.isTextual -> tagsNode.asText()
|
|
||||||
.split(',')
|
|
||||||
.map { it.trim() }
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
|
|
||||||
else -> emptyList()
|
|
||||||
}
|
|
||||||
OriginalWorkTranslationPayload(
|
|
||||||
title = title,
|
|
||||||
contentType = contentType,
|
|
||||||
category = category,
|
|
||||||
description = description,
|
|
||||||
tags = tags
|
|
||||||
)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
OriginalWorkTranslationPayload(
|
|
||||||
title = "",
|
|
||||||
contentType = "",
|
|
||||||
category = "",
|
|
||||||
description = "",
|
|
||||||
tags = emptyList()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val objectMapper = jacksonObjectMapper()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original.translation
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
|
||||||
|
|
||||||
interface OriginalWorkTranslationRepository : JpaRepository<OriginalWorkTranslation, Long> {
|
|
||||||
fun findByOriginalWorkIdAndLocale(originalWorkId: Long, locale: String): OriginalWorkTranslation?
|
|
||||||
|
|
||||||
fun findByOriginalWorkIdInAndLocale(originalWorkIds: Set<Long>, locale: String): List<OriginalWorkTranslation>
|
|
||||||
}
|
|
||||||
@@ -54,7 +54,7 @@ class ChatRoomQuotaController(
|
|||||||
): ApiResponse<PurchaseRoomQuotaResponse> = run {
|
): ApiResponse<PurchaseRoomQuotaResponse> = run {
|
||||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
if (req.container.isBlank()) throw SodaException("잘못된 접근입니다")
|
if (req.container.isBlank()) throw SodaException("container를 확인해주세요.")
|
||||||
|
|
||||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||||
@@ -79,7 +79,7 @@ class ChatRoomQuotaController(
|
|||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
chatRoomId = chatRoomId,
|
chatRoomId = chatRoomId,
|
||||||
characterId = characterId,
|
characterId = characterId,
|
||||||
addPaid = 12,
|
addPaid = 40,
|
||||||
container = req.container
|
container = req.container
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -86,10 +86,6 @@ class ChatRoomQuotaService(
|
|||||||
// 1) 유료 우선 사용: 글로벌에 영향 없음
|
// 1) 유료 우선 사용: 글로벌에 영향 없음
|
||||||
if (quota.remainingPaid > 0) {
|
if (quota.remainingPaid > 0) {
|
||||||
quota.remainingPaid -= 1
|
quota.remainingPaid -= 1
|
||||||
// 유료 차감 후, 무료와 유료가 모두 0이 되는 시점이면 다음 무료 충전을 예약한다.
|
|
||||||
if (quota.remainingPaid == 0 && quota.remainingFree == 0 && quota.nextRechargeAt == null) {
|
|
||||||
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
|
|
||||||
}
|
|
||||||
val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid)
|
val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid)
|
||||||
return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid)
|
return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid)
|
||||||
}
|
}
|
||||||
@@ -98,16 +94,16 @@ class ChatRoomQuotaService(
|
|||||||
val globalFree = globalFreeProvider()
|
val globalFree = globalFreeProvider()
|
||||||
if (globalFree <= 0) {
|
if (globalFree <= 0) {
|
||||||
// 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가
|
// 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가
|
||||||
throw SodaException("오늘의 무료 채팅이 모두 소진되었습니다. 내일 다시 이용해 주세요.")
|
throw SodaException("무료 쿼터가 소진되었습니다. 글로벌 무료 충전 이후 이용해 주세요.")
|
||||||
}
|
}
|
||||||
if (quota.remainingFree <= 0) {
|
if (quota.remainingFree <= 0) {
|
||||||
// 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가
|
// 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가
|
||||||
val waitMillis = quota.nextRechargeAt
|
val waitMillis = quota.nextRechargeAt
|
||||||
if (waitMillis == null) {
|
if (waitMillis != null && waitMillis > nowMillis) {
|
||||||
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
|
throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 무료 충전 이후 이용해 주세요.")
|
||||||
|
} else {
|
||||||
|
throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 잠시 후 다시 시도해 주세요.")
|
||||||
}
|
}
|
||||||
|
|
||||||
throw SodaException("무료 채팅이 모두 소진되었습니다.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 둘 다 가능 → 차감
|
// 둘 다 가능 → 차감
|
||||||
@@ -126,13 +122,13 @@ class ChatRoomQuotaService(
|
|||||||
memberId: Long,
|
memberId: Long,
|
||||||
chatRoomId: Long,
|
chatRoomId: Long,
|
||||||
characterId: Long,
|
characterId: Long,
|
||||||
addPaid: Int = 12,
|
addPaid: Int = 40,
|
||||||
container: String
|
container: String
|
||||||
): RoomQuotaStatus {
|
): RoomQuotaStatus {
|
||||||
// 요구사항: 10캔 결제 및 UseCan에 방/캐릭터 기록
|
// 요구사항: 30캔 결제 및 UseCan에 방/캐릭터 기록
|
||||||
canPaymentService.spendCan(
|
canPaymentService.spendCan(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
needCan = 10,
|
needCan = 30,
|
||||||
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
|
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
|
||||||
chatRoomId = chatRoomId,
|
chatRoomId = chatRoomId,
|
||||||
characterId = characterId,
|
characterId = characterId,
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.common
|
|
||||||
|
|
||||||
const val WAF_GEO_HEADER = "x-amzn-waf-geo-country"
|
|
||||||
|
|
||||||
enum class GeoCountry { KR, OTHER }
|
|
||||||
|
|
||||||
fun parseGeo(headerValue: String?): GeoCountry =
|
|
||||||
if (headerValue?.trim()?.uppercase() == "KR") GeoCountry.KR else GeoCountry.OTHER
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.common
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
import org.springframework.web.filter.OncePerRequestFilter
|
|
||||||
import javax.servlet.FilterChain
|
|
||||||
import javax.servlet.http.HttpServletRequest
|
|
||||||
import javax.servlet.http.HttpServletResponse
|
|
||||||
|
|
||||||
@Component
|
|
||||||
class GeoCountryFilter : OncePerRequestFilter() {
|
|
||||||
override fun doFilterInternal(
|
|
||||||
request: HttpServletRequest,
|
|
||||||
response: HttpServletResponse,
|
|
||||||
filterChain: FilterChain
|
|
||||||
) {
|
|
||||||
val country = parseGeo(request.getHeader(WAF_GEO_HEADER))
|
|
||||||
request.setAttribute("geoCountry", country)
|
|
||||||
filterChain.doFilter(request, response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler
|
|||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||||
import org.springframework.web.multipart.MaxUploadSizeExceededException
|
import org.springframework.web.multipart.MaxUploadSizeExceededException
|
||||||
import org.springframework.web.server.ResponseStatusException
|
|
||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
class SodaExceptionHandler {
|
class SodaExceptionHandler {
|
||||||
@@ -64,7 +63,6 @@ class SodaExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(Exception::class)
|
@ExceptionHandler(Exception::class)
|
||||||
fun handleException(e: Exception) = run {
|
fun handleException(e: Exception) = run {
|
||||||
if (e is ResponseStatusException) throw e
|
|
||||||
logger.error("API error", e)
|
logger.error("API error", e)
|
||||||
ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
|
ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,16 +123,6 @@ class RedisConfig(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 24시간 TTL 캐시: 인기 캐릭터 집계용
|
|
||||||
cacheConfigMap["popularCharacters_24h"] = RedisCacheConfiguration.defaultCacheConfig()
|
|
||||||
.entryTtl(Duration.ofHours(24))
|
|
||||||
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
|
|
||||||
.serializeValuesWith(
|
|
||||||
RedisSerializationContext.SerializationPair.fromSerializer(
|
|
||||||
GenericJackson2JsonRedisSerializer()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return RedisCacheManager.builder(redisConnectionFactory)
|
return RedisCacheManager.builder(redisConnectionFactory)
|
||||||
.cacheDefaults(defaultCacheConfig)
|
.cacheDefaults(defaultCacheConfig)
|
||||||
.withInitialCacheConfigurations(cacheConfigMap)
|
.withInitialCacheConfigurations(cacheConfigMap)
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ class SecurityConfig(
|
|||||||
.antMatchers("/api/home").permitAll()
|
.antMatchers("/api/home").permitAll()
|
||||||
.antMatchers("/api/home/latest-content").permitAll()
|
.antMatchers("/api/home/latest-content").permitAll()
|
||||||
.antMatchers("/api/home/day-of-week-series").permitAll()
|
.antMatchers("/api/home/day-of-week-series").permitAll()
|
||||||
.antMatchers("/api/home/content-ranking").permitAll()
|
|
||||||
.antMatchers(HttpMethod.GET, "/api/live").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/live").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/faq").permitAll()
|
.antMatchers(HttpMethod.GET, "/faq").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/faq/category").permitAll()
|
.antMatchers(HttpMethod.GET, "/faq/category").permitAll()
|
||||||
@@ -96,8 +95,6 @@ class SecurityConfig(
|
|||||||
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
|
||||||
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
|
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
.and()
|
.and()
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
package kr.co.vividnext.sodalive.configs
|
package kr.co.vividnext.sodalive.configs
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.i18n.LangInterceptor
|
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class WebConfig(
|
class WebConfig : WebMvcConfigurer {
|
||||||
private val langInterceptor: LangInterceptor
|
|
||||||
) : WebMvcConfigurer {
|
|
||||||
override fun addInterceptors(registry: InterceptorRegistry) {
|
|
||||||
registry.addInterceptor(langInterceptor).addPathPatterns("/**")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addCorsMappings(registry: CorsRegistry) {
|
override fun addCorsMappings(registry: CorsRegistry) {
|
||||||
registry.addMapping("/**")
|
registry.addMapping("/**")
|
||||||
.allowedOrigins(
|
.allowedOrigins(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user