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