Compare commits
	
		
			389 Commits
		
	
	
		
			7577f48a09
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 90555fd34f | |||
| a05ada5df0 | |||
| 0dc430b098 | |||
| 34480385d3 | |||
| 1f2103c7fa | |||
| fd68ed87a3 | |||
| 062c17c51e | |||
| 779fc5c5a5 | |||
| de169b79a1 | |||
| 08ebb311fb | |||
| aa24de0a5a | |||
| 12cdd25be7 | |||
| 59700493eb | |||
| e5937d573a | |||
| 88c3a84972 | |||
| db0d3a6ef3 | |||
| 3d29d27441 | |||
| b5f66603bd | |||
| 6da86e12bd | |||
| 976eeaa443 | |||
| 25d1d813f1 | |||
| 778f0c3ba2 | |||
| 38c50a4f8a | |||
| 9049022a74 | |||
| c497f321bb | |||
| 7b6f3a7a5f | |||
| 84c0768c8b | |||
| 53e9678efa | |||
| efb8d8115f | |||
| e4f547fa92 | |||
| 41183b4648 | |||
| 36e20bf0d1 | |||
| 0308e9ad83 | |||
| 06c0374f16 | |||
| c5bc610e2f | |||
| a86a24ca34 | |||
| cb2e3ea581 | |||
| 42eaf1d5e3 | |||
| 02ef706fc2 | |||
| 085b217abb | |||
| 0866e0972a | |||
| 4b13265737 | |||
| 79cd2b8123 | |||
| 8cc9641bbf | |||
| 32935aed88 | |||
| c72adbfc4b | |||
| bc378cc619 | |||
| 6327a5d2bf | |||
| 2ab2a04748 | |||
| fb0a9e98a1 | |||
| e45fe1bf10 | |||
| 3d852a8356 | |||
| b244944f41 | |||
| 3c7ba669e2 | |||
| 81e7e7129c | |||
| d7ad110b9e | |||
| 0c17ea2dcd | |||
| 78ff13a654 | |||
| 863c285049 | |||
| a3d74c0b57 | |||
| 9016a72046 | |||
| 3c32614d1c | |||
| 51988471cf | |||
| 8990bd0722 | |||
| aab2417976 | |||
| 1bd6f8da4e | |||
| 22bd1bf042 | |||
| d536a65fb4 | |||
| 03149a637d | |||
| bc6c05b3ea | |||
| 59ca353b25 | |||
| 6bc65ec412 | |||
| 97e95b51ab | |||
| b69756ef81 | |||
| a6dfa81ba6 | |||
| dad517a953 | |||
| eb2d093b02 | |||
| 67186bba55 | |||
| 1a3a9149a2 | |||
| edeecad2ce | |||
| 387f5388d9 | |||
| adcaa0a5fd | |||
| 47b2c1cb93 | |||
| ce120a6d5d | |||
| 7f3589dcfb | |||
| b134c28c10 | |||
| 41c8d0367d | |||
| 3b148d549e | |||
| b6c96af8a2 | |||
| 4904625488 | |||
| 08b5fd23ab | |||
| 0574f4f629 | |||
| 4adc3e127c | |||
| dd0a1c2293 | |||
| a07407417c | |||
| e33e3b43b7 | |||
| 634bf759ca | |||
| 0ed29c6097 | |||
| b752434fbb | |||
| eec63cc7b2 | |||
| 3dc9dd1f35 | |||
| 88e287067b | |||
| eb18e2d009 | |||
| 27a3f450ef | |||
| 58a46a09c3 | |||
| 83a1316a64 | |||
| f05f146c89 | |||
| a27852ed44 | |||
| 3782062f4a | |||
| fd83abb46c | |||
| a9d1b9f4a6 | |||
| ad69dad725 | |||
| 2f55303d16 | |||
| 3a9128a894 | |||
| def6296d4d | |||
| 034472defa | |||
| 550e4ac9ce | |||
| d26e0a89f6 | |||
| 6767afdd35 | |||
| a58de0cf92 | |||
| df93f0e0ce | |||
| 0b54b126db | |||
| a94cf8dad9 | |||
| 2c3e12a42c | |||
| c4dbdc1b8e | |||
| 42ed4692af | |||
| 258943535c | |||
| 0347d767f0 | |||
| 48b0190242 | |||
| 15d0952de8 | |||
| 84ebc1762b | |||
| a096b16945 | |||
| 37ac52116a | |||
| fcb68be006 | |||
| 048c48d754 | |||
| 6ecac8d331 | |||
| 8b1dd7cb95 | |||
| 5a58fe9077 | |||
| 12574dbe46 | |||
| b3e7c00232 | |||
| 692e060f6d | |||
| 2ac0a5f896 | |||
| f8be99547a | |||
| 7dd585c3dd | |||
| 7355949c1e | |||
| 539b9fb2b2 | |||
| 99386c6d53 | |||
| abbd73ac00 | |||
| 4bee95c8a6 | |||
| 090fc81829 | |||
| 75100cacec | |||
| 13fd262c94 | |||
| 8451cdfb80 | |||
| c8841856c0 | |||
| 2a30b28e43 | |||
| dd6849b840 | |||
| ca27903e45 | |||
| aeab6eddc2 | |||
| 1c0d40aed9 | |||
| 1444afaae2 | |||
| a05bc369b7 | |||
| 6c7f411869 | |||
| f61c45e89a | |||
| 27ed9f61d0 | |||
| df77e31043 | |||
| 2d65bdb8ee | |||
| 4966aaeda9 | |||
| 28bd700b03 | |||
| f2ca013b96 | |||
| 6cf7dabaef | |||
| e6d63592ec | |||
| 3ac4ebded3 | |||
| 6f9fc659f3 | |||
| 005bb0ea2e | |||
| 80a0543e10 | |||
| 5d42805514 | |||
| 1b7ae8a2c5 | |||
| 168b0b13fb | |||
| d99fcba468 | |||
| 147b8b0a42 | |||
| eed755fd11 | |||
| 74a612704e | |||
| 8defc56d1e | |||
| 1db20d118d | |||
| 7a70a770bb | |||
| cc9e4f974f | |||
| 2965b8fea0 | |||
| 00c617ec2e | |||
| 01ef738d31 | |||
| 423cbe7315 | |||
| afb003c397 | |||
| 2dc5a29220 | |||
| c525ec0330 | |||
| 735f1e26df | |||
| 5129400a29 | |||
| a6a01aaa37 | |||
| b819df9656 | |||
| 5d1c5fcc44 | |||
| ebad3b31b7 | |||
| 3e9f7f9e29 | |||
| 4b3463e97c | |||
| 002f2c2834 | |||
| 1509ee0729 | |||
| 830e41dfa3 | |||
| 4d1f84cc5c | |||
| 1bafbed17c | |||
| 694d9cd05a | |||
| 60172ae84d | |||
| 7e7a1122fa | |||
| a1533c8e98 | |||
| b0a6fc6498 | |||
| 74ed7b20ba | |||
| 206c25985a | |||
| 0001697274 | |||
| add21c45c5 | |||
| ef8458c7a3 | |||
| 81f972edc1 | |||
| c729a402aa | |||
| 2335050834 | |||
| 6340ed27cf | |||
| 618f80fddc | |||
| 45b6c8db96 | |||
| 5132a6b9fa | |||
| de6642b675 | |||
| 3b42399726 | |||
| 689f9fe48f | |||
| 73038222cc | |||
| c7925c1706 | |||
| 2659adb7a9 | |||
| be59bd7e89 | |||
| fcb2ca1917 | |||
| 51ce143fc2 | |||
| 804e139385 | |||
| f0fc996426 | |||
| 89eb11f808 | |||
| efdb485a3b | |||
| 30d89987a4 | |||
| 3d695069a2 | |||
| e068b57062 | |||
| 811810cd36 | |||
| c90df4b02b | |||
| 7c1082f833 | |||
| 800b8d3216 | |||
| ab877beae1 | |||
| 046c163e6f | |||
| 7959d3e5ed | |||
| 8e877a6366 | |||
| d18c19dd35 | |||
| a99260209b | |||
| 2192ddc8fa | |||
| 741a1282a3 | |||
| 1a6a331ad8 | |||
| 1ba63e2cab | |||
| 5696240e03 | |||
| 885243a5b0 | |||
| a849d00c7f | |||
| d04b44c931 | |||
| a3aad9d2c9 | |||
| d98268f809 | |||
| 34440e9ba3 | |||
| d1c889e5f2 | |||
| 1e29573ef7 | |||
| 55da259510 | |||
| cc2f533dc6 | |||
| 4436e6f20a | |||
| 32b0c19f9d | |||
| 3cedd36e15 | |||
| ecbe9b2e93 | |||
| 9ad6b6ea48 | |||
| 0d2daf4d2c | |||
| edf16a6021 | |||
| 9af2d768e8 | |||
| 7551a19b34 | |||
| f59f45d9a4 | |||
| 81e82ad731 | |||
| ca870392e2 | |||
| a7e167a95f | |||
| a49b82a7c2 | |||
| 704ad12ccf | |||
| ab9fd2bc16 | |||
| 69a63a77d3 | |||
| da7e4c2156 | |||
| a4b5185f6b | |||
| 22fc8b22b8 | |||
| a8da17162a | |||
| 5677824cde | |||
| f13c221fd6 | |||
| 4ffa9363a8 | |||
| 6d2f48f86d | |||
| 8e01ced1f5 | |||
| e8f1bc09f9 | |||
| 640f5ce6f5 | |||
| c0be30027c | |||
| 832586bd41 | |||
| 1a774937b3 | |||
| d1a936d55b | |||
| e508dafb34 | |||
| 8335717741 | |||
| 16a2b82ffd | |||
| 8db5c6443d | |||
| 9ed717fb95 | |||
| dcd4497315 | |||
| 54c0322398 | |||
| e3c33c71a0 | |||
| dc97eaa835 | |||
| 7055bb9872 | |||
| dcbe57806c | |||
| fd1b17e356 | |||
| 28427a873a | |||
| 5bdb101b52 | |||
| 97b2b38f8e | |||
| 2268f4a3fc | |||
| 9eff828249 | |||
| b14438cc15 | |||
| 3275ac5036 | |||
| b27d3bd5c6 | |||
| e049e0fa3c | |||
| 03ebc9cfe9 | |||
| caee89cf53 | |||
| 24841b9850 | |||
| e67b798714 | |||
| dc13053825 | |||
| af352256e9 | |||
| d35a3d1a8c | |||
| b92810efd2 | |||
| fcbd809691 | |||
| 60c4e0b528 | |||
| d3ec13e6c0 | |||
| a36d9f02d8 | |||
| d6db862c9d | |||
| 56542a7bf1 | |||
| 36b8e8169e | |||
| b102241efd | |||
| f36010fefa | |||
| aa23d6d50f | |||
| 6df043dfac | |||
| fe84292483 | |||
| 0f48c71837 | |||
| 107e8fce55 | |||
| 3079998a5d | |||
| e2d0ae558a | |||
| 1bca1b27ed | |||
| 6fc372c898 | |||
| ddcd54d3b9 | |||
| eb8c8c14e8 | |||
| affc0cc235 | |||
| f23251f5bb | |||
| 84f33d1bc2 | |||
| 73c9a90ae3 | |||
| c4e1709b99 | |||
| ced35af66d | |||
| b915ace6ff | |||
| e7a5fd5819 | |||
| 2fd7419bdd | |||
| 4bde03643c | |||
| fd510710d9 | |||
| 8a924bd5be | |||
| 1bc52b56af | |||
| 73edc0515f | |||
| 9c33fd93f7 | |||
| 7870f8ea78 | |||
| 3c087bc275 | |||
| 27c5b991cf | |||
| 8a937f01a4 | |||
| 3940282ed8 | |||
| ca704f38b9 | |||
| 6ff044e4ab | |||
| fa98138541 | |||
| cb7917dc26 | |||
| 58d066af0a | |||
| e2daff6463 | |||
| 7c3b7cffc2 | |||
| 775391f590 | |||
| 57adfec490 | |||
| 24e62c1885 | |||
| a70b5d89ec | |||
| 761d56f4bd | |||
| e759f62b5f | |||
| 9e2d031b5d | |||
| b9cb8ad4a8 | |||
| c1d4c1ff1d | |||
| 971683a81e | |||
| 51dae0f02c | |||
| e2c70de2e0 | |||
| d94418067f | |||
| 1cb2ee77b5 | |||
| 336d3c9434 | |||
| 8ad13c289e | |||
| 7649ce6e52 | 
| @@ -7,5 +7,5 @@ indent_size = 4 | ||||
| indent_style = space | ||||
| trim_trailing_whitespace = true | ||||
| insert_final_newline = true | ||||
| max_line_length = 120 | ||||
| max_line_length = 130 | ||||
| tab_width = 4 | ||||
|   | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -323,4 +323,7 @@ gradle-app.setting | ||||
| ### Gradle Patch ### | ||||
| **/build/ | ||||
|  | ||||
| .kiro/ | ||||
| .junie | ||||
|  | ||||
| # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle | ||||
|   | ||||
| @@ -70,6 +70,9 @@ dependencies { | ||||
|     implementation("org.apache.poi:poi-ooxml:5.2.3") | ||||
|     implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") | ||||
|  | ||||
|     // file mimetype check | ||||
|     implementation("org.apache.tika:tika-core:3.2.0") | ||||
|  | ||||
|     developmentOnly("org.springframework.boot:spring-boot-devtools") | ||||
|     runtimeOnly("com.h2database:h2") | ||||
|     runtimeOnly("com.mysql:mysql-connector-j") | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| 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 | ||||
| @@ -38,7 +39,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|             .innerJoin(useCan.room, liveRoom) | ||||
|             .innerJoin(liveRoom.member, member) | ||||
|             .leftJoin(creatorSettlementRatio) | ||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) | ||||
|             .on( | ||||
|                 member.id.eq(creatorSettlementRatio.member.id) | ||||
|                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||
|             ) | ||||
|             .where( | ||||
|                 useCan.isRefund.isFalse | ||||
|                     .and(useCan.createdAt.goe(startDate)) | ||||
| @@ -51,6 +55,10 @@ 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( | ||||
| @@ -62,6 +70,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|                     order.can, | ||||
|                     order.id.count(), | ||||
|                     order.can.sum(), | ||||
|                     order.point.sum(), | ||||
|                     creatorSettlementRatio.contentSettlementRatio | ||||
|                 ) | ||||
|             ) | ||||
| @@ -69,7 +78,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|             .innerJoin(order.audioContent, audioContent) | ||||
|             .innerJoin(audioContent.member, member) | ||||
|             .leftJoin(creatorSettlementRatio) | ||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) | ||||
|             .on( | ||||
|                 member.id.eq(creatorSettlementRatio.member.id) | ||||
|                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||
|             ) | ||||
|             .where( | ||||
|                 order.createdAt.goe(startDate) | ||||
|                     .and(order.createdAt.loe(endDate)) | ||||
| @@ -80,6 +92,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|                 order.type, | ||||
|                 orderFormattedDate, | ||||
|                 order.can, | ||||
|                 pointGroup, | ||||
|                 creatorSettlementRatio.contentSettlementRatio | ||||
|             ) | ||||
|             .orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc()) | ||||
| @@ -113,6 +126,10 @@ 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( | ||||
| @@ -123,6 +140,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|                     order.can, | ||||
|                     order.id.count(), | ||||
|                     order.can.sum(), | ||||
|                     order.point.sum(), | ||||
|                     creatorSettlementRatio.contentSettlementRatio | ||||
|                 ) | ||||
|             ) | ||||
| @@ -130,9 +148,19 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|             .innerJoin(order.audioContent, audioContent) | ||||
|             .innerJoin(audioContent.member, member) | ||||
|             .leftJoin(creatorSettlementRatio) | ||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) | ||||
|             .on( | ||||
|                 member.id.eq(creatorSettlementRatio.member.id) | ||||
|                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||
|             ) | ||||
|             .where(order.isActive.isTrue) | ||||
|             .groupBy(member.id, audioContent.id, order.type, order.can) | ||||
|             .groupBy( | ||||
|                 member.id, | ||||
|                 audioContent.id, | ||||
|                 order.type, | ||||
|                 order.can, | ||||
|                 pointGroup, | ||||
|                 creatorSettlementRatio.contentSettlementRatio | ||||
|             ) | ||||
|             .offset(offset) | ||||
|             .limit(limit) | ||||
|             .orderBy(member.id.desc(), audioContent.id.desc()) | ||||
| @@ -211,7 +239,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|             .innerJoin(useCan.communityPost, creatorCommunity) | ||||
|             .innerJoin(creatorCommunity.member, member) | ||||
|             .leftJoin(creatorSettlementRatio) | ||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) | ||||
|             .on( | ||||
|                 member.id.eq(creatorSettlementRatio.member.id) | ||||
|                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||
|             ) | ||||
|             .where( | ||||
|                 useCan.isRefund.isFalse | ||||
|                     .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) | ||||
| @@ -232,7 +263,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|             .innerJoin(useCan.room, liveRoom) | ||||
|             .innerJoin(liveRoom.member, member) | ||||
|             .leftJoin(creatorSettlementRatio) | ||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) | ||||
|             .on( | ||||
|                 member.id.eq(creatorSettlementRatio.member.id) | ||||
|                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||
|             ) | ||||
|             .where( | ||||
|                 useCan.isRefund.isFalse | ||||
|                     .and(useCan.createdAt.goe(startDate)) | ||||
| @@ -262,7 +296,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|             .innerJoin(useCan.room, liveRoom) | ||||
|             .innerJoin(liveRoom.member, member) | ||||
|             .leftJoin(creatorSettlementRatio) | ||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) | ||||
|             .on( | ||||
|                 member.id.eq(creatorSettlementRatio.member.id) | ||||
|                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||
|             ) | ||||
|             .where( | ||||
|                 useCan.isRefund.isFalse | ||||
|                     .and(useCan.createdAt.goe(startDate)) | ||||
| @@ -282,7 +319,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|             .innerJoin(order.audioContent, audioContent) | ||||
|             .innerJoin(audioContent.member, member) | ||||
|             .leftJoin(creatorSettlementRatio) | ||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) | ||||
|             .on( | ||||
|                 member.id.eq(creatorSettlementRatio.member.id) | ||||
|                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||
|             ) | ||||
|             .where( | ||||
|                 order.createdAt.goe(startDate) | ||||
|                     .and(order.createdAt.loe(endDate)) | ||||
| @@ -312,7 +352,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|             .innerJoin(order.audioContent, audioContent) | ||||
|             .innerJoin(audioContent.member, member) | ||||
|             .leftJoin(creatorSettlementRatio) | ||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) | ||||
|             .on( | ||||
|                 member.id.eq(creatorSettlementRatio.member.id) | ||||
|                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||
|             ) | ||||
|             .where( | ||||
|                 order.createdAt.goe(startDate) | ||||
|                     .and(order.createdAt.loe(endDate)) | ||||
| @@ -332,7 +375,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|             .innerJoin(useCan.communityPost, creatorCommunity) | ||||
|             .innerJoin(creatorCommunity.member, member) | ||||
|             .leftJoin(creatorSettlementRatio) | ||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) | ||||
|             .on( | ||||
|                 member.id.eq(creatorSettlementRatio.member.id) | ||||
|                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||
|             ) | ||||
|             .where( | ||||
|                 useCan.isRefund.isFalse | ||||
|                     .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) | ||||
| @@ -363,7 +409,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|             .innerJoin(useCan.communityPost, creatorCommunity) | ||||
|             .innerJoin(creatorCommunity.member, member) | ||||
|             .leftJoin(creatorSettlementRatio) | ||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) | ||||
|             .on( | ||||
|                 member.id.eq(creatorSettlementRatio.member.id) | ||||
|                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||
|             ) | ||||
|             .where( | ||||
|                 useCan.isRefund.isFalse | ||||
|                     .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) | ||||
|   | ||||
| @@ -22,11 +22,15 @@ data class GetCalculateContentQueryData @QueryProjection constructor( | ||||
|     val numberOfPeople: Long, | ||||
|     // 합계 | ||||
|     val totalCan: Int, | ||||
|     // 포인트 | ||||
|     val totalPoint: Int, | ||||
|     // 정산비율 | ||||
|     val settlementRatio: Int? | ||||
| ) { | ||||
|     fun toGetCalculateContentResponse(): GetCalculateContentResponse { | ||||
|         val orderTypeStr = if (orderType == OrderType.RENTAL) { | ||||
|         val orderTypeStr = if (totalPoint > 0) { | ||||
|             "포인트" | ||||
|         } else if (orderType == OrderType.RENTAL) { | ||||
|             "대여" | ||||
|         } else { | ||||
|             "소장" | ||||
|   | ||||
| @@ -21,11 +21,15 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor( | ||||
|     val numberOfPeople: Long, | ||||
|     // 합계 | ||||
|     val totalCan: Int, | ||||
|     // 포인트 | ||||
|     val totalPoint: Int, | ||||
|     // 정산비율 | ||||
|     val settlementRatio: Int? | ||||
| ) { | ||||
|     fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem { | ||||
|         val orderTypeStr = if (orderType == OrderType.RENTAL) { | ||||
|         val orderTypeStr = if (totalPoint > 0) { | ||||
|             "포인트" | ||||
|         } else if (orderType == OrderType.RENTAL) { | ||||
|             "대여" | ||||
|         } else { | ||||
|             "소장" | ||||
|   | ||||
| @@ -2,6 +2,7 @@ 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 | ||||
| @@ -9,12 +10,29 @@ import javax.persistence.OneToOne | ||||
|  | ||||
| @Entity | ||||
| data class CreatorSettlementRatio( | ||||
|     val subsidy: Int, | ||||
|     val liveSettlementRatio: Int, | ||||
|     val contentSettlementRatio: Int, | ||||
|     val communitySettlementRatio: Int | ||||
|     var subsidy: Int, | ||||
|     var liveSettlementRatio: Int, | ||||
|     var contentSettlementRatio: Int, | ||||
|     var 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,6 +4,7 @@ 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 | ||||
| @@ -27,4 +28,14 @@ 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,7 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository | ||||
|  | ||||
| interface CreatorSettlementRatioRepository : | ||||
|     JpaRepository<CreatorSettlementRatio, Long>, | ||||
|     CreatorSettlementRatioQueryRepository | ||||
|     CreatorSettlementRatioQueryRepository { | ||||
|     fun findByMemberId(memberId: Long): CreatorSettlementRatio? | ||||
| } | ||||
|  | ||||
| interface CreatorSettlementRatioQueryRepository { | ||||
|     fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem> | ||||
| @@ -21,6 +23,7 @@ class CreatorSettlementRatioQueryRepositoryImpl( | ||||
|         return queryFactory | ||||
|             .select( | ||||
|                 QGetCreatorSettlementRatioItem( | ||||
|                     member.id, | ||||
|                     member.nickname, | ||||
|                     creatorSettlementRatio.subsidy, | ||||
|                     creatorSettlementRatio.liveSettlementRatio, | ||||
| @@ -30,6 +33,7 @@ class CreatorSettlementRatioQueryRepositoryImpl( | ||||
|             ) | ||||
|             .from(creatorSettlementRatio) | ||||
|             .innerJoin(creatorSettlementRatio.member, member) | ||||
|             .where(creatorSettlementRatio.deletedAt.isNull) | ||||
|             .orderBy(creatorSettlementRatio.id.asc()) | ||||
|             .offset(offset) | ||||
|             .limit(limit) | ||||
| @@ -40,6 +44,7 @@ class CreatorSettlementRatioQueryRepositoryImpl( | ||||
|         return queryFactory | ||||
|             .select(creatorSettlementRatio.id) | ||||
|             .from(creatorSettlementRatio) | ||||
|             .where(creatorSettlementRatio.deletedAt.isNull) | ||||
|             .fetch() | ||||
|             .size | ||||
|     } | ||||
|   | ||||
| @@ -14,8 +14,6 @@ class CreatorSettlementRatioService( | ||||
| ) { | ||||
|     @Transactional | ||||
|     fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { | ||||
|         val creatorSettlementRatio = request.toEntity() | ||||
|  | ||||
|         val creator = memberRepository.findByIdOrNull(request.memberId) | ||||
|             ?: throw SodaException("잘못된 크리에이터 입니다.") | ||||
|  | ||||
| @@ -23,10 +21,52 @@ 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,6 +8,7 @@ data class GetCreatorSettlementRatioResponse( | ||||
| ) | ||||
|  | ||||
| data class GetCreatorSettlementRatioItem @QueryProjection constructor( | ||||
|     val memberId: Long, | ||||
|     val nickname: String, | ||||
|     val subsidy: Int, | ||||
|     val liveSettlementRatio: Int, | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| 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 | ||||
| @@ -13,6 +15,11 @@ 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,6 +1,38 @@ | ||||
| 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> | ||||
| 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() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -3,11 +3,13 @@ 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: Int | ||||
|     val price: BigDecimal, | ||||
|     val currency: String | ||||
| ) { | ||||
|     fun toEntity(): Can { | ||||
|         var title = "${can.moneyFormat()} 캔" | ||||
| @@ -20,6 +22,7 @@ data class AdminCanRequest( | ||||
|             can = can, | ||||
|             rewardCan = rewardCan, | ||||
|             price = price, | ||||
|             currency = currency, | ||||
|             status = CanStatus.SALE | ||||
|         ) | ||||
|     } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| 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 | ||||
| @@ -20,6 +21,10 @@ 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,6 +21,7 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService) | ||||
|     @GetMapping("/detail") | ||||
|     fun getChargeStatusDetail( | ||||
|         @RequestParam startDateStr: String, | ||||
|         @RequestParam paymentGateway: PaymentGateway | ||||
|     ) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway)) | ||||
|         @RequestParam paymentGateway: PaymentGateway, | ||||
|         @RequestParam(value = "currency", required = false) currency: String? = null | ||||
|     ) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency)) | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| 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 | ||||
| @@ -14,7 +15,7 @@ import java.time.LocalDateTime | ||||
|  | ||||
| @Repository | ||||
| class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||
|     fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> { | ||||
|     fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> { | ||||
|         val formattedDate = Expressions.stringTemplate( | ||||
|             "DATE_FORMAT({0}, {1})", | ||||
|             Expressions.dateTimeTemplate( | ||||
| @@ -26,15 +27,16 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory | ||||
|             ), | ||||
|             "%Y-%m-%d" | ||||
|         ) | ||||
|         val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale) | ||||
|  | ||||
|         return queryFactory | ||||
|             .select( | ||||
|                 QGetChargeStatusQueryDto( | ||||
|                 QGetChargeStatusResponse( | ||||
|                     formattedDate, | ||||
|                     payment.price.sum(), | ||||
|                     can1.price.sum(), | ||||
|                     payment.id.count(), | ||||
|                     payment.paymentGateway | ||||
|                     payment.paymentGateway.stringValue(), | ||||
|                     currency.coalesce("KRW") | ||||
|                 ) | ||||
|             ) | ||||
|             .from(payment) | ||||
| @@ -46,15 +48,46 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory | ||||
|                     .and(charge.status.eq(ChargeStatus.CHARGE)) | ||||
|                     .and(payment.status.eq(PaymentStatus.COMPLETE)) | ||||
|             ) | ||||
|             .groupBy(formattedDate, payment.paymentGateway) | ||||
|             .groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW")) | ||||
|             .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 | ||||
|         paymentGateway: PaymentGateway, | ||||
|         currency: String? = null | ||||
|     ): List<GetChargeStatusDetailQueryDto> { | ||||
|         val formattedDate = Expressions.stringTemplate( | ||||
|             "DATE_FORMAT({0}, {1})", | ||||
| @@ -67,6 +100,20 @@ 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( | ||||
| @@ -75,8 +122,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory | ||||
|                     member.nickname, | ||||
|                     payment.method.coalesce(""), | ||||
|                     payment.price, | ||||
|                     can1.price, | ||||
|                     payment.locale.coalesce(""), | ||||
|                     currencyExpr, | ||||
|                     formattedDate | ||||
|                 ) | ||||
|             ) | ||||
| @@ -84,13 +130,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory | ||||
|             .innerJoin(charge.member, member) | ||||
|             .innerJoin(charge.payment, payment) | ||||
|             .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)) | ||||
|                     .and(payment.paymentGateway.eq(paymentGateway)) | ||||
|             ) | ||||
|             .where(whereBuilder) | ||||
|             .orderBy(formattedDate.desc()) | ||||
|             .fetch() | ||||
|     } | ||||
|   | ||||
| @@ -20,48 +20,17 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository) | ||||
|             .withZoneSameInstant(ZoneId.of("UTC")) | ||||
|             .toLocalDateTime() | ||||
|  | ||||
|         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 = "" | ||||
|             ) | ||||
|         ) | ||||
|         val summaryRows = repository.getChargeStatusSummary(startDate, endDate) | ||||
|         val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList() | ||||
|         chargeStatusList.addAll(0, summaryRows) | ||||
|  | ||||
|         return chargeStatusList.toList() | ||||
|     } | ||||
|  | ||||
|     fun getChargeStatusDetail( | ||||
|         startDateStr: String, | ||||
|         paymentGateway: PaymentGateway | ||||
|         paymentGateway: PaymentGateway, | ||||
|         currency: String? = null | ||||
|     ): List<GetChargeStatusDetailResponse> { | ||||
|         val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||
|         val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0) | ||||
| @@ -74,18 +43,16 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository) | ||||
|             .withZoneSameInstant(ZoneId.of("UTC")) | ||||
|             .toLocalDateTime() | ||||
|  | ||||
|         return repository.getChargeStatusDetail(startDate, endDate, paymentGateway) | ||||
|             .asSequence() | ||||
|         return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency) | ||||
|             .map { | ||||
|                 GetChargeStatusDetailResponse( | ||||
|                     memberId = it.memberId, | ||||
|                     nickname = it.nickname, | ||||
|                     method = it.method, | ||||
|                     amount = it.appleChargeAmount.toInt(), | ||||
|                     amount = it.amount, | ||||
|                     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 appleChargeAmount: Double, | ||||
|     val pgChargeAmount: Int, | ||||
|     val amount: BigDecimal, | ||||
|     val locale: String, | ||||
|     val datetime: String | ||||
| ) | ||||
|   | ||||
| @@ -1,10 +1,12 @@ | ||||
| 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: Int, | ||||
|     val amount: BigDecimal, | ||||
|     val locale: String, | ||||
|     val datetime: String | ||||
| ) | ||||
|   | ||||
| @@ -1,12 +0,0 @@ | ||||
| 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,8 +1,12 @@ | ||||
| package kr.co.vividnext.sodalive.admin.charge | ||||
|  | ||||
| data class GetChargeStatusResponse( | ||||
| import com.querydsl.core.annotations.QueryProjection | ||||
| import java.math.BigDecimal | ||||
|  | ||||
| data class GetChargeStatusResponse @QueryProjection constructor( | ||||
|     val date: String, | ||||
|     val chargeAmount: Int, | ||||
|     val chargeAmount: BigDecimal, | ||||
|     val chargeCount: Long, | ||||
|     val pg: String | ||||
|     val pg: String, | ||||
|     val currency: String | ||||
| ) | ||||
|   | ||||
| @@ -0,0 +1,229 @@ | ||||
| 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, "배너 정렬 순서가 성공적으로 변경되었습니다.") | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,32 @@ | ||||
| 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 | ||||
|         ) | ||||
|     ) | ||||
| } | ||||
| @@ -0,0 +1,139 @@ | ||||
| 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 | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,49 @@ | ||||
| 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) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,62 @@ | ||||
| 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() | ||||
|     ) | ||||
| } | ||||
| @@ -0,0 +1,423 @@ | ||||
| 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} 수정에 실패했습니다. 다시 시도해 주세요.") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,82 @@ | ||||
| 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)) | ||||
| } | ||||
| @@ -0,0 +1,45 @@ | ||||
| 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 | ||||
| ) | ||||
| @@ -0,0 +1,153 @@ | ||||
| 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 } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,132 @@ | ||||
| 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 | ||||
| ) | ||||
| @@ -0,0 +1,90 @@ | ||||
| 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 | ||||
| ) | ||||
| @@ -0,0 +1,62 @@ | ||||
| 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> | ||||
| ) | ||||
| @@ -0,0 +1,9 @@ | ||||
| package kr.co.vividnext.sodalive.admin.chat.character.dto | ||||
|  | ||||
| /** | ||||
|  * 캐릭터 검색 결과 페이지 응답 DTO | ||||
|  */ | ||||
| data class ChatCharacterSearchListPageResponse( | ||||
|     val totalCount: Long, | ||||
|     val content: List<ChatCharacterListResponse> | ||||
| ) | ||||
| @@ -0,0 +1,30 @@ | ||||
| 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> | ||||
| ) | ||||
| @@ -0,0 +1,170 @@ | ||||
| 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}") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,53 @@ | ||||
| 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 | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,78 @@ | ||||
| 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) } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,30 @@ | ||||
| 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> | ||||
| ) | ||||
| @@ -0,0 +1,32 @@ | ||||
| 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> | ||||
| ) | ||||
| @@ -0,0 +1,199 @@ | ||||
| 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}") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,95 @@ | ||||
| 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> | ||||
| ) | ||||
| @@ -0,0 +1,213 @@ | ||||
| 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,6 +140,7 @@ class AdminAudioContentQueryRepositoryImpl( | ||||
|                 audioContent.duration.isNotNull | ||||
|                     .and(audioContent.member.isNotNull) | ||||
|                     .and(audioContentHashTag.audioContent.id.eq(audioContentId)) | ||||
|                     .and(audioContentHashTag.isActive.isTrue) | ||||
|             ) | ||||
|             .fetch() | ||||
|     } | ||||
|   | ||||
| @@ -0,0 +1,45 @@ | ||||
| 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 | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| 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 | ||||
| ) | ||||
| @@ -0,0 +1,8 @@ | ||||
| package kr.co.vividnext.sodalive.admin.point | ||||
|  | ||||
| data class ModifyPointRewardPolicyRequest( | ||||
|     val title: String?, | ||||
|     val startDate: String?, | ||||
|     val endDate: String?, | ||||
|     val isActive: Boolean? | ||||
| ) | ||||
| @@ -0,0 +1,36 @@ | ||||
| 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)) | ||||
| } | ||||
| @@ -0,0 +1,59 @@ | ||||
| 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 | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,55 @@ | ||||
| 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,7 +3,6 @@ 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 | ||||
| @@ -67,7 +66,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { | ||||
|         val firstPaymentTotalAmount = CaseBuilder() | ||||
|             .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT)) | ||||
|             .then(adTrackingHistory.price) | ||||
|             .otherwise(Expressions.constant(0.0)) | ||||
|             .otherwise(0.toBigDecimal()) | ||||
|             .sum() | ||||
|  | ||||
|         val repeatPaymentCount = CaseBuilder() | ||||
| @@ -79,7 +78,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { | ||||
|         val repeatPaymentTotalAmount = CaseBuilder() | ||||
|             .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) | ||||
|             .then(adTrackingHistory.price) | ||||
|             .otherwise(Expressions.constant(0.0)) | ||||
|             .otherwise(0.toBigDecimal()) | ||||
|             .sum() | ||||
|  | ||||
|         val allPaymentCount = CaseBuilder() | ||||
| @@ -97,7 +96,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { | ||||
|                     .or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) | ||||
|             ) | ||||
|             .then(adTrackingHistory.price) | ||||
|             .otherwise(Expressions.constant(0.0)) | ||||
|             .otherwise(0.toBigDecimal()) | ||||
|             .sum() | ||||
|  | ||||
|         return queryFactory | ||||
| @@ -111,11 +110,11 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { | ||||
|                     loginCount, | ||||
|                     signUpCount, | ||||
|                     firstPaymentCount, | ||||
|                     roundedValueDecimalPlaces2(firstPaymentTotalAmount), | ||||
|                     firstPaymentTotalAmount, | ||||
|                     repeatPaymentCount, | ||||
|                     roundedValueDecimalPlaces2(repeatPaymentTotalAmount), | ||||
|                     repeatPaymentTotalAmount, | ||||
|                     allPaymentCount, | ||||
|                     roundedValueDecimalPlaces2(allPaymentTotalAmount) | ||||
|                     allPaymentTotalAmount | ||||
|                 ) | ||||
|             ) | ||||
|             .from(adTrackingHistory) | ||||
| @@ -148,13 +147,4 @@ 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,6 +1,7 @@ | ||||
| package kr.co.vividnext.sodalive.admin.statistics.ad | ||||
|  | ||||
| import com.querydsl.core.annotations.QueryProjection | ||||
| import java.math.BigDecimal | ||||
|  | ||||
| data class GetAdminAdStatisticsResponse( | ||||
|     val totalCount: Int, | ||||
| @@ -16,9 +17,9 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor( | ||||
|     val loginCount: Int, | ||||
|     val signUpCount: Int, | ||||
|     val firstPaymentCount: Int, | ||||
|     val firstPaymentTotalAmount: Double, | ||||
|     val firstPaymentTotalAmount: BigDecimal, | ||||
|     val repeatPaymentCount: Int, | ||||
|     val repeatPaymentTotalAmount: Double, | ||||
|     val repeatPaymentTotalAmount: BigDecimal, | ||||
|     val allPaymentCount: Int, | ||||
|     val allPaymentTotalAmount: Double | ||||
|     val allPaymentTotalAmount: BigDecimal | ||||
| ) | ||||
|   | ||||
| @@ -0,0 +1,30 @@ | ||||
| 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> | ||||
| ) | ||||
| @@ -0,0 +1,66 @@ | ||||
| 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 | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										269
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,269 @@ | ||||
| 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 | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,33 @@ | ||||
| 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 | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,94 @@ | ||||
| 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 | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| package kr.co.vividnext.sodalive.api.live | ||||
|  | ||||
| import kr.co.vividnext.sodalive.content.AudioContentMainItem | ||||
| import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse | ||||
| import kr.co.vividnext.sodalive.live.recommend.GetRecommendChannelResponse | ||||
| import kr.co.vividnext.sodalive.live.recommend.GetRecommendLiveResponse | ||||
| import kr.co.vividnext.sodalive.live.room.GetLatestFinishedLiveResponse | ||||
| import kr.co.vividnext.sodalive.live.room.GetRoomListResponse | ||||
|  | ||||
| data class LiveMainResponse( | ||||
|     val liveOnAirRoomList: List<GetRoomListResponse>, | ||||
|     val communityPostList: List<GetCommunityPostListResponse>, | ||||
|     val recommendLiveList: List<GetRecommendLiveResponse>, | ||||
|     val latestFinishedLiveList: List<GetLatestFinishedLiveResponse>, | ||||
|     val replayLive: List<AudioContentMainItem>, | ||||
|     val followingChannelList: List<GetRecommendChannelResponse>, | ||||
|     val liveReservationRoomList: List<GetRoomListResponse> | ||||
| ) | ||||
| @@ -12,6 +12,7 @@ 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( | ||||
| @@ -94,4 +95,27 @@ 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,4 +28,8 @@ class AuditionService( | ||||
|             roleList = roleList | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> { | ||||
|         return repository.getInProgressAuditionList(isAdult) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -49,10 +49,12 @@ 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 | ||||
|                 auditionApplicant.isActive.isTrue, | ||||
|                 member.isActive.isTrue | ||||
|             ) | ||||
|             .fetch() | ||||
|             .size | ||||
| @@ -87,7 +89,8 @@ class AuditionApplicantQueryRepositoryImpl( | ||||
|             .leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id)) | ||||
|             .where( | ||||
|                 auditionRole.id.eq(auditionRoleId), | ||||
|                 auditionApplicant.isActive.isTrue | ||||
|                 auditionApplicant.isActive.isTrue, | ||||
|                 member.isActive.isTrue | ||||
|             ) | ||||
|             .groupBy(auditionApplicant.id) | ||||
|             .orderBy(orderBy) | ||||
|   | ||||
| @@ -0,0 +1,48 @@ | ||||
| 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,6 +1,8 @@ | ||||
| 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 | ||||
| @@ -10,7 +12,10 @@ data class Can( | ||||
|     var title: String, | ||||
|     var can: Int, | ||||
|     var rewardCan: Int, | ||||
|     var price: Int, | ||||
|     @Column(precision = 10, scale = 4, nullable = false) | ||||
|     var price: BigDecimal, | ||||
|     @Column(length = 3, nullable = false, columnDefinition = "CHAR(3)") | ||||
|     var currency: String, | ||||
|     @Enumerated(value = EnumType.STRING) | ||||
|     var status: CanStatus | ||||
| ) : BaseEntity() | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| 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 | ||||
| @@ -9,13 +10,15 @@ 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(): ApiResponse<List<CanResponse>> { | ||||
|         return ApiResponse.ok(service.getCans()) | ||||
|     fun getCans(request: HttpServletRequest): ApiResponse<List<CanResponse>> { | ||||
|         val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER | ||||
|         return ApiResponse.ok(service.getCans(geoCountry)) | ||||
|     } | ||||
|  | ||||
|     @GetMapping("/status") | ||||
|   | ||||
| @@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository | ||||
| interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository | ||||
|  | ||||
| interface CanQueryRepository { | ||||
|     fun findAllByStatus(status: CanStatus): List<CanResponse> | ||||
|     fun findAllByStatusAndCurrency(status: CanStatus, currency: String): 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 findAllByStatus(status: CanStatus): List<CanResponse> { | ||||
|     override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> { | ||||
|         return queryFactory | ||||
|             .select( | ||||
|                 QCanResponse( | ||||
| @@ -40,11 +40,16 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue | ||||
|                     can1.title, | ||||
|                     can1.can, | ||||
|                     can1.rewardCan, | ||||
|                     can1.price | ||||
|                     can1.price.intValue(), | ||||
|                     can1.currency, | ||||
|                     can1.price.stringValue() | ||||
|                 ) | ||||
|             ) | ||||
|             .from(can1) | ||||
|             .where(can1.status.eq(status)) | ||||
|             .where( | ||||
|                 can1.status.eq(status), | ||||
|                 can1.currency.eq(currency) | ||||
|             ) | ||||
|             .orderBy(can1.can.asc()) | ||||
|             .fetch() | ||||
|     } | ||||
| @@ -64,11 +69,13 @@ 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,5 +7,7 @@ data class CanResponse @QueryProjection constructor( | ||||
|     val title: String, | ||||
|     val can: Int, | ||||
|     val rewardCan: Int, | ||||
|     val price: Int | ||||
|     val price: Int, | ||||
|     val currency: String, | ||||
|     val priceStr: String | ||||
| ) | ||||
|   | ||||
| @@ -3,6 +3,7 @@ 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 | ||||
| @@ -11,8 +12,12 @@ import java.time.format.DateTimeFormatter | ||||
|  | ||||
| @Service | ||||
| class CanService(private val repository: CanRepository) { | ||||
|     fun getCans(): List<CanResponse> { | ||||
|         return repository.findAllByStatus(status = CanStatus.SALE) | ||||
|     fun getCans(geoCountry: GeoCountry): List<CanResponse> { | ||||
|         val currency = when (geoCountry) { | ||||
|             GeoCountry.KR -> "KRW" | ||||
|             else -> "USD" | ||||
|         } | ||||
|         return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency) | ||||
|     } | ||||
|  | ||||
|     fun getCanStatus(member: Member, container: String): GetCanStatusResponse { | ||||
| @@ -35,6 +40,7 @@ class CanService(private val repository: CanRepository) { | ||||
|                     "aos" -> { | ||||
|                         it.useCanCalculates.any { useCanCalculate -> | ||||
|                             useCanCalculate.paymentGateway == PaymentGateway.PG || | ||||
|                                 useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE || | ||||
|                                 useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP | ||||
|                         } | ||||
|                     } | ||||
| @@ -42,12 +48,14 @@ 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.PG || | ||||
|                             useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| @@ -72,6 +80,10 @@ 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,7 +1,9 @@ | ||||
| package kr.co.vividnext.sodalive.can.charge | ||||
|  | ||||
| import java.math.BigDecimal | ||||
|  | ||||
| data class ChargeCompleteResponse( | ||||
|     val price: Double, | ||||
|     val price: BigDecimal, | ||||
|     val currencyCode: String, | ||||
|     val isFirstCharged: Boolean | ||||
| ) | ||||
|   | ||||
| @@ -6,20 +6,77 @@ 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 | ||||
|     private val trackingService: AdTrackingService, | ||||
|  | ||||
|     @Value("\${payverse.inbound-ip}") | ||||
|     private val payverseInboundIp: String | ||||
| ) { | ||||
|  | ||||
|     @PostMapping("/payverse") | ||||
|     fun payverseCharge( | ||||
|         @RequestBody request: PayverseChargeRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) { | ||||
|             throw SodaException("로그인 정보를 확인해주세요.") | ||||
|         } | ||||
|  | ||||
|         ApiResponse.ok(service.payverseCharge(member, request)) | ||||
|     } | ||||
|  | ||||
|     @PostMapping("/payverse/verify") | ||||
|     fun payverseVerify( | ||||
|         @RequestBody verifyRequest: PayverseVerifyRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) { | ||||
|             throw SodaException("로그인 정보를 확인해주세요.") | ||||
|         } | ||||
|  | ||||
|         val response = service.payverseVerify(memberId = member.id!!, verifyRequest) | ||||
|         trackingCharge(member, response) | ||||
|         ApiResponse.ok(Unit) | ||||
|     } | ||||
|  | ||||
|     // Payverse Webhook 엔드포인트 (payverseVerify 아래) | ||||
|     @PostMapping("/payverse/webhook") | ||||
|     fun payverseWebhook( | ||||
|         @RequestBody request: PayverseWebhookRequest, | ||||
|         servletRequest: HttpServletRequest | ||||
|     ): PayverseWebhookResponse { | ||||
|         val header = servletRequest.getHeader("X-Forwarded-For") | ||||
|         val remoteIp = if (header.isNullOrEmpty()) { | ||||
|             servletRequest.remoteAddr | ||||
|         } else { | ||||
|             header.split(",")[0].trim() // 첫 번째 값이 클라이언트 IP | ||||
|         } | ||||
|  | ||||
|         if (remoteIp != payverseInboundIp) { | ||||
|             throw ResponseStatusException(HttpStatus.NOT_FOUND) | ||||
|         } | ||||
|  | ||||
|         val success = service.payverseWebhook(request) | ||||
|         if (!success) { | ||||
|             throw ResponseStatusException(HttpStatus.NOT_FOUND) | ||||
|         } | ||||
|         return PayverseWebhookResponse(receiveResult = "SUCCESS") | ||||
|     } | ||||
|  | ||||
|     @PostMapping | ||||
|     fun charge( | ||||
|         @RequestBody chargeRequest: ChargeRequest, | ||||
| @@ -111,8 +168,7 @@ class ChargeController( | ||||
|                 memberId = member.id!!, | ||||
|                 chargeId = chargeId, | ||||
|                 productId = request.productId, | ||||
|                 purchaseToken = request.purchaseToken, | ||||
|                 paymentGateway = request.paymentGateway | ||||
|                 purchaseToken = request.purchaseToken | ||||
|             ) | ||||
|  | ||||
|             trackingCharge(member, response) | ||||
|   | ||||
| @@ -2,6 +2,7 @@ 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) | ||||
|  | ||||
| @@ -20,14 +21,14 @@ data class VerifyResult( | ||||
|     val method: String, | ||||
|     val pg: String, | ||||
|     val status: Int, | ||||
|     val price: Int | ||||
|     val price: BigDecimal | ||||
| ) | ||||
|  | ||||
| data class AppleChargeRequest( | ||||
|     val title: String, | ||||
|     val chargeCan: Int, | ||||
|     val paymentGateway: PaymentGateway, | ||||
|     var price: Double? = null, | ||||
|     var price: BigDecimal? = null, | ||||
|     var locale: String? = null | ||||
| ) | ||||
|  | ||||
| @@ -38,9 +39,53 @@ data class AppleVerifyResponse(val status: Int) | ||||
| data class GoogleChargeRequest( | ||||
|     val title: String, | ||||
|     val chargeCan: Int, | ||||
|     val price: Double, | ||||
|     val price: BigDecimal, | ||||
|     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,15 +113,18 @@ 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,6 +5,7 @@ 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 | ||||
| @@ -12,10 +13,16 @@ 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 | ||||
| @@ -27,6 +34,8 @@ 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) | ||||
| @@ -36,6 +45,9 @@ 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, | ||||
| @@ -53,34 +65,341 @@ class ChargeService( | ||||
|     @Value("\${apple.iap-verify-sandbox-url}") | ||||
|     private val appleInAppVerifySandBoxUrl: String, | ||||
|     @Value("\${apple.iap-verify-url}") | ||||
|     private val appleInAppVerifyUrl: String | ||||
|     private val appleInAppVerifyUrl: String, | ||||
|  | ||||
|     @Value("\${payverse.mid}") | ||||
|     private val payverseMid: String, | ||||
|     @Value("\${payverse.client-key}") | ||||
|     private val payverseClientKey: String, | ||||
|     @Value("\${payverse.secret-key}") | ||||
|     private val payverseSecretKey: String, | ||||
|  | ||||
|     @Value("\${payverse.usd-mid}") | ||||
|     private val payverseUsdMid: String, | ||||
|     @Value("\${payverse.usd-client-key}") | ||||
|     private val payverseUsdClientKey: String, | ||||
|     @Value("\${payverse.usd-secret-key}") | ||||
|     private val payverseUsdSecretKey: String, | ||||
|  | ||||
|     @Value("\${payverse.host}") | ||||
|     private val payverseHost: String, | ||||
|  | ||||
|     @Value("\${server.env}") | ||||
|     private val serverEnv: String | ||||
| ) { | ||||
|  | ||||
|     @Transactional | ||||
|     fun chargeByCoupon(couponNumber: String, member: Member) { | ||||
|     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 { | ||||
|         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 | ||||
|  | ||||
|         val payment = Payment( | ||||
|             status = PaymentStatus.COMPLETE, | ||||
|             paymentGateway = PaymentGateway.PG | ||||
|         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() | ||||
|         ) | ||||
|         payment.method = coupon.couponName | ||||
|         couponCharge.payment = payment | ||||
|         chargeRepository.save(couponCharge) | ||||
|         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!!}" | ||||
|  | ||||
|         member.charge(0, coupon.can, "pg") | ||||
|         val payload = linkedMapOf( | ||||
|             "mid" to mid, | ||||
|             "clientKey" to clientKey, | ||||
|             "orderId" to chargeId.toString(), | ||||
|             "customerId" to customerId, | ||||
|             "productName" to can.title, | ||||
|             "requestCurrency" to requestCurrency, | ||||
|             "requestAmount" to amount, | ||||
|             "reqDate" to reqDate, | ||||
|             "sign" to sign | ||||
|         ) | ||||
|         val payloadJson = objectMapper.writeValueAsString(payload) | ||||
|  | ||||
|         return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson) | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse { | ||||
|         val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) | ||||
|             ?: throw SodaException("결제정보에 오류가 있습니다.") | ||||
|         val member = memberRepository.findByIdOrNull(memberId) | ||||
|             ?: throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         val isKrw = charge.can?.currency == "KRW" | ||||
|         val mid = if (isKrw) { | ||||
|             payverseMid | ||||
|         } else { | ||||
|             payverseUsdMid | ||||
|         } | ||||
|         val clientKey = if (isKrw) { | ||||
|             payverseClientKey | ||||
|         } else { | ||||
|             payverseUsdClientKey | ||||
|         } | ||||
|  | ||||
|         // 결제수단 확인 | ||||
|         if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) { | ||||
|             throw SodaException("결제정보에 오류가 있습니다.") | ||||
|         } | ||||
|  | ||||
|         // 결제 상태에 따른 분기 처리 | ||||
|         when (charge.payment?.status) { | ||||
|             PaymentStatus.REQUEST -> { | ||||
|                 try { | ||||
|                     val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}" | ||||
|                     val request = Request.Builder() | ||||
|                         .url(url) | ||||
|                         .addHeader("mid", mid) | ||||
|                         .addHeader("clientKey", clientKey) | ||||
|                         .get() | ||||
|                         .build() | ||||
|  | ||||
|                     val response = okHttpClient.newCall(request).execute() | ||||
|                     if (!response.isSuccessful) { | ||||
|                         throw SodaException("결제정보에 오류가 있습니다.") | ||||
|                     } | ||||
|  | ||||
|                     val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.") | ||||
|                     val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java) | ||||
|  | ||||
|                     val customerId = "${serverEnv}_user_${member.id!!}" | ||||
|                     val isSuccess = verifyResponse.resultStatus == "SUCCESS" && | ||||
|                         verifyResponse.transactionStatus == "SUCCESS" && | ||||
|                         verifyResponse.orderId.toLongOrNull() == charge.id && | ||||
|                         verifyResponse.customerId == customerId && | ||||
|                         verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0 | ||||
|  | ||||
|                     if (isSuccess) { | ||||
|                         // verify 함수의 232~248 라인과 동일 처리 | ||||
|                         charge.payment?.receiptId = verifyResponse.tid | ||||
|                         val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") { | ||||
|                             mapPayverseSchemeToMethodByCode(verifyResponse.schemeCode) | ||||
|                         } else { | ||||
|                             null | ||||
|                         } | ||||
|                         charge.payment?.method = mappedMethod ?: verifyResponse.schemeCode | ||||
|                         charge.payment?.status = PaymentStatus.COMPLETE | ||||
|                         // 통화코드 설정 | ||||
|                         charge.payment?.locale = verifyResponse.requestCurrency | ||||
|  | ||||
|                         member.charge(charge.chargeCan, charge.rewardCan, "pg") | ||||
|  | ||||
|                         applicationEventPublisher.publishEvent( | ||||
|                             ChargeSpringEvent( | ||||
|                                 chargeId = charge.id!!, | ||||
|                                 memberId = member.id!! | ||||
|                             ) | ||||
|                         ) | ||||
|  | ||||
|                         return ChargeCompleteResponse( | ||||
|                             price = charge.payment!!.price, | ||||
|                             currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", | ||||
|                             isFirstCharged = chargeRepository.isFirstCharged(memberId) | ||||
|                         ) | ||||
|                     } else { | ||||
|                         throw SodaException("결제정보에 오류가 있습니다.") | ||||
|                     } | ||||
|                 } catch (_: Exception) { | ||||
|                     throw SodaException("결제정보에 오류가 있습니다.") | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             PaymentStatus.COMPLETE -> { | ||||
|                 // 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환 | ||||
|                 return ChargeCompleteResponse( | ||||
|                     price = charge.payment!!.price, | ||||
|                     currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", | ||||
|                     isFirstCharged = chargeRepository.isFirstCharged(memberId) | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             else -> { | ||||
|                 throw SodaException("결제정보에 오류가 있습니다.") | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
| @@ -94,7 +413,7 @@ class ChargeService( | ||||
|         charge.can = can | ||||
|  | ||||
|         val payment = Payment(paymentGateway = request.paymentGateway) | ||||
|         payment.price = can.price.toDouble() | ||||
|         payment.price = can.price | ||||
|         charge.payment = payment | ||||
|  | ||||
|         chargeRepository.save(charge) | ||||
| @@ -133,14 +452,14 @@ class ChargeService( | ||||
|                     ) | ||||
|  | ||||
|                     return ChargeCompleteResponse( | ||||
|                         price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), | ||||
|                         price = charge.payment!!.price, | ||||
|                         currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", | ||||
|                         isFirstCharged = chargeRepository.isFirstCharged(memberId) | ||||
|                     ) | ||||
|                 } else { | ||||
|                     throw SodaException("결제정보에 오류가 있습니다.") | ||||
|                 } | ||||
|             } catch (e: Exception) { | ||||
|             } catch (_: Exception) { | ||||
|                 throw SodaException("결제정보에 오류가 있습니다.") | ||||
|             } | ||||
|         } else { | ||||
| @@ -165,7 +484,7 @@ class ChargeService( | ||||
|                     VerifyResult::class.java | ||||
|                 ) | ||||
|  | ||||
|                 if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) { | ||||
|                 if (verifyResult.status == 1) { | ||||
|                     charge.payment?.receiptId = verifyResult.receiptId | ||||
|                     charge.payment?.method = if (verifyResult.pg.contains("카카오")) { | ||||
|                         "${verifyResult.pg}-${verifyResult.method}" | ||||
| @@ -183,14 +502,14 @@ class ChargeService( | ||||
|                     ) | ||||
|  | ||||
|                     return ChargeCompleteResponse( | ||||
|                         price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), | ||||
|                         price = charge.payment!!.price, | ||||
|                         currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", | ||||
|                         isFirstCharged = chargeRepository.isFirstCharged(memberId) | ||||
|                     ) | ||||
|                 } else { | ||||
|                     throw SodaException("결제정보에 오류가 있습니다.") | ||||
|                 } | ||||
|             } catch (e: Exception) { | ||||
|             } catch (_: Exception) { | ||||
|                 throw SodaException("결제정보에 오류가 있습니다.") | ||||
|             } | ||||
|         } else { | ||||
| @@ -208,7 +527,7 @@ class ChargeService( | ||||
|         payment.price = if (request.price != null) { | ||||
|             request.price!! | ||||
|         } else { | ||||
|             0.toDouble() | ||||
|             0.toBigDecimal() | ||||
|         } | ||||
|  | ||||
|         payment.locale = request.locale | ||||
| @@ -243,7 +562,7 @@ class ChargeService( | ||||
|                 ) | ||||
|  | ||||
|                 return ChargeCompleteResponse( | ||||
|                     price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), | ||||
|                     price = charge.payment!!.price, | ||||
|                     currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", | ||||
|                     isFirstCharged = chargeRepository.isFirstCharged(memberId) | ||||
|                 ) | ||||
| @@ -260,7 +579,7 @@ class ChargeService( | ||||
|         member: Member, | ||||
|         title: String, | ||||
|         chargeCan: Int, | ||||
|         price: Double, | ||||
|         price: BigDecimal, | ||||
|         currencyCode: String, | ||||
|         productId: String, | ||||
|         purchaseToken: String, | ||||
| @@ -288,8 +607,7 @@ class ChargeService( | ||||
|         memberId: Long, | ||||
|         chargeId: Long, | ||||
|         productId: String, | ||||
|         purchaseToken: String, | ||||
|         paymentGateway: PaymentGateway | ||||
|         purchaseToken: String | ||||
|     ): ChargeCompleteResponse { | ||||
|         val charge = chargeRepository.findByIdOrNull(id = chargeId) | ||||
|             ?: throw SodaException("결제정보에 오류가 있습니다.") | ||||
| @@ -311,7 +629,7 @@ class ChargeService( | ||||
|                 ) | ||||
|  | ||||
|                 return ChargeCompleteResponse( | ||||
|                     price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), | ||||
|                     price = charge.payment!!.price, | ||||
|                     currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", | ||||
|                     isFirstCharged = chargeRepository.isFirstCharged(memberId) | ||||
|                 ) | ||||
| @@ -393,4 +711,13 @@ 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,9 +1,10 @@ | ||||
| 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: Int, | ||||
|     val price: BigDecimal, | ||||
|     val paymentGateway: PaymentGateway | ||||
| ) | ||||
|   | ||||
| @@ -41,7 +41,7 @@ class ChargeTempService( | ||||
|         charge.member = member | ||||
|  | ||||
|         val payment = Payment(paymentGateway = request.paymentGateway) | ||||
|         payment.price = request.price.toDouble() | ||||
|         payment.price = request.price | ||||
|         charge.payment = payment | ||||
|  | ||||
|         chargeRepository.save(charge) | ||||
| @@ -66,7 +66,7 @@ class ChargeTempService( | ||||
|                     VerifyResult::class.java | ||||
|                 ) | ||||
|  | ||||
|                 if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) { | ||||
|                 if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price) { | ||||
|                     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 (e: Exception) { | ||||
|             } catch (_: Exception) { | ||||
|                 throw SodaException("결제정보에 오류가 있습니다.") | ||||
|             } | ||||
|         } else { | ||||
|   | ||||
| @@ -3,13 +3,21 @@ 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("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok( | ||||
|             service.useCanCoupon( | ||||
|                 couponNumber = request.couponNumber, | ||||
|                 memberId = member.id!! | ||||
|             ) | ||||
|         val completeMessage = service.useCanCoupon( | ||||
|             couponNumber = request.couponNumber, | ||||
|             memberId = member.id!! | ||||
|         ) | ||||
|  | ||||
|         ApiResponse.ok(Unit, completeMessage) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -79,6 +79,7 @@ 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,5 +1,6 @@ | ||||
| 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 | ||||
| @@ -30,6 +31,9 @@ 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,15 +68,12 @@ 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) | ||||
|     } | ||||
| @@ -124,7 +121,7 @@ class CanCouponService( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun useCanCoupon(couponNumber: String, memberId: Long) { | ||||
|     fun useCanCoupon(couponNumber: String, memberId: Long): String { | ||||
|         val member = memberRepository.findByIdOrNull(id = memberId) | ||||
|             ?: throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
| @@ -132,7 +129,7 @@ class CanCouponService( | ||||
|  | ||||
|         issueService.validateAvailableUseCoupon(couponNumber, memberId) | ||||
|  | ||||
|         chargeService.chargeByCoupon(couponNumber, member) | ||||
|         return chargeService.chargeByCoupon(couponNumber, member) | ||||
|     } | ||||
|  | ||||
|     private fun insertHyphens(input: String): String { | ||||
|   | ||||
| @@ -4,6 +4,7 @@ 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,6 +10,7 @@ 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,6 +13,7 @@ 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 | ||||
| @@ -37,6 +38,8 @@ class CanPaymentService( | ||||
|         memberId: Long, | ||||
|         needCan: Int, | ||||
|         canUsage: CanUsage, | ||||
|         chatRoomId: Long? = null, | ||||
|         characterId: Long? = null, | ||||
|         isSecret: Boolean = false, | ||||
|         liveRoom: LiveRoom? = null, | ||||
|         order: Order? = null, | ||||
| @@ -109,6 +112,14 @@ 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("잘못된 요청입니다.") | ||||
|         } | ||||
| @@ -116,6 +127,7 @@ class CanPaymentService( | ||||
|         useCanRepository.save(useCan) | ||||
|  | ||||
|         setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG) | ||||
|         setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE) | ||||
|         setUseCanCalculate( | ||||
|             recipientId, | ||||
|             useRewardCan, | ||||
| @@ -327,4 +339,100 @@ 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,6 +2,7 @@ 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 | ||||
| @@ -25,7 +26,8 @@ data class Payment( | ||||
|     var receiptId: String? = null | ||||
|     var method: String? = null | ||||
|  | ||||
|     var price: Double = 0.toDouble() | ||||
|     @Column(precision = 10, scale = 4, nullable = false) | ||||
|     var price: BigDecimal = 0.toBigDecimal() | ||||
|     var locale: String? = null | ||||
|     var orderId: String? = null | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| package kr.co.vividnext.sodalive.can.payment | ||||
|  | ||||
| enum class PaymentGateway { | ||||
|     PG, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD | ||||
|     PG, PAYVERSE, GOOGLE_IAP, APPLE_IAP, POINT_CLICK_AD | ||||
| } | ||||
|   | ||||
| @@ -9,5 +9,9 @@ enum class CanUsage { | ||||
|     SPIN_ROULETTE, | ||||
|     PAID_COMMUNITY_POST, | ||||
|     ALARM_SLOT, | ||||
|     AUDITION_VOTE | ||||
|     AUDITION_VOTE, | ||||
|     CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) | ||||
|     CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 | ||||
|     CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전 | ||||
|     CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분) | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| 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 | ||||
| @@ -28,7 +30,11 @@ data class UseCan( | ||||
|  | ||||
|     var isRefund: Boolean = false, | ||||
|  | ||||
|     val isSecret: Boolean = false | ||||
|     val isSecret: Boolean = false, | ||||
|  | ||||
|     // 채팅 연동을 위한 식별자 (옵션) | ||||
|     var chatRoomId: Long? = null, | ||||
|     var characterId: Long? = null | ||||
| ) : BaseEntity() { | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "member_id", nullable = false) | ||||
| @@ -58,6 +64,16 @@ 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,23 +6,56 @@ import org.springframework.data.jpa.repository.JpaRepository | ||||
| import org.springframework.stereotype.Repository | ||||
|  | ||||
| @Repository | ||||
| interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository | ||||
| interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository { | ||||
|     // 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외) | ||||
|     fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn( | ||||
|         memberId: Long, | ||||
|         imageId: Long, | ||||
|         usages: Collection<CanUsage> | ||||
|     ): Boolean | ||||
| } | ||||
|  | ||||
| interface UseCanQueryRepository { | ||||
|     fun isExistOrdered(postId: Long, memberId: Long): Boolean | ||||
|     fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean | ||||
|     fun countPurchasedActiveImagesByCharacter( | ||||
|         memberId: Long, | ||||
|         characterId: Long, | ||||
|         usages: Collection<CanUsage> | ||||
|     ): Long | ||||
| } | ||||
|  | ||||
| class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository { | ||||
|     override fun isExistOrdered(postId: Long, memberId: Long): Boolean { | ||||
|     override fun isExistCommunityPostOrdered(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() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,163 @@ | ||||
| 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 | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| 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() | ||||
| @@ -0,0 +1,29 @@ | ||||
| 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() | ||||
| @@ -0,0 +1,22 @@ | ||||
| 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() | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| package kr.co.vividnext.sodalive.chat.character | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Entity | ||||
| import javax.persistence.FetchType | ||||
| import javax.persistence.JoinColumn | ||||
| import javax.persistence.ManyToOne | ||||
|  | ||||
| /** | ||||
|  * ChatCharacter와 ChatCharacterGoal 간의 매핑 엔티티 | ||||
|  * ChatCharacterGoal의 중복을 방지하기 위한 매핑 테이블 | ||||
|  */ | ||||
| @Entity | ||||
| class ChatCharacterGoalMapping( | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "chat_character_id") | ||||
|     val chatCharacter: ChatCharacter, | ||||
|  | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "goal_id") | ||||
|     val goal: ChatCharacterGoal | ||||
| ) : BaseEntity() | ||||
| @@ -0,0 +1,22 @@ | ||||
| package kr.co.vividnext.sodalive.chat.character | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Column | ||||
| import javax.persistence.Entity | ||||
| import javax.persistence.OneToMany | ||||
| import javax.persistence.Table | ||||
| import javax.persistence.UniqueConstraint | ||||
|  | ||||
| /** | ||||
|  * 캐릭터 취미 | ||||
|  */ | ||||
|  | ||||
| @Entity | ||||
| @Table(uniqueConstraints = [UniqueConstraint(columnNames = ["hobby"])]) | ||||
| class ChatCharacterHobby( | ||||
|     @Column(nullable = false) | ||||
|     val hobby: String | ||||
| ) : BaseEntity() { | ||||
|     @OneToMany(mappedBy = "hobby") | ||||
|     var hobbyMappings: MutableList<ChatCharacterHobbyMapping> = mutableListOf() | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| package kr.co.vividnext.sodalive.chat.character | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Entity | ||||
| import javax.persistence.FetchType | ||||
| import javax.persistence.JoinColumn | ||||
| import javax.persistence.ManyToOne | ||||
|  | ||||
| /** | ||||
|  * ChatCharacter와 ChatCharacterHobby 간의 매핑 엔티티 | ||||
|  * ChatCharacterHobby의 중복을 방지하기 위한 매핑 테이블 | ||||
|  */ | ||||
| @Entity | ||||
| class ChatCharacterHobbyMapping( | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "chat_character_id") | ||||
|     val chatCharacter: ChatCharacter, | ||||
|  | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "hobby_id") | ||||
|     val hobby: ChatCharacterHobby | ||||
| ) : BaseEntity() | ||||
| @@ -0,0 +1,29 @@ | ||||
| package kr.co.vividnext.sodalive.chat.character | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Column | ||||
| import javax.persistence.Entity | ||||
| import javax.persistence.FetchType | ||||
| import javax.persistence.JoinColumn | ||||
| import javax.persistence.ManyToOne | ||||
|  | ||||
| /** | ||||
|  * 캐릭터 기억 | ||||
|  */ | ||||
|  | ||||
| @Entity | ||||
| class ChatCharacterMemory( | ||||
|     // 기억 제목 | ||||
|     val title: String, | ||||
|  | ||||
|     // 기억 내용 | ||||
|     @Column(columnDefinition = "TEXT", nullable = false) | ||||
|     var content: String, | ||||
|  | ||||
|     // 감정 | ||||
|     var emotion: String, | ||||
|  | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "chat_character_id") | ||||
|     val chatCharacter: ChatCharacter | ||||
| ) : BaseEntity() | ||||
| @@ -0,0 +1,26 @@ | ||||
| package kr.co.vividnext.sodalive.chat.character | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Column | ||||
| import javax.persistence.Entity | ||||
| import javax.persistence.FetchType | ||||
| import javax.persistence.JoinColumn | ||||
| import javax.persistence.ManyToOne | ||||
|  | ||||
| /** | ||||
|  * 캐릭터 성격 특성 | ||||
|  */ | ||||
|  | ||||
| @Entity | ||||
| class ChatCharacterPersonality( | ||||
|     // 성격 특성 | ||||
|     val trait: String, | ||||
|  | ||||
|     // 성격 특성 설명 | ||||
|     @Column(columnDefinition = "TEXT", nullable = false) | ||||
|     var description: String, | ||||
|  | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "chat_character_id") | ||||
|     val chatCharacter: ChatCharacter | ||||
| ) : BaseEntity() | ||||
| @@ -0,0 +1,33 @@ | ||||
| package kr.co.vividnext.sodalive.chat.character | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Column | ||||
| import javax.persistence.Entity | ||||
| import javax.persistence.FetchType | ||||
| import javax.persistence.JoinColumn | ||||
| import javax.persistence.ManyToOne | ||||
|  | ||||
| /** | ||||
|  * 캐릭터 관계 | ||||
|  */ | ||||
|  | ||||
| @Entity | ||||
| class ChatCharacterRelationship( | ||||
|     // 상대 인물 이름 | ||||
|     var personName: String, | ||||
|     // 관계명 (예: 친구, 동료 등) | ||||
|     var relationshipName: String, | ||||
|     // 관계 설명 | ||||
|     @Column(columnDefinition = "TEXT") | ||||
|     var description: String, | ||||
|     // 중요도 | ||||
|     var importance: Int, | ||||
|     // 관계 타입 (분류용) | ||||
|     var relationshipType: String, | ||||
|     // 현재 상태 | ||||
|     var currentStatus: String, | ||||
|  | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "chat_character_id") | ||||
|     val chatCharacter: ChatCharacter | ||||
| ) : BaseEntity() | ||||
| @@ -0,0 +1,22 @@ | ||||
| package kr.co.vividnext.sodalive.chat.character | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Column | ||||
| import javax.persistence.Entity | ||||
| import javax.persistence.OneToMany | ||||
| import javax.persistence.Table | ||||
| import javax.persistence.UniqueConstraint | ||||
|  | ||||
| /** | ||||
|  * 캐릭터 태그 | ||||
|  */ | ||||
|  | ||||
| @Entity | ||||
| @Table(uniqueConstraints = [UniqueConstraint(columnNames = ["tag"])]) | ||||
| class ChatCharacterTag( | ||||
|     @Column(nullable = false) | ||||
|     val tag: String | ||||
| ) : BaseEntity() { | ||||
|     @OneToMany(mappedBy = "tag") | ||||
|     var tagMappings: MutableList<ChatCharacterTagMapping> = mutableListOf() | ||||
| } | ||||
| @@ -0,0 +1,22 @@ | ||||
| package kr.co.vividnext.sodalive.chat.character | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Entity | ||||
| import javax.persistence.FetchType | ||||
| import javax.persistence.JoinColumn | ||||
| import javax.persistence.ManyToOne | ||||
|  | ||||
| /** | ||||
|  * ChatCharacter와 ChatCharacterTag 간의 매핑 엔티티 | ||||
|  * ChatCharacterTag의 중복을 방지하기 위한 매핑 테이블 | ||||
|  */ | ||||
| @Entity | ||||
| class ChatCharacterTagMapping( | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "chat_character_id") | ||||
|     val chatCharacter: ChatCharacter, | ||||
|  | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "tag_id") | ||||
|     val tag: ChatCharacterTag | ||||
| ) : BaseEntity() | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user