Compare commits
	
		
			383 Commits
		
	
	
		
			3c616474ff
			...
			test
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a05ada5df0 | |||
| 34480385d3 | |||
| fd68ed87a3 | |||
| 779fc5c5a5 | |||
| 08ebb311fb | |||
| 12cdd25be7 | |||
| 59700493eb | |||
| 88c3a84972 | |||
| db0d3a6ef3 | |||
| 3d29d27441 | |||
| b5f66603bd | |||
| 976eeaa443 | |||
| 25d1d813f1 | |||
| 778f0c3ba2 | |||
| 38c50a4f8a | |||
| c497f321bb | |||
| 84c0768c8b | |||
| efb8d8115f | |||
| 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 | |||
| a6dfa81ba6 | |||
| dad517a953 | |||
| eb2d093b02 | |||
| 67186bba55 | |||
| edeecad2ce | |||
| 387f5388d9 | |||
| adcaa0a5fd | |||
| 47b2c1cb93 | |||
| 7f3589dcfb | |||
| b134c28c10 | |||
| 41c8d0367d | |||
| 3b148d549e | |||
| b6c96af8a2 | |||
| 4904625488 | |||
| 0574f4f629 | |||
| 4adc3e127c | |||
| dd0a1c2293 | |||
| a07407417c | |||
| e33e3b43b7 | |||
| 634bf759ca | |||
| 0ed29c6097 | |||
| b752434fbb | |||
| eec63cc7b2 | |||
| 3dc9dd1f35 | |||
| 88e287067b | |||
| 27a3f450ef | |||
| 58a46a09c3 | |||
| 83a1316a64 | |||
| f05f146c89 | |||
| 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 | |||
| 2659adb7a9 | |||
| fcb2ca1917 | |||
| 804e139385 | |||
| f0fc996426 | |||
| efdb485a3b | |||
| 3d695069a2 | |||
| e068b57062 | |||
| 811810cd36 | |||
| c90df4b02b | |||
| 7c1082f833 | |||
| 800b8d3216 | |||
| ab877beae1 | |||
| 046c163e6f | |||
| 8e877a6366 | |||
| d18c19dd35 | |||
| a99260209b | |||
| 2192ddc8fa | |||
| 741a1282a3 | |||
| 1a6a331ad8 | |||
| 1ba63e2cab | |||
| 5696240e03 | |||
| 885243a5b0 | |||
| a849d00c7f | |||
| d04b44c931 | |||
| a3aad9d2c9 | |||
| d98268f809 | |||
| 34440e9ba3 | |||
| d1c889e5f2 | |||
| 55da259510 | |||
| 4436e6f20a | |||
| 3cedd36e15 | |||
| ecbe9b2e93 | |||
| 9ad6b6ea48 | |||
| 0d2daf4d2c | |||
| edf16a6021 | |||
| 7551a19b34 | |||
| f59f45d9a4 | |||
| 81e82ad731 | |||
| ca870392e2 | |||
| a7e167a95f | |||
| a49b82a7c2 | |||
| 704ad12ccf | |||
| ab9fd2bc16 | |||
| 69a63a77d3 | |||
| da7e4c2156 | |||
| a4b5185f6b | |||
| 22fc8b22b8 | |||
| a8da17162a | |||
| f13c221fd6 | |||
| 4ffa9363a8 | |||
| 6d2f48f86d | |||
| 8e01ced1f5 | |||
| 640f5ce6f5 | |||
| c0be30027c | |||
| 832586bd41 | |||
| 1a774937b3 | |||
| e508dafb34 | |||
| 8335717741 | |||
| 16a2b82ffd | |||
| 8db5c6443d | |||
| 9ed717fb95 | |||
| dcd4497315 | |||
| 54c0322398 | |||
| e3c33c71a0 | |||
| 7055bb9872 | |||
| fd1b17e356 | |||
| 28427a873a | |||
| 5bdb101b52 | |||
| 97b2b38f8e | |||
| 2268f4a3fc | |||
| 9eff828249 | |||
| 3275ac5036 | |||
| e049e0fa3c | |||
| caee89cf53 | |||
| e67b798714 | |||
| dc13053825 | |||
| af352256e9 | |||
| b92810efd2 | |||
| fcbd809691 | |||
| d3ec13e6c0 | |||
| a36d9f02d8 | |||
| d6db862c9d | |||
| 56542a7bf1 | |||
| 36b8e8169e | |||
| b102241efd | |||
| f36010fefa | |||
| aa23d6d50f | |||
| 6df043dfac | |||
| fe84292483 | |||
| 0f48c71837 | |||
| 107e8fce55 | |||
| 3079998a5d | |||
| e2d0ae558a | |||
| 1bca1b27ed | |||
| 6fc372c898 | |||
| ddcd54d3b9 | |||
| eb8c8c14e8 | |||
| affc0cc235 | |||
| f23251f5bb | |||
| 73c9a90ae3 | |||
| ced35af66d | |||
| b915ace6ff | |||
| 2fd7419bdd | |||
| fd510710d9 | |||
| 8a924bd5be | |||
| 73edc0515f | |||
| 7870f8ea78 | |||
| 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 | |||
| 7649ce6e52 | |||
| 5759a51017 | |||
| dd5c121f1f | |||
| cae3a92a66 | |||
| 562550880c | |||
| a9c68f9971 | |||
| d822a4a8ac | |||
| e52c914000 | |||
| a301f854ba | |||
| 602d9625e2 | |||
| 5598bca8d3 | |||
| 1bbaf8f7b7 | |||
| 3bb2753607 | |||
| 08848c783d | |||
| 6e229af790 | |||
| ce8cc3eb29 | |||
| 198ecddc89 | |||
| ae439b7e64 | |||
| 3f1101ff73 | |||
| 5777d9700f | |||
| e1e9f4588a | |||
| be2f013b9a | |||
| 0b03ebeb70 | |||
| c466ecb77c | |||
| ba9c71a4ec | |||
| e33050a6d6 | |||
| 3595c02e74 | |||
| 3ff84074bd | |||
| 6dd6be183b | |||
| 0764247447 | |||
| f9f9b9aab9 | |||
| ec0252bae0 | |||
| dc74d203bd | |||
| 387d364861 | |||
| 82afdecf6c | |||
| 519c63a023 | |||
| d45a25258e | |||
| bc822355df | |||
| 9535ff18de | |||
| da0a83bb6d | |||
| 4977ee99df | |||
| 9ed031e574 | 
| @@ -7,5 +7,5 @@ indent_size = 4 | |||||||
| indent_style = space | indent_style = space | ||||||
| trim_trailing_whitespace = true | trim_trailing_whitespace = true | ||||||
| insert_final_newline = true | insert_final_newline = true | ||||||
| max_line_length = 120 | max_line_length = 130 | ||||||
| tab_width = 4 | tab_width = 4 | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -323,4 +323,7 @@ gradle-app.setting | |||||||
| ### Gradle Patch ### | ### Gradle Patch ### | ||||||
| **/build/ | **/build/ | ||||||
|  |  | ||||||
|  | .kiro/ | ||||||
|  | .junie | ||||||
|  |  | ||||||
| # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle | ||||||
|   | |||||||
| @@ -65,9 +65,14 @@ dependencies { | |||||||
|     // android publisher |     // android publisher | ||||||
|     implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0") |     implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0") | ||||||
|  |  | ||||||
|  |     implementation("com.google.api-client:google-api-client:1.32.1") | ||||||
|  |  | ||||||
|     implementation("org.apache.poi:poi-ooxml:5.2.3") |     implementation("org.apache.poi:poi-ooxml:5.2.3") | ||||||
|     implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") |     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") |     developmentOnly("org.springframework.boot:spring-boot-devtools") | ||||||
|     runtimeOnly("com.h2database:h2") |     runtimeOnly("com.h2database:h2") | ||||||
|     runtimeOnly("com.mysql:mysql-connector-j") |     runtimeOnly("com.mysql:mysql-connector-j") | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| package kr.co.vividnext.sodalive.admin.calculate | 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.DateTimePath | ||||||
| import com.querydsl.core.types.dsl.Expressions | import com.querydsl.core.types.dsl.Expressions | ||||||
| import com.querydsl.core.types.dsl.StringTemplate | import com.querydsl.core.types.dsl.StringTemplate | ||||||
| @@ -38,7 +39,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             .innerJoin(useCan.room, liveRoom) |             .innerJoin(useCan.room, liveRoom) | ||||||
|             .innerJoin(liveRoom.member, member) |             .innerJoin(liveRoom.member, member) | ||||||
|             .leftJoin(creatorSettlementRatio) |             .leftJoin(creatorSettlementRatio) | ||||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) |             .on( | ||||||
|  |                 member.id.eq(creatorSettlementRatio.member.id) | ||||||
|  |                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||||
|  |             ) | ||||||
|             .where( |             .where( | ||||||
|                 useCan.isRefund.isFalse |                 useCan.isRefund.isFalse | ||||||
|                     .and(useCan.createdAt.goe(startDate)) |                     .and(useCan.createdAt.goe(startDate)) | ||||||
| @@ -51,6 +55,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|  |  | ||||||
|     fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> { |     fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> { | ||||||
|         val orderFormattedDate = getFormattedDate(order.createdAt) |         val orderFormattedDate = getFormattedDate(order.createdAt) | ||||||
|  |         val pointGroup = CaseBuilder() | ||||||
|  |             .`when`(order.point.loe(0)).then(0) | ||||||
|  |             .otherwise(1) | ||||||
|  |  | ||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select( |             .select( | ||||||
|                 QGetCalculateContentQueryData( |                 QGetCalculateContentQueryData( | ||||||
| @@ -62,6 +70,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|                     order.can, |                     order.can, | ||||||
|                     order.id.count(), |                     order.id.count(), | ||||||
|                     order.can.sum(), |                     order.can.sum(), | ||||||
|  |                     order.point.sum(), | ||||||
|                     creatorSettlementRatio.contentSettlementRatio |                     creatorSettlementRatio.contentSettlementRatio | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
| @@ -69,7 +78,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             .innerJoin(order.audioContent, audioContent) |             .innerJoin(order.audioContent, audioContent) | ||||||
|             .innerJoin(audioContent.member, member) |             .innerJoin(audioContent.member, member) | ||||||
|             .leftJoin(creatorSettlementRatio) |             .leftJoin(creatorSettlementRatio) | ||||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) |             .on( | ||||||
|  |                 member.id.eq(creatorSettlementRatio.member.id) | ||||||
|  |                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||||
|  |             ) | ||||||
|             .where( |             .where( | ||||||
|                 order.createdAt.goe(startDate) |                 order.createdAt.goe(startDate) | ||||||
|                     .and(order.createdAt.loe(endDate)) |                     .and(order.createdAt.loe(endDate)) | ||||||
| @@ -80,6 +92,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|                 order.type, |                 order.type, | ||||||
|                 orderFormattedDate, |                 orderFormattedDate, | ||||||
|                 order.can, |                 order.can, | ||||||
|  |                 pointGroup, | ||||||
|                 creatorSettlementRatio.contentSettlementRatio |                 creatorSettlementRatio.contentSettlementRatio | ||||||
|             ) |             ) | ||||||
|             .orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc()) |             .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> { |     fun getCumulativeSalesByContent(offset: Long, limit: Long): List<GetCumulativeSalesByContentQueryData> { | ||||||
|  |         val pointGroup = CaseBuilder() | ||||||
|  |             .`when`(order.point.loe(0)).then(0) | ||||||
|  |             .otherwise(1) | ||||||
|  |  | ||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select( |             .select( | ||||||
|                 QGetCumulativeSalesByContentQueryData( |                 QGetCumulativeSalesByContentQueryData( | ||||||
| @@ -123,6 +140,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|                     order.can, |                     order.can, | ||||||
|                     order.id.count(), |                     order.id.count(), | ||||||
|                     order.can.sum(), |                     order.can.sum(), | ||||||
|  |                     order.point.sum(), | ||||||
|                     creatorSettlementRatio.contentSettlementRatio |                     creatorSettlementRatio.contentSettlementRatio | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
| @@ -130,9 +148,19 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             .innerJoin(order.audioContent, audioContent) |             .innerJoin(order.audioContent, audioContent) | ||||||
|             .innerJoin(audioContent.member, member) |             .innerJoin(audioContent.member, member) | ||||||
|             .leftJoin(creatorSettlementRatio) |             .leftJoin(creatorSettlementRatio) | ||||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) |             .on( | ||||||
|  |                 member.id.eq(creatorSettlementRatio.member.id) | ||||||
|  |                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||||
|  |             ) | ||||||
|             .where(order.isActive.isTrue) |             .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) |             .offset(offset) | ||||||
|             .limit(limit) |             .limit(limit) | ||||||
|             .orderBy(member.id.desc(), audioContent.id.desc()) |             .orderBy(member.id.desc(), audioContent.id.desc()) | ||||||
| @@ -211,7 +239,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             .innerJoin(useCan.communityPost, creatorCommunity) |             .innerJoin(useCan.communityPost, creatorCommunity) | ||||||
|             .innerJoin(creatorCommunity.member, member) |             .innerJoin(creatorCommunity.member, member) | ||||||
|             .leftJoin(creatorSettlementRatio) |             .leftJoin(creatorSettlementRatio) | ||||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) |             .on( | ||||||
|  |                 member.id.eq(creatorSettlementRatio.member.id) | ||||||
|  |                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||||
|  |             ) | ||||||
|             .where( |             .where( | ||||||
|                 useCan.isRefund.isFalse |                 useCan.isRefund.isFalse | ||||||
|                     .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) |                     .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) | ||||||
| @@ -232,7 +263,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             .innerJoin(useCan.room, liveRoom) |             .innerJoin(useCan.room, liveRoom) | ||||||
|             .innerJoin(liveRoom.member, member) |             .innerJoin(liveRoom.member, member) | ||||||
|             .leftJoin(creatorSettlementRatio) |             .leftJoin(creatorSettlementRatio) | ||||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) |             .on( | ||||||
|  |                 member.id.eq(creatorSettlementRatio.member.id) | ||||||
|  |                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||||
|  |             ) | ||||||
|             .where( |             .where( | ||||||
|                 useCan.isRefund.isFalse |                 useCan.isRefund.isFalse | ||||||
|                     .and(useCan.createdAt.goe(startDate)) |                     .and(useCan.createdAt.goe(startDate)) | ||||||
| @@ -262,7 +296,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             .innerJoin(useCan.room, liveRoom) |             .innerJoin(useCan.room, liveRoom) | ||||||
|             .innerJoin(liveRoom.member, member) |             .innerJoin(liveRoom.member, member) | ||||||
|             .leftJoin(creatorSettlementRatio) |             .leftJoin(creatorSettlementRatio) | ||||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) |             .on( | ||||||
|  |                 member.id.eq(creatorSettlementRatio.member.id) | ||||||
|  |                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||||
|  |             ) | ||||||
|             .where( |             .where( | ||||||
|                 useCan.isRefund.isFalse |                 useCan.isRefund.isFalse | ||||||
|                     .and(useCan.createdAt.goe(startDate)) |                     .and(useCan.createdAt.goe(startDate)) | ||||||
| @@ -282,7 +319,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             .innerJoin(order.audioContent, audioContent) |             .innerJoin(order.audioContent, audioContent) | ||||||
|             .innerJoin(audioContent.member, member) |             .innerJoin(audioContent.member, member) | ||||||
|             .leftJoin(creatorSettlementRatio) |             .leftJoin(creatorSettlementRatio) | ||||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) |             .on( | ||||||
|  |                 member.id.eq(creatorSettlementRatio.member.id) | ||||||
|  |                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||||
|  |             ) | ||||||
|             .where( |             .where( | ||||||
|                 order.createdAt.goe(startDate) |                 order.createdAt.goe(startDate) | ||||||
|                     .and(order.createdAt.loe(endDate)) |                     .and(order.createdAt.loe(endDate)) | ||||||
| @@ -312,7 +352,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             .innerJoin(order.audioContent, audioContent) |             .innerJoin(order.audioContent, audioContent) | ||||||
|             .innerJoin(audioContent.member, member) |             .innerJoin(audioContent.member, member) | ||||||
|             .leftJoin(creatorSettlementRatio) |             .leftJoin(creatorSettlementRatio) | ||||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) |             .on( | ||||||
|  |                 member.id.eq(creatorSettlementRatio.member.id) | ||||||
|  |                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||||
|  |             ) | ||||||
|             .where( |             .where( | ||||||
|                 order.createdAt.goe(startDate) |                 order.createdAt.goe(startDate) | ||||||
|                     .and(order.createdAt.loe(endDate)) |                     .and(order.createdAt.loe(endDate)) | ||||||
| @@ -332,7 +375,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             .innerJoin(useCan.communityPost, creatorCommunity) |             .innerJoin(useCan.communityPost, creatorCommunity) | ||||||
|             .innerJoin(creatorCommunity.member, member) |             .innerJoin(creatorCommunity.member, member) | ||||||
|             .leftJoin(creatorSettlementRatio) |             .leftJoin(creatorSettlementRatio) | ||||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) |             .on( | ||||||
|  |                 member.id.eq(creatorSettlementRatio.member.id) | ||||||
|  |                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||||
|  |             ) | ||||||
|             .where( |             .where( | ||||||
|                 useCan.isRefund.isFalse |                 useCan.isRefund.isFalse | ||||||
|                     .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) |                     .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) | ||||||
| @@ -363,7 +409,10 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             .innerJoin(useCan.communityPost, creatorCommunity) |             .innerJoin(useCan.communityPost, creatorCommunity) | ||||||
|             .innerJoin(creatorCommunity.member, member) |             .innerJoin(creatorCommunity.member, member) | ||||||
|             .leftJoin(creatorSettlementRatio) |             .leftJoin(creatorSettlementRatio) | ||||||
|             .on(member.id.eq(creatorSettlementRatio.member.id)) |             .on( | ||||||
|  |                 member.id.eq(creatorSettlementRatio.member.id) | ||||||
|  |                     .and(creatorSettlementRatio.deletedAt.isNull) | ||||||
|  |             ) | ||||||
|             .where( |             .where( | ||||||
|                 useCan.isRefund.isFalse |                 useCan.isRefund.isFalse | ||||||
|                     .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) |                     .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) | ||||||
|   | |||||||
| @@ -22,11 +22,15 @@ data class GetCalculateContentQueryData @QueryProjection constructor( | |||||||
|     val numberOfPeople: Long, |     val numberOfPeople: Long, | ||||||
|     // 합계 |     // 합계 | ||||||
|     val totalCan: Int, |     val totalCan: Int, | ||||||
|  |     // 포인트 | ||||||
|  |     val totalPoint: Int, | ||||||
|     // 정산비율 |     // 정산비율 | ||||||
|     val settlementRatio: Int? |     val settlementRatio: Int? | ||||||
| ) { | ) { | ||||||
|     fun toGetCalculateContentResponse(): GetCalculateContentResponse { |     fun toGetCalculateContentResponse(): GetCalculateContentResponse { | ||||||
|         val orderTypeStr = if (orderType == OrderType.RENTAL) { |         val orderTypeStr = if (totalPoint > 0) { | ||||||
|  |             "포인트" | ||||||
|  |         } else if (orderType == OrderType.RENTAL) { | ||||||
|             "대여" |             "대여" | ||||||
|         } else { |         } else { | ||||||
|             "소장" |             "소장" | ||||||
|   | |||||||
| @@ -21,11 +21,15 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor( | |||||||
|     val numberOfPeople: Long, |     val numberOfPeople: Long, | ||||||
|     // 합계 |     // 합계 | ||||||
|     val totalCan: Int, |     val totalCan: Int, | ||||||
|  |     // 포인트 | ||||||
|  |     val totalPoint: Int, | ||||||
|     // 정산비율 |     // 정산비율 | ||||||
|     val settlementRatio: Int? |     val settlementRatio: Int? | ||||||
| ) { | ) { | ||||||
|     fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem { |     fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem { | ||||||
|         val orderTypeStr = if (orderType == OrderType.RENTAL) { |         val orderTypeStr = if (totalPoint > 0) { | ||||||
|  |             "포인트" | ||||||
|  |         } else if (orderType == OrderType.RENTAL) { | ||||||
|             "대여" |             "대여" | ||||||
|         } else { |         } 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.common.BaseEntity | ||||||
| import kr.co.vividnext.sodalive.member.Member | import kr.co.vividnext.sodalive.member.Member | ||||||
|  | import java.time.LocalDateTime | ||||||
| import javax.persistence.Entity | import javax.persistence.Entity | ||||||
| import javax.persistence.FetchType | import javax.persistence.FetchType | ||||||
| import javax.persistence.JoinColumn | import javax.persistence.JoinColumn | ||||||
| @@ -9,12 +10,29 @@ import javax.persistence.OneToOne | |||||||
|  |  | ||||||
| @Entity | @Entity | ||||||
| data class CreatorSettlementRatio( | data class CreatorSettlementRatio( | ||||||
|     val subsidy: Int, |     var subsidy: Int, | ||||||
|     val liveSettlementRatio: Int, |     var liveSettlementRatio: Int, | ||||||
|     val contentSettlementRatio: Int, |     var contentSettlementRatio: Int, | ||||||
|     val communitySettlementRatio: Int |     var communitySettlementRatio: Int | ||||||
| ) : BaseEntity() { | ) : BaseEntity() { | ||||||
|     @OneToOne(fetch = FetchType.LAZY) |     @OneToOne(fetch = FetchType.LAZY) | ||||||
|     @JoinColumn(name = "member_id", nullable = false) |     @JoinColumn(name = "member_id", nullable = false) | ||||||
|     var member: Member? = null |     var member: Member? = null | ||||||
|  |  | ||||||
|  |     var deletedAt: LocalDateTime? = null | ||||||
|  |  | ||||||
|  |     fun softDelete() { | ||||||
|  |         this.deletedAt = LocalDateTime.now() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun restore() { | ||||||
|  |         this.deletedAt = null | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) { | ||||||
|  |         this.subsidy = subsidy | ||||||
|  |         this.liveSettlementRatio = live | ||||||
|  |         this.contentSettlementRatio = content | ||||||
|  |         this.communitySettlementRatio = community | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse | |||||||
| import org.springframework.data.domain.Pageable | import org.springframework.data.domain.Pageable | ||||||
| import org.springframework.security.access.prepost.PreAuthorize | import org.springframework.security.access.prepost.PreAuthorize | ||||||
| import org.springframework.web.bind.annotation.GetMapping | import org.springframework.web.bind.annotation.GetMapping | ||||||
|  | import org.springframework.web.bind.annotation.PathVariable | ||||||
| import org.springframework.web.bind.annotation.PostMapping | import org.springframework.web.bind.annotation.PostMapping | ||||||
| import org.springframework.web.bind.annotation.RequestBody | import org.springframework.web.bind.annotation.RequestBody | ||||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||||
| @@ -27,4 +28,14 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat | |||||||
|             limit = pageable.pageSize.toLong() |             limit = pageable.pageSize.toLong() | ||||||
|         ) |         ) | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     @PostMapping("/update") | ||||||
|  |     fun updateCreatorSettlementRatio( | ||||||
|  |         @RequestBody request: CreateCreatorSettlementRatioRequest | ||||||
|  |     ) = ApiResponse.ok(service.updateCreatorSettlementRatio(request)) | ||||||
|  |  | ||||||
|  |     @PostMapping("/delete/{memberId}") | ||||||
|  |     fun deleteCreatorSettlementRatio( | ||||||
|  |         @PathVariable memberId: Long | ||||||
|  |     ) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId)) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -7,7 +7,9 @@ import org.springframework.data.jpa.repository.JpaRepository | |||||||
|  |  | ||||||
| interface CreatorSettlementRatioRepository : | interface CreatorSettlementRatioRepository : | ||||||
|     JpaRepository<CreatorSettlementRatio, Long>, |     JpaRepository<CreatorSettlementRatio, Long>, | ||||||
|     CreatorSettlementRatioQueryRepository |     CreatorSettlementRatioQueryRepository { | ||||||
|  |     fun findByMemberId(memberId: Long): CreatorSettlementRatio? | ||||||
|  | } | ||||||
|  |  | ||||||
| interface CreatorSettlementRatioQueryRepository { | interface CreatorSettlementRatioQueryRepository { | ||||||
|     fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem> |     fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem> | ||||||
| @@ -21,6 +23,7 @@ class CreatorSettlementRatioQueryRepositoryImpl( | |||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select( |             .select( | ||||||
|                 QGetCreatorSettlementRatioItem( |                 QGetCreatorSettlementRatioItem( | ||||||
|  |                     member.id, | ||||||
|                     member.nickname, |                     member.nickname, | ||||||
|                     creatorSettlementRatio.subsidy, |                     creatorSettlementRatio.subsidy, | ||||||
|                     creatorSettlementRatio.liveSettlementRatio, |                     creatorSettlementRatio.liveSettlementRatio, | ||||||
| @@ -30,6 +33,7 @@ class CreatorSettlementRatioQueryRepositoryImpl( | |||||||
|             ) |             ) | ||||||
|             .from(creatorSettlementRatio) |             .from(creatorSettlementRatio) | ||||||
|             .innerJoin(creatorSettlementRatio.member, member) |             .innerJoin(creatorSettlementRatio.member, member) | ||||||
|  |             .where(creatorSettlementRatio.deletedAt.isNull) | ||||||
|             .orderBy(creatorSettlementRatio.id.asc()) |             .orderBy(creatorSettlementRatio.id.asc()) | ||||||
|             .offset(offset) |             .offset(offset) | ||||||
|             .limit(limit) |             .limit(limit) | ||||||
| @@ -40,6 +44,7 @@ class CreatorSettlementRatioQueryRepositoryImpl( | |||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select(creatorSettlementRatio.id) |             .select(creatorSettlementRatio.id) | ||||||
|             .from(creatorSettlementRatio) |             .from(creatorSettlementRatio) | ||||||
|  |             .where(creatorSettlementRatio.deletedAt.isNull) | ||||||
|             .fetch() |             .fetch() | ||||||
|             .size |             .size | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -14,8 +14,6 @@ class CreatorSettlementRatioService( | |||||||
| ) { | ) { | ||||||
|     @Transactional |     @Transactional | ||||||
|     fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { |     fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { | ||||||
|         val creatorSettlementRatio = request.toEntity() |  | ||||||
|  |  | ||||||
|         val creator = memberRepository.findByIdOrNull(request.memberId) |         val creator = memberRepository.findByIdOrNull(request.memberId) | ||||||
|             ?: throw SodaException("잘못된 크리에이터 입니다.") |             ?: throw SodaException("잘못된 크리에이터 입니다.") | ||||||
|  |  | ||||||
| @@ -23,10 +21,52 @@ class CreatorSettlementRatioService( | |||||||
|             throw SodaException("잘못된 크리에이터 입니다.") |             throw SodaException("잘못된 크리에이터 입니다.") | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         val existing = repository.findByMemberId(request.memberId) | ||||||
|  |         if (existing != null) { | ||||||
|  |             // revive if soft-deleted, then update values | ||||||
|  |             existing.restore() | ||||||
|  |             existing.updateValues( | ||||||
|  |                 request.subsidy, | ||||||
|  |                 request.liveSettlementRatio, | ||||||
|  |                 request.contentSettlementRatio, | ||||||
|  |                 request.communitySettlementRatio | ||||||
|  |             ) | ||||||
|  |             repository.save(existing) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val creatorSettlementRatio = request.toEntity() | ||||||
|         creatorSettlementRatio.member = creator |         creatorSettlementRatio.member = creator | ||||||
|         repository.save(creatorSettlementRatio) |         repository.save(creatorSettlementRatio) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { | ||||||
|  |         val creator = memberRepository.findByIdOrNull(request.memberId) | ||||||
|  |             ?: throw SodaException("잘못된 크리에이터 입니다.") | ||||||
|  |         if (creator.role != MemberRole.CREATOR) { | ||||||
|  |             throw SodaException("잘못된 크리에이터 입니다.") | ||||||
|  |         } | ||||||
|  |         val existing = repository.findByMemberId(request.memberId) | ||||||
|  |             ?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.") | ||||||
|  |         existing.restore() | ||||||
|  |         existing.updateValues( | ||||||
|  |             request.subsidy, | ||||||
|  |             request.liveSettlementRatio, | ||||||
|  |             request.contentSettlementRatio, | ||||||
|  |             request.communitySettlementRatio | ||||||
|  |         ) | ||||||
|  |         repository.save(existing) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun deleteCreatorSettlementRatio(memberId: Long) { | ||||||
|  |         val existing = repository.findByMemberId(memberId) | ||||||
|  |             ?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.") | ||||||
|  |         existing.softDelete() | ||||||
|  |         repository.save(existing) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     @Transactional(readOnly = true) |     @Transactional(readOnly = true) | ||||||
|     fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse { |     fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse { | ||||||
|         val totalCount = repository.getCreatorSettlementRatioTotalCount() |         val totalCount = repository.getCreatorSettlementRatioTotalCount() | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ data class GetCreatorSettlementRatioResponse( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| data class GetCreatorSettlementRatioItem @QueryProjection constructor( | data class GetCreatorSettlementRatioItem @QueryProjection constructor( | ||||||
|  |     val memberId: Long, | ||||||
|     val nickname: String, |     val nickname: String, | ||||||
|     val subsidy: Int, |     val subsidy: Int, | ||||||
|     val liveSettlementRatio: Int, |     val liveSettlementRatio: Int, | ||||||
|   | |||||||
| @@ -1,8 +1,10 @@ | |||||||
| package kr.co.vividnext.sodalive.admin.can | package kr.co.vividnext.sodalive.admin.can | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.can.CanResponse | ||||||
| import kr.co.vividnext.sodalive.common.ApiResponse | import kr.co.vividnext.sodalive.common.ApiResponse | ||||||
| import org.springframework.security.access.prepost.PreAuthorize | import org.springframework.security.access.prepost.PreAuthorize | ||||||
| import org.springframework.web.bind.annotation.DeleteMapping | import org.springframework.web.bind.annotation.DeleteMapping | ||||||
|  | import org.springframework.web.bind.annotation.GetMapping | ||||||
| import org.springframework.web.bind.annotation.PathVariable | import org.springframework.web.bind.annotation.PathVariable | ||||||
| import org.springframework.web.bind.annotation.PostMapping | import org.springframework.web.bind.annotation.PostMapping | ||||||
| import org.springframework.web.bind.annotation.RequestBody | import org.springframework.web.bind.annotation.RequestBody | ||||||
| @@ -13,6 +15,11 @@ import org.springframework.web.bind.annotation.RestController | |||||||
| @RequestMapping("/admin/can") | @RequestMapping("/admin/can") | ||||||
| @PreAuthorize("hasRole('ADMIN')") | @PreAuthorize("hasRole('ADMIN')") | ||||||
| class AdminCanController(private val service: AdminCanService) { | class AdminCanController(private val service: AdminCanService) { | ||||||
|  |     @GetMapping | ||||||
|  |     fun getCans(): ApiResponse<List<CanResponse>> { | ||||||
|  |         return ApiResponse.ok(service.getCans()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     @PostMapping |     @PostMapping | ||||||
|     fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request)) |     fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request)) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,38 @@ | |||||||
| package kr.co.vividnext.sodalive.admin.can | package kr.co.vividnext.sodalive.admin.can | ||||||
|  |  | ||||||
|  | import com.querydsl.jpa.impl.JPAQueryFactory | ||||||
| import kr.co.vividnext.sodalive.can.Can | import kr.co.vividnext.sodalive.can.Can | ||||||
|  | import kr.co.vividnext.sodalive.can.CanResponse | ||||||
|  | import kr.co.vividnext.sodalive.can.CanStatus | ||||||
|  | import kr.co.vividnext.sodalive.can.QCan.can1 | ||||||
|  | import kr.co.vividnext.sodalive.can.QCanResponse | ||||||
| import org.springframework.data.jpa.repository.JpaRepository | import org.springframework.data.jpa.repository.JpaRepository | ||||||
|  | import org.springframework.stereotype.Repository | ||||||
|  |  | ||||||
| interface AdminCanRepository : JpaRepository<Can, Long> | 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.Can | ||||||
| import kr.co.vividnext.sodalive.can.CanStatus | import kr.co.vividnext.sodalive.can.CanStatus | ||||||
| import kr.co.vividnext.sodalive.extensions.moneyFormat | import kr.co.vividnext.sodalive.extensions.moneyFormat | ||||||
|  | import java.math.BigDecimal | ||||||
|  |  | ||||||
| data class AdminCanRequest( | data class AdminCanRequest( | ||||||
|     val can: Int, |     val can: Int, | ||||||
|     val rewardCan: Int, |     val rewardCan: Int, | ||||||
|     val price: Int |     val price: BigDecimal, | ||||||
|  |     val currency: String | ||||||
| ) { | ) { | ||||||
|     fun toEntity(): Can { |     fun toEntity(): Can { | ||||||
|         var title = "${can.moneyFormat()} 캔" |         var title = "${can.moneyFormat()} 캔" | ||||||
| @@ -20,6 +22,7 @@ data class AdminCanRequest( | |||||||
|             can = can, |             can = can, | ||||||
|             rewardCan = rewardCan, |             rewardCan = rewardCan, | ||||||
|             price = price, |             price = price, | ||||||
|  |             currency = currency, | ||||||
|             status = CanStatus.SALE |             status = CanStatus.SALE | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| package kr.co.vividnext.sodalive.admin.can | package kr.co.vividnext.sodalive.admin.can | ||||||
|  |  | ||||||
| import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository | import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository | ||||||
|  | import kr.co.vividnext.sodalive.can.CanResponse | ||||||
| import kr.co.vividnext.sodalive.can.CanStatus | import kr.co.vividnext.sodalive.can.CanStatus | ||||||
| import kr.co.vividnext.sodalive.can.charge.Charge | import kr.co.vividnext.sodalive.can.charge.Charge | ||||||
| import kr.co.vividnext.sodalive.can.charge.ChargeRepository | import kr.co.vividnext.sodalive.can.charge.ChargeRepository | ||||||
| @@ -20,6 +21,10 @@ class AdminCanService( | |||||||
|     private val chargeRepository: ChargeRepository, |     private val chargeRepository: ChargeRepository, | ||||||
|     private val memberRepository: AdminMemberRepository |     private val memberRepository: AdminMemberRepository | ||||||
| ) { | ) { | ||||||
|  |     fun getCans(): List<CanResponse> { | ||||||
|  |         return repository.findAllByStatus(status = CanStatus.SALE) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     @Transactional |     @Transactional | ||||||
|     fun saveCan(request: AdminCanRequest) { |     fun saveCan(request: AdminCanRequest) { | ||||||
|         repository.save(request.toEntity()) |         repository.save(request.toEntity()) | ||||||
|   | |||||||
| @@ -21,6 +21,7 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService) | |||||||
|     @GetMapping("/detail") |     @GetMapping("/detail") | ||||||
|     fun getChargeStatusDetail( |     fun getChargeStatusDetail( | ||||||
|         @RequestParam startDateStr: String, |         @RequestParam startDateStr: String, | ||||||
|         @RequestParam paymentGateway: PaymentGateway |         @RequestParam paymentGateway: PaymentGateway, | ||||||
|     ) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, 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 | package kr.co.vividnext.sodalive.admin.charge | ||||||
|  |  | ||||||
|  | import com.querydsl.core.BooleanBuilder | ||||||
| import com.querydsl.core.types.dsl.Expressions | import com.querydsl.core.types.dsl.Expressions | ||||||
| import com.querydsl.jpa.impl.JPAQueryFactory | import com.querydsl.jpa.impl.JPAQueryFactory | ||||||
| import kr.co.vividnext.sodalive.can.QCan.can1 | import kr.co.vividnext.sodalive.can.QCan.can1 | ||||||
| @@ -14,7 +15,7 @@ import java.time.LocalDateTime | |||||||
|  |  | ||||||
| @Repository | @Repository | ||||||
| class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) { | class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) { | ||||||
|     fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> { |     fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> { | ||||||
|         val formattedDate = Expressions.stringTemplate( |         val formattedDate = Expressions.stringTemplate( | ||||||
|             "DATE_FORMAT({0}, {1})", |             "DATE_FORMAT({0}, {1})", | ||||||
|             Expressions.dateTimeTemplate( |             Expressions.dateTimeTemplate( | ||||||
| @@ -26,15 +27,16 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory | |||||||
|             ), |             ), | ||||||
|             "%Y-%m-%d" |             "%Y-%m-%d" | ||||||
|         ) |         ) | ||||||
|  |         val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale) | ||||||
|  |  | ||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select( |             .select( | ||||||
|                 QGetChargeStatusQueryDto( |                 QGetChargeStatusResponse( | ||||||
|                     formattedDate, |                     formattedDate, | ||||||
|                     payment.price.sum(), |                     payment.price.sum(), | ||||||
|                     can1.price.sum(), |  | ||||||
|                     payment.id.count(), |                     payment.id.count(), | ||||||
|                     payment.paymentGateway |                     payment.paymentGateway.stringValue(), | ||||||
|  |                     currency.coalesce("KRW") | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|             .from(payment) |             .from(payment) | ||||||
| @@ -46,15 +48,46 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory | |||||||
|                     .and(charge.status.eq(ChargeStatus.CHARGE)) |                     .and(charge.status.eq(ChargeStatus.CHARGE)) | ||||||
|                     .and(payment.status.eq(PaymentStatus.COMPLETE)) |                     .and(payment.status.eq(PaymentStatus.COMPLETE)) | ||||||
|             ) |             ) | ||||||
|             .groupBy(formattedDate, payment.paymentGateway) |             .groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW")) | ||||||
|             .orderBy(formattedDate.desc()) |             .orderBy(formattedDate.desc()) | ||||||
|             .fetch() |             .fetch() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun getChargeStatusSummary(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> { | ||||||
|  |         val currency = Expressions.stringTemplate( | ||||||
|  |             "substring({0}, length({0}) - 2, 3)", | ||||||
|  |             payment.locale | ||||||
|  |         ).coalesce("KRW") | ||||||
|  |  | ||||||
|  |         return queryFactory | ||||||
|  |             .select( | ||||||
|  |                 QGetChargeStatusResponse( | ||||||
|  |                     Expressions.stringTemplate("'합계'"), // date | ||||||
|  |                     payment.price.sum(), | ||||||
|  |                     payment.id.count(), | ||||||
|  |                     Expressions.stringTemplate("''"), | ||||||
|  |                     currency | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             .from(payment) | ||||||
|  |             .innerJoin(payment.charge, charge) | ||||||
|  |             .leftJoin(charge.can, can1) | ||||||
|  |             .where( | ||||||
|  |                 charge.createdAt.goe(startDate) | ||||||
|  |                     .and(charge.createdAt.loe(endDate)) | ||||||
|  |                     .and(charge.status.eq(ChargeStatus.CHARGE)) | ||||||
|  |                     .and(payment.status.eq(PaymentStatus.COMPLETE)) | ||||||
|  |             ) | ||||||
|  |             .groupBy(currency) | ||||||
|  |             .orderBy(currency.asc()) | ||||||
|  |             .fetch() | ||||||
|  |     } | ||||||
|  |  | ||||||
|     fun getChargeStatusDetail( |     fun getChargeStatusDetail( | ||||||
|         startDate: LocalDateTime, |         startDate: LocalDateTime, | ||||||
|         endDate: LocalDateTime, |         endDate: LocalDateTime, | ||||||
|         paymentGateway: PaymentGateway |         paymentGateway: PaymentGateway, | ||||||
|  |         currency: String? = null | ||||||
|     ): List<GetChargeStatusDetailQueryDto> { |     ): List<GetChargeStatusDetailQueryDto> { | ||||||
|         val formattedDate = Expressions.stringTemplate( |         val formattedDate = Expressions.stringTemplate( | ||||||
|             "DATE_FORMAT({0}, {1})", |             "DATE_FORMAT({0}, {1})", | ||||||
| @@ -67,6 +100,20 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory | |||||||
|             ), |             ), | ||||||
|             "%Y-%m-%d %H:%i:%s" |             "%Y-%m-%d %H:%i:%s" | ||||||
|         ) |         ) | ||||||
|  |         val currencyExpr = Expressions.stringTemplate( | ||||||
|  |             "substring({0}, length({0}) - 2, 3)", | ||||||
|  |             payment.locale | ||||||
|  |         ).coalesce("KRW") | ||||||
|  |         val whereBuilder = BooleanBuilder() | ||||||
|  |         whereBuilder.and(charge.createdAt.goe(startDate)) | ||||||
|  |             .and(charge.createdAt.loe(endDate)) | ||||||
|  |             .and(charge.status.eq(ChargeStatus.CHARGE)) | ||||||
|  |             .and(payment.status.eq(PaymentStatus.COMPLETE)) | ||||||
|  |             .and(payment.paymentGateway.eq(paymentGateway)) | ||||||
|  |  | ||||||
|  |         if (currency != null) { | ||||||
|  |             whereBuilder.and(currencyExpr.eq(currency)) | ||||||
|  |         } | ||||||
|  |  | ||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select( |             .select( | ||||||
| @@ -75,8 +122,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory | |||||||
|                     member.nickname, |                     member.nickname, | ||||||
|                     payment.method.coalesce(""), |                     payment.method.coalesce(""), | ||||||
|                     payment.price, |                     payment.price, | ||||||
|                     can1.price, |                     currencyExpr, | ||||||
|                     payment.locale.coalesce(""), |  | ||||||
|                     formattedDate |                     formattedDate | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
| @@ -84,13 +130,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory | |||||||
|             .innerJoin(charge.member, member) |             .innerJoin(charge.member, member) | ||||||
|             .innerJoin(charge.payment, payment) |             .innerJoin(charge.payment, payment) | ||||||
|             .leftJoin(charge.can, can1) |             .leftJoin(charge.can, can1) | ||||||
|             .where( |             .where(whereBuilder) | ||||||
|                 charge.createdAt.goe(startDate) |  | ||||||
|                     .and(charge.createdAt.loe(endDate)) |  | ||||||
|                     .and(charge.status.eq(ChargeStatus.CHARGE)) |  | ||||||
|                     .and(payment.status.eq(PaymentStatus.COMPLETE)) |  | ||||||
|                     .and(payment.paymentGateway.eq(paymentGateway)) |  | ||||||
|             ) |  | ||||||
|             .orderBy(formattedDate.desc()) |             .orderBy(formattedDate.desc()) | ||||||
|             .fetch() |             .fetch() | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -20,48 +20,17 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository) | |||||||
|             .withZoneSameInstant(ZoneId.of("UTC")) |             .withZoneSameInstant(ZoneId.of("UTC")) | ||||||
|             .toLocalDateTime() |             .toLocalDateTime() | ||||||
|  |  | ||||||
|         var totalChargeAmount = 0 |         val summaryRows = repository.getChargeStatusSummary(startDate, endDate) | ||||||
|         var totalChargeCount = 0L |         val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList() | ||||||
|  |         chargeStatusList.addAll(0, summaryRows) | ||||||
|         val chargeStatusList = repository.getChargeStatus(startDate, endDate) |  | ||||||
|             .asSequence() |  | ||||||
|             .map { |  | ||||||
|                 val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) { |  | ||||||
|                     it.pgChargeAmount |  | ||||||
|                 } else { |  | ||||||
|                     it.appleChargeAmount.toInt() |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 val chargeCount = it.chargeCount |  | ||||||
|  |  | ||||||
|                 totalChargeAmount += chargeAmount |  | ||||||
|                 totalChargeCount += chargeCount |  | ||||||
|  |  | ||||||
|                 GetChargeStatusResponse( |  | ||||||
|                     date = it.date, |  | ||||||
|                     chargeAmount = chargeAmount, |  | ||||||
|                     chargeCount = chargeCount, |  | ||||||
|                     pg = it.paymentGateWay.name |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|             .toMutableList() |  | ||||||
|  |  | ||||||
|         chargeStatusList.add( |  | ||||||
|             0, |  | ||||||
|             GetChargeStatusResponse( |  | ||||||
|                 date = "합계", |  | ||||||
|                 chargeAmount = totalChargeAmount, |  | ||||||
|                 chargeCount = totalChargeCount, |  | ||||||
|                 pg = "" |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return chargeStatusList.toList() |         return chargeStatusList.toList() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun getChargeStatusDetail( |     fun getChargeStatusDetail( | ||||||
|         startDateStr: String, |         startDateStr: String, | ||||||
|         paymentGateway: PaymentGateway |         paymentGateway: PaymentGateway, | ||||||
|  |         currency: String? = null | ||||||
|     ): List<GetChargeStatusDetailResponse> { |     ): List<GetChargeStatusDetailResponse> { | ||||||
|         val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") |         val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||||
|         val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0) |         val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0) | ||||||
| @@ -74,18 +43,16 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository) | |||||||
|             .withZoneSameInstant(ZoneId.of("UTC")) |             .withZoneSameInstant(ZoneId.of("UTC")) | ||||||
|             .toLocalDateTime() |             .toLocalDateTime() | ||||||
|  |  | ||||||
|         return repository.getChargeStatusDetail(startDate, endDate, paymentGateway) |         return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency) | ||||||
|             .asSequence() |  | ||||||
|             .map { |             .map { | ||||||
|                 GetChargeStatusDetailResponse( |                 GetChargeStatusDetailResponse( | ||||||
|                     memberId = it.memberId, |                     memberId = it.memberId, | ||||||
|                     nickname = it.nickname, |                     nickname = it.nickname, | ||||||
|                     method = it.method, |                     method = it.method, | ||||||
|                     amount = it.appleChargeAmount.toInt(), |                     amount = it.amount, | ||||||
|                     locale = it.locale, |                     locale = it.locale, | ||||||
|                     datetime = it.datetime |                     datetime = it.datetime | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|             .toList() |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,13 +1,13 @@ | |||||||
| package kr.co.vividnext.sodalive.admin.charge | package kr.co.vividnext.sodalive.admin.charge | ||||||
|  |  | ||||||
| import com.querydsl.core.annotations.QueryProjection | import com.querydsl.core.annotations.QueryProjection | ||||||
|  | import java.math.BigDecimal | ||||||
|  |  | ||||||
| data class GetChargeStatusDetailQueryDto @QueryProjection constructor( | data class GetChargeStatusDetailQueryDto @QueryProjection constructor( | ||||||
|     val memberId: Long, |     val memberId: Long, | ||||||
|     val nickname: String, |     val nickname: String, | ||||||
|     val method: String, |     val method: String, | ||||||
|     val appleChargeAmount: Double, |     val amount: BigDecimal, | ||||||
|     val pgChargeAmount: Int, |  | ||||||
|     val locale: String, |     val locale: String, | ||||||
|     val datetime: String |     val datetime: String | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -1,10 +1,12 @@ | |||||||
| package kr.co.vividnext.sodalive.admin.charge | package kr.co.vividnext.sodalive.admin.charge | ||||||
|  |  | ||||||
|  | import java.math.BigDecimal | ||||||
|  |  | ||||||
| data class GetChargeStatusDetailResponse( | data class GetChargeStatusDetailResponse( | ||||||
|     val memberId: Long, |     val memberId: Long, | ||||||
|     val nickname: String, |     val nickname: String, | ||||||
|     val method: String, |     val method: String, | ||||||
|     val amount: Int, |     val amount: BigDecimal, | ||||||
|     val locale: String, |     val locale: String, | ||||||
|     val datetime: 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 | 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 date: String, | ||||||
|     val chargeAmount: Int, |     val chargeAmount: BigDecimal, | ||||||
|     val chargeCount: Long, |     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 |                 audioContent.duration.isNotNull | ||||||
|                     .and(audioContent.member.isNotNull) |                     .and(audioContent.member.isNotNull) | ||||||
|                     .and(audioContentHashTag.audioContent.id.eq(audioContentId)) |                     .and(audioContentHashTag.audioContent.id.eq(audioContentId)) | ||||||
|  |                     .and(audioContentHashTag.isActive.isTrue) | ||||||
|             ) |             ) | ||||||
|             .fetch() |             .fetch() | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -38,6 +38,7 @@ class AdminRecommendSeriesQueryRepositoryImpl( | |||||||
|                     .and(series.isActive.isTrue) |                     .and(series.isActive.isTrue) | ||||||
|                     .and(recommendSeries.isFree.eq(isFree)) |                     .and(recommendSeries.isFree.eq(isFree)) | ||||||
|             ) |             ) | ||||||
|  |             .orderBy(recommendSeries.orders.asc()) | ||||||
|             .fetch() |             .fetch() | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -156,8 +156,8 @@ class AdminEventBannerService( | |||||||
|             ) |             ) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (!link.isNullOrBlank() && event.link != link) { |         if (event.link != link) { | ||||||
|             event.link = link |             event.link = if (link.isNullOrBlank()) null else link | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (!title.isNullOrBlank() && event.title != title) { |         if (!title.isNullOrBlank() && event.title != title) { | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.member | |||||||
|  |  | ||||||
| import kr.co.vividnext.sodalive.common.SodaException | import kr.co.vividnext.sodalive.common.SodaException | ||||||
| import kr.co.vividnext.sodalive.member.Member | import kr.co.vividnext.sodalive.member.Member | ||||||
|  | import kr.co.vividnext.sodalive.member.MemberProvider | ||||||
| import kr.co.vividnext.sodalive.member.MemberRole | import kr.co.vividnext.sodalive.member.MemberRole | ||||||
| import org.springframework.beans.factory.annotation.Value | import org.springframework.beans.factory.annotation.Value | ||||||
| import org.springframework.data.domain.Pageable | import org.springframework.data.domain.Pageable | ||||||
| @@ -98,6 +99,13 @@ class AdminMemberService( | |||||||
|                     MemberRole.BOT -> "봇" |                     MemberRole.BOT -> "봇" | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  |                 val loginType = when (it.provider) { | ||||||
|  |                     MemberProvider.EMAIL -> "이메일" | ||||||
|  |                     MemberProvider.KAKAO -> "카카오" | ||||||
|  |                     MemberProvider.GOOGLE -> "구글" | ||||||
|  |                     MemberProvider.APPLE -> "애플" | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 val signUpDate = it.createdAt!! |                 val signUpDate = it.createdAt!! | ||||||
|                     .atZone(ZoneId.of("UTC")) |                     .atZone(ZoneId.of("UTC")) | ||||||
|                     .withZoneSameInstant(ZoneId.of("Asia/Seoul")) |                     .withZoneSameInstant(ZoneId.of("Asia/Seoul")) | ||||||
| @@ -122,6 +130,7 @@ class AdminMemberService( | |||||||
|                         "$cloudFrontHost/profile/default-profile.png" |                         "$cloudFrontHost/profile/default-profile.png" | ||||||
|                     }, |                     }, | ||||||
|                     userType = userType, |                     userType = userType, | ||||||
|  |                     loginType = loginType, | ||||||
|                     container = it.container, |                     container = it.container, | ||||||
|                     auth = it.auth != null, |                     auth = it.auth != null, | ||||||
|                     signUpDate = signUpDate, |                     signUpDate = signUpDate, | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ data class GetAdminMemberListResponseItem( | |||||||
|     val nickname: String, |     val nickname: String, | ||||||
|     val profileUrl: String, |     val profileUrl: String, | ||||||
|     val userType: String, |     val userType: String, | ||||||
|  |     val loginType: String, | ||||||
|     val container: String, |     val container: String, | ||||||
|     val auth: Boolean, |     val auth: Boolean, | ||||||
|     val signUpDate: String, |     val signUpDate: String, | ||||||
|   | |||||||
| @@ -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.CaseBuilder | ||||||
| import com.querydsl.core.types.dsl.DateTimePath | import com.querydsl.core.types.dsl.DateTimePath | ||||||
| import com.querydsl.core.types.dsl.Expressions | import com.querydsl.core.types.dsl.Expressions | ||||||
| import com.querydsl.core.types.dsl.NumberExpression |  | ||||||
| import com.querydsl.core.types.dsl.StringTemplate | import com.querydsl.core.types.dsl.StringTemplate | ||||||
| import com.querydsl.jpa.impl.JPAQueryFactory | import com.querydsl.jpa.impl.JPAQueryFactory | ||||||
| import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType | import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType | ||||||
| @@ -18,16 +17,16 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|         endDate: LocalDateTime |         endDate: LocalDateTime | ||||||
|     ): Int { |     ): Int { | ||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select(adTrackingHistory.id.pid) |             .select(adTrackingHistory.pid) | ||||||
|             .from(adTrackingHistory) |             .from(adTrackingHistory) | ||||||
|             .where( |             .where( | ||||||
|                 adTrackingHistory.id.createdAt.goe(startDate), |                 adTrackingHistory.createdAt.goe(startDate), | ||||||
|                 adTrackingHistory.id.createdAt.loe(endDate) |                 adTrackingHistory.createdAt.loe(endDate) | ||||||
|             ) |             ) | ||||||
|             .groupBy( |             .groupBy( | ||||||
|                 getFormattedDate(adTrackingHistory.id.createdAt), |                 getFormattedDate(adTrackingHistory.createdAt), | ||||||
|                 adTrackingHistory.mediaGroup, |                 adTrackingHistory.mediaGroup, | ||||||
|                 adTrackingHistory.id.pid, |                 adTrackingHistory.pid, | ||||||
|                 adTrackingHistory.pidName |                 adTrackingHistory.pidName | ||||||
|             ) |             ) | ||||||
|             .fetch() |             .fetch() | ||||||
| @@ -41,45 +40,51 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|         limit: Long |         limit: Long | ||||||
|     ): List<GetAdminAdStatisticsItem> { |     ): List<GetAdminAdStatisticsItem> { | ||||||
|         val signUpCount = CaseBuilder() |         val signUpCount = CaseBuilder() | ||||||
|             .`when`(adTrackingHistory.id.type.eq(AdTrackingHistoryType.SIGNUP)) |             .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.SIGNUP)) | ||||||
|  |             .then(1) | ||||||
|  |             .otherwise(0) | ||||||
|  |             .sum() | ||||||
|  |  | ||||||
|  |         val launchCount = CaseBuilder() | ||||||
|  |             .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.APP_LAUNCH)) | ||||||
|             .then(1) |             .then(1) | ||||||
|             .otherwise(0) |             .otherwise(0) | ||||||
|             .sum() |             .sum() | ||||||
|  |  | ||||||
|         val loginCount = CaseBuilder() |         val loginCount = CaseBuilder() | ||||||
|             .`when`(adTrackingHistory.id.type.eq(AdTrackingHistoryType.LOGIN)) |             .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.LOGIN)) | ||||||
|             .then(1) |             .then(1) | ||||||
|             .otherwise(0) |             .otherwise(0) | ||||||
|             .sum() |             .sum() | ||||||
|  |  | ||||||
|         val firstPaymentCount = CaseBuilder() |         val firstPaymentCount = CaseBuilder() | ||||||
|             .`when`(adTrackingHistory.id.type.eq(AdTrackingHistoryType.FIRST_PAYMENT)) |             .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT)) | ||||||
|             .then(1) |             .then(1) | ||||||
|             .otherwise(0) |             .otherwise(0) | ||||||
|             .sum() |             .sum() | ||||||
|  |  | ||||||
|         val firstPaymentTotalAmount = CaseBuilder() |         val firstPaymentTotalAmount = CaseBuilder() | ||||||
|             .`when`(adTrackingHistory.id.type.eq(AdTrackingHistoryType.FIRST_PAYMENT)) |             .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT)) | ||||||
|             .then(adTrackingHistory.price) |             .then(adTrackingHistory.price) | ||||||
|             .otherwise(Expressions.constant(0.0)) |             .otherwise(0.toBigDecimal()) | ||||||
|             .sum() |             .sum() | ||||||
|  |  | ||||||
|         val repeatPaymentCount = CaseBuilder() |         val repeatPaymentCount = CaseBuilder() | ||||||
|             .`when`(adTrackingHistory.id.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) |             .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) | ||||||
|             .then(1) |             .then(1) | ||||||
|             .otherwise(0) |             .otherwise(0) | ||||||
|             .sum() |             .sum() | ||||||
|  |  | ||||||
|         val repeatPaymentTotalAmount = CaseBuilder() |         val repeatPaymentTotalAmount = CaseBuilder() | ||||||
|             .`when`(adTrackingHistory.id.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) |             .`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) | ||||||
|             .then(adTrackingHistory.price) |             .then(adTrackingHistory.price) | ||||||
|             .otherwise(Expressions.constant(0.0)) |             .otherwise(0.toBigDecimal()) | ||||||
|             .sum() |             .sum() | ||||||
|  |  | ||||||
|         val allPaymentCount = CaseBuilder() |         val allPaymentCount = CaseBuilder() | ||||||
|             .`when`( |             .`when`( | ||||||
|                 adTrackingHistory.id.type.eq(AdTrackingHistoryType.FIRST_PAYMENT) |                 adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT) | ||||||
|                     .or(adTrackingHistory.id.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) |                     .or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) | ||||||
|             ) |             ) | ||||||
|             .then(1) |             .then(1) | ||||||
|             .otherwise(0) |             .otherwise(0) | ||||||
| @@ -87,42 +92,43 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|  |  | ||||||
|         val allPaymentTotalAmount = CaseBuilder() |         val allPaymentTotalAmount = CaseBuilder() | ||||||
|             .`when`( |             .`when`( | ||||||
|                 adTrackingHistory.id.type.eq(AdTrackingHistoryType.FIRST_PAYMENT) |                 adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT) | ||||||
|                     .or(adTrackingHistory.id.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) |                     .or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT)) | ||||||
|             ) |             ) | ||||||
|             .then(adTrackingHistory.price) |             .then(adTrackingHistory.price) | ||||||
|             .otherwise(Expressions.constant(0.0)) |             .otherwise(0.toBigDecimal()) | ||||||
|             .sum() |             .sum() | ||||||
|  |  | ||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select( |             .select( | ||||||
|                 QGetAdminAdStatisticsItem( |                 QGetAdminAdStatisticsItem( | ||||||
|                     getFormattedDate(adTrackingHistory.id.createdAt), |                     getFormattedDate(adTrackingHistory.createdAt), | ||||||
|                     adTrackingHistory.mediaGroup, |                     adTrackingHistory.mediaGroup, | ||||||
|                     adTrackingHistory.id.pid, |                     adTrackingHistory.pid, | ||||||
|                     adTrackingHistory.pidName, |                     adTrackingHistory.pidName, | ||||||
|  |                     launchCount, | ||||||
|                     loginCount, |                     loginCount, | ||||||
|                     signUpCount, |                     signUpCount, | ||||||
|                     firstPaymentCount, |                     firstPaymentCount, | ||||||
|                     roundedValueDecimalPlaces2(firstPaymentTotalAmount), |                     firstPaymentTotalAmount, | ||||||
|                     repeatPaymentCount, |                     repeatPaymentCount, | ||||||
|                     roundedValueDecimalPlaces2(repeatPaymentTotalAmount), |                     repeatPaymentTotalAmount, | ||||||
|                     allPaymentCount, |                     allPaymentCount, | ||||||
|                     roundedValueDecimalPlaces2(allPaymentTotalAmount) |                     allPaymentTotalAmount | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|             .from(adTrackingHistory) |             .from(adTrackingHistory) | ||||||
|             .where( |             .where( | ||||||
|                 adTrackingHistory.id.createdAt.goe(startDate), |                 adTrackingHistory.createdAt.goe(startDate), | ||||||
|                 adTrackingHistory.id.createdAt.loe(endDate) |                 adTrackingHistory.createdAt.loe(endDate) | ||||||
|             ) |             ) | ||||||
|             .groupBy( |             .groupBy( | ||||||
|                 getFormattedDate(adTrackingHistory.id.createdAt), |                 getFormattedDate(adTrackingHistory.createdAt), | ||||||
|                 adTrackingHistory.mediaGroup, |                 adTrackingHistory.mediaGroup, | ||||||
|                 adTrackingHistory.id.pid, |                 adTrackingHistory.pid, | ||||||
|                 adTrackingHistory.pidName |                 adTrackingHistory.pidName | ||||||
|             ) |             ) | ||||||
|             .orderBy(getFormattedDate(adTrackingHistory.id.createdAt).desc()) |             .orderBy(getFormattedDate(adTrackingHistory.createdAt).desc()) | ||||||
|             .offset(offset) |             .offset(offset) | ||||||
|             .limit(limit) |             .limit(limit) | ||||||
|             .fetch() |             .fetch() | ||||||
| @@ -141,13 +147,4 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) { | |||||||
|             "%Y-%m-%d" |             "%Y-%m-%d" | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun roundedValueDecimalPlaces2(valueExpression: NumberExpression<Double>): NumberExpression<Double> { |  | ||||||
|         return Expressions.numberTemplate( |  | ||||||
|             Double::class.java, |  | ||||||
|             "ROUND({0}, {1})", |  | ||||||
|             valueExpression, |  | ||||||
|             2 |  | ||||||
|         ) |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| package kr.co.vividnext.sodalive.admin.statistics.ad | package kr.co.vividnext.sodalive.admin.statistics.ad | ||||||
|  |  | ||||||
| import com.querydsl.core.annotations.QueryProjection | import com.querydsl.core.annotations.QueryProjection | ||||||
|  | import java.math.BigDecimal | ||||||
|  |  | ||||||
| data class GetAdminAdStatisticsResponse( | data class GetAdminAdStatisticsResponse( | ||||||
|     val totalCount: Int, |     val totalCount: Int, | ||||||
| @@ -12,12 +13,13 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor( | |||||||
|     val mediaGroup: String, |     val mediaGroup: String, | ||||||
|     val pid: String, |     val pid: String, | ||||||
|     val pidName: String, |     val pidName: String, | ||||||
|  |     val launchCount: Int, | ||||||
|     val loginCount: Int, |     val loginCount: Int, | ||||||
|     val signUpCount: Int, |     val signUpCount: Int, | ||||||
|     val firstPaymentCount: Int, |     val firstPaymentCount: Int, | ||||||
|     val firstPaymentTotalAmount: Double, |     val firstPaymentTotalAmount: BigDecimal, | ||||||
|     val repeatPaymentCount: Int, |     val repeatPaymentCount: Int, | ||||||
|     val repeatPaymentTotalAmount: Double, |     val repeatPaymentTotalAmount: BigDecimal, | ||||||
|     val allPaymentCount: Int, |     val allPaymentCount: Int, | ||||||
|     val allPaymentTotalAmount: Double |     val allPaymentTotalAmount: BigDecimal | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -8,8 +8,10 @@ import kr.co.vividnext.sodalive.can.charge.ChargeStatus | |||||||
| import kr.co.vividnext.sodalive.can.charge.QCharge.charge | import kr.co.vividnext.sodalive.can.charge.QCharge.charge | ||||||
| import kr.co.vividnext.sodalive.can.payment.PaymentStatus | import kr.co.vividnext.sodalive.can.payment.PaymentStatus | ||||||
| import kr.co.vividnext.sodalive.can.payment.QPayment.payment | import kr.co.vividnext.sodalive.can.payment.QPayment.payment | ||||||
|  | import kr.co.vividnext.sodalive.member.MemberProvider | ||||||
| import kr.co.vividnext.sodalive.member.QMember.member | import kr.co.vividnext.sodalive.member.QMember.member | ||||||
| import kr.co.vividnext.sodalive.member.QSignOut.signOut | import kr.co.vividnext.sodalive.member.QSignOut.signOut | ||||||
|  | import kr.co.vividnext.sodalive.member.auth.QAuth.auth | ||||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||||
|  |  | ||||||
| @@ -27,6 +29,57 @@ class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory) | |||||||
|             .size |             .size | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun getTotalSignUpEmailCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { | ||||||
|  |         return queryFactory | ||||||
|  |             .select(member.id) | ||||||
|  |             .from(member) | ||||||
|  |             .where( | ||||||
|  |                 member.createdAt.goe(startDate), | ||||||
|  |                 member.createdAt.loe(endDate), | ||||||
|  |                 member.provider.eq(MemberProvider.EMAIL) | ||||||
|  |             ) | ||||||
|  |             .fetch() | ||||||
|  |             .size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getTotalSignUpKakaoCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { | ||||||
|  |         return queryFactory | ||||||
|  |             .select(member.id) | ||||||
|  |             .from(member) | ||||||
|  |             .where( | ||||||
|  |                 member.createdAt.goe(startDate), | ||||||
|  |                 member.createdAt.loe(endDate), | ||||||
|  |                 member.provider.eq(MemberProvider.KAKAO) | ||||||
|  |             ) | ||||||
|  |             .fetch() | ||||||
|  |             .size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getTotalSignUpGoogleCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { | ||||||
|  |         return queryFactory | ||||||
|  |             .select(member.id) | ||||||
|  |             .from(member) | ||||||
|  |             .where( | ||||||
|  |                 member.createdAt.goe(startDate), | ||||||
|  |                 member.createdAt.loe(endDate), | ||||||
|  |                 member.provider.eq(MemberProvider.GOOGLE) | ||||||
|  |             ) | ||||||
|  |             .fetch() | ||||||
|  |             .size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getTotalAuthCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { | ||||||
|  |         return queryFactory | ||||||
|  |             .select(auth.id) | ||||||
|  |             .from(auth) | ||||||
|  |             .where( | ||||||
|  |                 auth.createdAt.goe(startDate), | ||||||
|  |                 auth.createdAt.loe(endDate) | ||||||
|  |             ) | ||||||
|  |             .fetch() | ||||||
|  |             .size | ||||||
|  |     } | ||||||
|  |  | ||||||
|     fun getTotalSignOutCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { |     fun getTotalSignOutCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { | ||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select(signOut.id) |             .select(signOut.id) | ||||||
| @@ -79,6 +132,81 @@ class AdminMemberStatisticsRepository(private val queryFactory: JPAQueryFactory) | |||||||
|             .fetch() |             .fetch() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun getSignUpEmailCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> { | ||||||
|  |         return queryFactory | ||||||
|  |             .select( | ||||||
|  |                 QDateAndMemberCount( | ||||||
|  |                     getFormattedDate(member.createdAt), | ||||||
|  |                     member.id.countDistinct().castToNum(Int::class.java) | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             .from(member) | ||||||
|  |             .where( | ||||||
|  |                 member.createdAt.goe(startDate), | ||||||
|  |                 member.createdAt.loe(endDate), | ||||||
|  |                 member.provider.eq(MemberProvider.EMAIL) | ||||||
|  |             ) | ||||||
|  |             .groupBy(getFormattedDate(member.createdAt)) | ||||||
|  |             .orderBy(getFormattedDate(member.createdAt).desc()) | ||||||
|  |             .fetch() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getSignUpKakaoCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> { | ||||||
|  |         return queryFactory | ||||||
|  |             .select( | ||||||
|  |                 QDateAndMemberCount( | ||||||
|  |                     getFormattedDate(member.createdAt), | ||||||
|  |                     member.id.countDistinct().castToNum(Int::class.java) | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             .from(member) | ||||||
|  |             .where( | ||||||
|  |                 member.createdAt.goe(startDate), | ||||||
|  |                 member.createdAt.loe(endDate), | ||||||
|  |                 member.provider.eq(MemberProvider.KAKAO) | ||||||
|  |             ) | ||||||
|  |             .groupBy(getFormattedDate(member.createdAt)) | ||||||
|  |             .orderBy(getFormattedDate(member.createdAt).desc()) | ||||||
|  |             .fetch() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getSignUpGoogleCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> { | ||||||
|  |         return queryFactory | ||||||
|  |             .select( | ||||||
|  |                 QDateAndMemberCount( | ||||||
|  |                     getFormattedDate(member.createdAt), | ||||||
|  |                     member.id.countDistinct().castToNum(Int::class.java) | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             .from(member) | ||||||
|  |             .where( | ||||||
|  |                 member.createdAt.goe(startDate), | ||||||
|  |                 member.createdAt.loe(endDate), | ||||||
|  |                 member.provider.eq(MemberProvider.GOOGLE) | ||||||
|  |             ) | ||||||
|  |             .groupBy(getFormattedDate(member.createdAt)) | ||||||
|  |             .orderBy(getFormattedDate(member.createdAt).desc()) | ||||||
|  |             .fetch() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getAuthCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> { | ||||||
|  |         return queryFactory | ||||||
|  |             .select( | ||||||
|  |                 QDateAndMemberCount( | ||||||
|  |                     getFormattedDate(auth.createdAt), | ||||||
|  |                     auth.id.countDistinct().castToNum(Int::class.java) | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             .from(auth) | ||||||
|  |             .where( | ||||||
|  |                 auth.createdAt.goe(startDate), | ||||||
|  |                 auth.createdAt.loe(endDate) | ||||||
|  |             ) | ||||||
|  |             .groupBy(getFormattedDate(auth.createdAt)) | ||||||
|  |             .orderBy(getFormattedDate(auth.createdAt).desc()) | ||||||
|  |             .fetch() | ||||||
|  |     } | ||||||
|  |  | ||||||
|     fun getSignOutCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> { |     fun getSignOutCountInRange(startDate: LocalDateTime, endDate: LocalDateTime): List<DateAndMemberCount> { | ||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select( |             .select( | ||||||
|   | |||||||
| @@ -46,6 +46,19 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics | |||||||
|             .toLocalDateTime() |             .toLocalDateTime() | ||||||
|  |  | ||||||
|         val totalSignUpCount = repository.getTotalSignUpCount(startDate = startDateTime, endDate = endDateTime) |         val totalSignUpCount = repository.getTotalSignUpCount(startDate = startDateTime, endDate = endDateTime) | ||||||
|  |         val totalSignUpEmailCount = repository.getTotalSignUpEmailCount( | ||||||
|  |             startDate = startDateTime, | ||||||
|  |             endDate = endDateTime | ||||||
|  |         ) | ||||||
|  |         val totalSignUpKakaoCount = repository.getTotalSignUpKakaoCount( | ||||||
|  |             startDate = startDateTime, | ||||||
|  |             endDate = endDateTime | ||||||
|  |         ) | ||||||
|  |         val totalSignUpGoogleCount = repository.getTotalSignUpGoogleCount( | ||||||
|  |             startDate = startDateTime, | ||||||
|  |             endDate = endDateTime | ||||||
|  |         ) | ||||||
|  |         val totalAuthCount = repository.getTotalAuthCount(startDate = startDateTime, endDate = endDateTime) | ||||||
|         val totalSignOutCount = repository.getTotalSignOutCount(startDate = startDateTime, endDate = endDateTime) |         val totalSignOutCount = repository.getTotalSignOutCount(startDate = startDateTime, endDate = endDateTime) | ||||||
|         val totalPaymentMemberCount = repository.getPaymentMemberCount(startDate = startDateTime, endDate = endDateTime) |         val totalPaymentMemberCount = repository.getPaymentMemberCount(startDate = startDateTime, endDate = endDateTime) | ||||||
|  |  | ||||||
| @@ -64,6 +77,26 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics | |||||||
|             endDate = endDateTime |             endDate = endDateTime | ||||||
|         ).associateBy({ it.date }, { it.memberCount }) |         ).associateBy({ it.date }, { it.memberCount }) | ||||||
|  |  | ||||||
|  |         val signUpEmailCountInRange = repository.getSignUpEmailCountInRange( | ||||||
|  |             startDate = startDateTime, | ||||||
|  |             endDate = endDateTime | ||||||
|  |         ).associateBy({ it.date }, { it.memberCount }) | ||||||
|  |  | ||||||
|  |         val signUpKakaoCountInRange = repository.getSignUpKakaoCountInRange( | ||||||
|  |             startDate = startDateTime, | ||||||
|  |             endDate = endDateTime | ||||||
|  |         ).associateBy({ it.date }, { it.memberCount }) | ||||||
|  |  | ||||||
|  |         val signUpGoogleCountInRange = repository.getSignUpGoogleCountInRange( | ||||||
|  |             startDate = startDateTime, | ||||||
|  |             endDate = endDateTime | ||||||
|  |         ).associateBy({ it.date }, { it.memberCount }) | ||||||
|  |  | ||||||
|  |         val authCountInRange = repository.getAuthCountInRange( | ||||||
|  |             startDate = startDateTime, | ||||||
|  |             endDate = endDateTime | ||||||
|  |         ).associateBy({ it.date }, { it.memberCount }) | ||||||
|  |  | ||||||
|         val signOutCountInRange = repository.getSignOutCountInRange( |         val signOutCountInRange = repository.getSignOutCountInRange( | ||||||
|             startDate = startDateTime, |             startDate = startDateTime, | ||||||
|             endDate = endDateTime |             endDate = endDateTime | ||||||
| @@ -83,7 +116,11 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics | |||||||
|                 val date = it.format(formatter) |                 val date = it.format(formatter) | ||||||
|                 GetMemberStatisticsItem( |                 GetMemberStatisticsItem( | ||||||
|                     date = date, |                     date = date, | ||||||
|  |                     authCount = authCountInRange[date] ?: 0, | ||||||
|                     signUpCount = signUpCountInRange[date] ?: 0, |                     signUpCount = signUpCountInRange[date] ?: 0, | ||||||
|  |                     signUpEmailCount = signUpEmailCountInRange[date] ?: 0, | ||||||
|  |                     signUpKakaoCount = signUpKakaoCountInRange[date] ?: 0, | ||||||
|  |                     signUpGoogleCount = signUpGoogleCountInRange[date] ?: 0, | ||||||
|                     signOutCount = signOutCountInRange[date] ?: 0, |                     signOutCount = signOutCountInRange[date] ?: 0, | ||||||
|                     paymentMemberCount = paymentMemberCountInRangeMap[date] ?: 0 |                     paymentMemberCount = paymentMemberCountInRangeMap[date] ?: 0 | ||||||
|                 ) |                 ) | ||||||
| @@ -92,7 +129,11 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics | |||||||
|  |  | ||||||
|         return GetMemberStatisticsResponse( |         return GetMemberStatisticsResponse( | ||||||
|             totalCount = dateRange.totalDays, |             totalCount = dateRange.totalDays, | ||||||
|  |             totalAuthCount = totalAuthCount, | ||||||
|             totalSignUpCount = totalSignUpCount, |             totalSignUpCount = totalSignUpCount, | ||||||
|  |             totalSignUpEmailCount = totalSignUpEmailCount, | ||||||
|  |             totalSignUpKakaoCount = totalSignUpKakaoCount, | ||||||
|  |             totalSignUpGoogleCount = totalSignUpGoogleCount, | ||||||
|             totalSignOutCount = totalSignOutCount, |             totalSignOutCount = totalSignOutCount, | ||||||
|             totalPaymentMemberCount = totalPaymentMemberCount, |             totalPaymentMemberCount = totalPaymentMemberCount, | ||||||
|             items = items |             items = items | ||||||
|   | |||||||
| @@ -2,7 +2,11 @@ package kr.co.vividnext.sodalive.admin.statistics.member | |||||||
|  |  | ||||||
| data class GetMemberStatisticsResponse( | data class GetMemberStatisticsResponse( | ||||||
|     val totalCount: Int, |     val totalCount: Int, | ||||||
|  |     val totalAuthCount: Int, | ||||||
|     val totalSignUpCount: Int, |     val totalSignUpCount: Int, | ||||||
|  |     val totalSignUpEmailCount: Int, | ||||||
|  |     val totalSignUpKakaoCount: Int, | ||||||
|  |     val totalSignUpGoogleCount: Int, | ||||||
|     val totalSignOutCount: Int, |     val totalSignOutCount: Int, | ||||||
|     val totalPaymentMemberCount: Int, |     val totalPaymentMemberCount: Int, | ||||||
|     val items: List<GetMemberStatisticsItem> |     val items: List<GetMemberStatisticsItem> | ||||||
| @@ -10,7 +14,11 @@ data class GetMemberStatisticsResponse( | |||||||
|  |  | ||||||
| data class GetMemberStatisticsItem( | data class GetMemberStatisticsItem( | ||||||
|     val date: String, |     val date: String, | ||||||
|  |     val authCount: Int, | ||||||
|     val signUpCount: Int, |     val signUpCount: Int, | ||||||
|  |     val signUpEmailCount: Int, | ||||||
|  |     val signUpKakaoCount: Int, | ||||||
|  |     val signUpGoogleCount: Int, | ||||||
|     val signOutCount: Int, |     val signOutCount: Int, | ||||||
|     val paymentMemberCount: Int |     val paymentMemberCount: Int | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -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> | ||||||
|  | ) | ||||||
| @@ -18,13 +18,11 @@ class AuditionController(private val service: AuditionService) { | |||||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, |         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, | ||||||
|         pageable: Pageable |         pageable: Pageable | ||||||
|     ) = run { |     ) = run { | ||||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") |  | ||||||
|  |  | ||||||
|         ApiResponse.ok( |         ApiResponse.ok( | ||||||
|             service.getAuditionList( |             service.getAuditionList( | ||||||
|                 offset = pageable.offset, |                 offset = pageable.offset, | ||||||
|                 limit = pageable.pageSize.toLong(), |                 limit = pageable.pageSize.toLong(), | ||||||
|                 isAdult = member.auth != null |                 isAdult = member?.auth != null | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ interface AuditionQueryRepository { | |||||||
|     fun getCompletedAuditionCount(isAdult: Boolean): Int |     fun getCompletedAuditionCount(isAdult: Boolean): Int | ||||||
|     fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem> |     fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem> | ||||||
|     fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData |     fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData | ||||||
|  |     fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> | ||||||
| } | } | ||||||
|  |  | ||||||
| class AuditionQueryRepositoryImpl( | class AuditionQueryRepositoryImpl( | ||||||
| @@ -94,4 +95,27 @@ class AuditionQueryRepositoryImpl( | |||||||
|             .where(audition.id.eq(auditionId)) |             .where(audition.id.eq(auditionId)) | ||||||
|             .fetchFirst() |             .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 |             roleList = roleList | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> { | ||||||
|  |         return repository.getInProgressAuditionList(isAdult) | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -49,10 +49,12 @@ class AuditionApplicantQueryRepositoryImpl( | |||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select(auditionApplicant.id) |             .select(auditionApplicant.id) | ||||||
|             .from(auditionApplicant) |             .from(auditionApplicant) | ||||||
|  |             .innerJoin(auditionApplicant.member, member) | ||||||
|             .innerJoin(auditionApplicant.role, auditionRole) |             .innerJoin(auditionApplicant.role, auditionRole) | ||||||
|             .where( |             .where( | ||||||
|                 auditionRole.id.eq(auditionRoleId), |                 auditionRole.id.eq(auditionRoleId), | ||||||
|                 auditionApplicant.isActive.isTrue |                 auditionApplicant.isActive.isTrue, | ||||||
|  |                 member.isActive.isTrue | ||||||
|             ) |             ) | ||||||
|             .fetch() |             .fetch() | ||||||
|             .size |             .size | ||||||
| @@ -87,7 +89,8 @@ class AuditionApplicantQueryRepositoryImpl( | |||||||
|             .leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id)) |             .leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id)) | ||||||
|             .where( |             .where( | ||||||
|                 auditionRole.id.eq(auditionRoleId), |                 auditionRole.id.eq(auditionRoleId), | ||||||
|                 auditionApplicant.isActive.isTrue |                 auditionApplicant.isActive.isTrue, | ||||||
|  |                 member.isActive.isTrue | ||||||
|             ) |             ) | ||||||
|             .groupBy(auditionApplicant.id) |             .groupBy(auditionApplicant.id) | ||||||
|             .orderBy(orderBy) |             .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 | package kr.co.vividnext.sodalive.can | ||||||
|  |  | ||||||
| import kr.co.vividnext.sodalive.common.BaseEntity | import kr.co.vividnext.sodalive.common.BaseEntity | ||||||
|  | import java.math.BigDecimal | ||||||
|  | import javax.persistence.Column | ||||||
| import javax.persistence.Entity | import javax.persistence.Entity | ||||||
| import javax.persistence.EnumType | import javax.persistence.EnumType | ||||||
| import javax.persistence.Enumerated | import javax.persistence.Enumerated | ||||||
| @@ -10,7 +12,10 @@ data class Can( | |||||||
|     var title: String, |     var title: String, | ||||||
|     var can: Int, |     var can: Int, | ||||||
|     var rewardCan: 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) |     @Enumerated(value = EnumType.STRING) | ||||||
|     var status: CanStatus |     var status: CanStatus | ||||||
| ) : BaseEntity() | ) : BaseEntity() | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| package kr.co.vividnext.sodalive.can | package kr.co.vividnext.sodalive.can | ||||||
|  |  | ||||||
| import kr.co.vividnext.sodalive.common.ApiResponse | import kr.co.vividnext.sodalive.common.ApiResponse | ||||||
|  | import kr.co.vividnext.sodalive.common.GeoCountry | ||||||
| import kr.co.vividnext.sodalive.common.SodaException | import kr.co.vividnext.sodalive.common.SodaException | ||||||
| import kr.co.vividnext.sodalive.member.Member | import kr.co.vividnext.sodalive.member.Member | ||||||
| import org.springframework.data.domain.Pageable | import org.springframework.data.domain.Pageable | ||||||
| @@ -9,13 +10,15 @@ import org.springframework.web.bind.annotation.GetMapping | |||||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||||
| import org.springframework.web.bind.annotation.RequestParam | import org.springframework.web.bind.annotation.RequestParam | ||||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||||
|  | import javax.servlet.http.HttpServletRequest | ||||||
|  |  | ||||||
| @RestController | @RestController | ||||||
| @RequestMapping("/can") | @RequestMapping("/can") | ||||||
| class CanController(private val service: CanService) { | class CanController(private val service: CanService) { | ||||||
|     @GetMapping |     @GetMapping | ||||||
|     fun getCans(): ApiResponse<List<CanResponse>> { |     fun getCans(request: HttpServletRequest): ApiResponse<List<CanResponse>> { | ||||||
|         return ApiResponse.ok(service.getCans()) |         val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER | ||||||
|  |         return ApiResponse.ok(service.getCans(geoCountry)) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     @GetMapping("/status") |     @GetMapping("/status") | ||||||
|   | |||||||
| @@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository | |||||||
| interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository | interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository | ||||||
|  |  | ||||||
| interface CanQueryRepository { | interface CanQueryRepository { | ||||||
|     fun findAllByStatus(status: CanStatus): List<CanResponse> |     fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> | ||||||
|     fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan> |     fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan> | ||||||
|     fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge> |     fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge> | ||||||
|     fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? |     fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? | ||||||
| @@ -32,7 +32,7 @@ interface CanQueryRepository { | |||||||
|  |  | ||||||
| @Repository | @Repository | ||||||
| class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository { | class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository { | ||||||
|     override fun findAllByStatus(status: CanStatus): List<CanResponse> { |     override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> { | ||||||
|         return queryFactory |         return queryFactory | ||||||
|             .select( |             .select( | ||||||
|                 QCanResponse( |                 QCanResponse( | ||||||
| @@ -40,11 +40,16 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue | |||||||
|                     can1.title, |                     can1.title, | ||||||
|                     can1.can, |                     can1.can, | ||||||
|                     can1.rewardCan, |                     can1.rewardCan, | ||||||
|                     can1.price |                     can1.price.intValue(), | ||||||
|  |                     can1.currency, | ||||||
|  |                     can1.price.stringValue() | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|             .from(can1) |             .from(can1) | ||||||
|             .where(can1.status.eq(status)) |             .where( | ||||||
|  |                 can1.status.eq(status), | ||||||
|  |                 can1.currency.eq(currency) | ||||||
|  |             ) | ||||||
|             .orderBy(can1.can.asc()) |             .orderBy(can1.can.asc()) | ||||||
|             .fetch() |             .fetch() | ||||||
|     } |     } | ||||||
| @@ -64,11 +69,13 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue | |||||||
|         val chargeStatusCondition = when (container) { |         val chargeStatusCondition = when (container) { | ||||||
|             "aos" -> { |             "aos" -> { | ||||||
|                 charge.payment.paymentGateway.eq(PaymentGateway.PG) |                 charge.payment.paymentGateway.eq(PaymentGateway.PG) | ||||||
|  |                     .or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE)) | ||||||
|                     .or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP)) |                     .or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP)) | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             "ios" -> { |             "ios" -> { | ||||||
|                 charge.payment.paymentGateway.eq(PaymentGateway.PG) |                 charge.payment.paymentGateway.eq(PaymentGateway.PG) | ||||||
|  |                     .or(charge.payment.paymentGateway.eq(PaymentGateway.PAYVERSE)) | ||||||
|                     .or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP)) |                     .or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP)) | ||||||
|             } |             } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,5 +7,7 @@ data class CanResponse @QueryProjection constructor( | |||||||
|     val title: String, |     val title: String, | ||||||
|     val can: Int, |     val can: Int, | ||||||
|     val rewardCan: Int, |     val rewardCan: Int, | ||||||
|     val price: Int |     val price: Int, | ||||||
|  |     val currency: String, | ||||||
|  |     val priceStr: String | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.can | |||||||
| import kr.co.vividnext.sodalive.can.charge.ChargeStatus | import kr.co.vividnext.sodalive.can.charge.ChargeStatus | ||||||
| import kr.co.vividnext.sodalive.can.payment.PaymentGateway | import kr.co.vividnext.sodalive.can.payment.PaymentGateway | ||||||
| import kr.co.vividnext.sodalive.can.use.CanUsage | import kr.co.vividnext.sodalive.can.use.CanUsage | ||||||
|  | import kr.co.vividnext.sodalive.common.GeoCountry | ||||||
| import kr.co.vividnext.sodalive.member.Member | import kr.co.vividnext.sodalive.member.Member | ||||||
| import org.springframework.data.domain.Pageable | import org.springframework.data.domain.Pageable | ||||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||||
| @@ -11,8 +12,12 @@ import java.time.format.DateTimeFormatter | |||||||
|  |  | ||||||
| @Service | @Service | ||||||
| class CanService(private val repository: CanRepository) { | class CanService(private val repository: CanRepository) { | ||||||
|     fun getCans(): List<CanResponse> { |     fun getCans(geoCountry: GeoCountry): List<CanResponse> { | ||||||
|         return repository.findAllByStatus(status = CanStatus.SALE) |         val currency = when (geoCountry) { | ||||||
|  |             GeoCountry.KR -> "KRW" | ||||||
|  |             else -> "USD" | ||||||
|  |         } | ||||||
|  |         return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun getCanStatus(member: Member, container: String): GetCanStatusResponse { |     fun getCanStatus(member: Member, container: String): GetCanStatusResponse { | ||||||
| @@ -35,6 +40,7 @@ class CanService(private val repository: CanRepository) { | |||||||
|                     "aos" -> { |                     "aos" -> { | ||||||
|                         it.useCanCalculates.any { useCanCalculate -> |                         it.useCanCalculates.any { useCanCalculate -> | ||||||
|                             useCanCalculate.paymentGateway == PaymentGateway.PG || |                             useCanCalculate.paymentGateway == PaymentGateway.PG || | ||||||
|  |                                 useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE || | ||||||
|                                 useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP |                                 useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
| @@ -42,12 +48,14 @@ class CanService(private val repository: CanRepository) { | |||||||
|                     "ios" -> { |                     "ios" -> { | ||||||
|                         it.useCanCalculates.any { useCanCalculate -> |                         it.useCanCalculates.any { useCanCalculate -> | ||||||
|                             useCanCalculate.paymentGateway == PaymentGateway.PG || |                             useCanCalculate.paymentGateway == PaymentGateway.PG || | ||||||
|  |                                 useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE || | ||||||
|                                 useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP |                                 useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|                     else -> it.useCanCalculates.any { useCanCalculate -> |                     else -> it.useCanCalculates.any { useCanCalculate -> | ||||||
|                         useCanCalculate.paymentGateway == PaymentGateway.PG |                         useCanCalculate.paymentGateway == PaymentGateway.PG || | ||||||
|  |                             useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| @@ -72,6 +80,10 @@ class CanService(private val repository: CanRepository) { | |||||||
|                     CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}" |                     CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}" | ||||||
|                     CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}" |                     CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}" | ||||||
|                     CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" |                     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!! |                 val createdAt = it.createdAt!! | ||||||
|   | |||||||
| @@ -1,7 +1,9 @@ | |||||||
| package kr.co.vividnext.sodalive.can.charge | package kr.co.vividnext.sodalive.can.charge | ||||||
|  |  | ||||||
|  | import java.math.BigDecimal | ||||||
|  |  | ||||||
| data class ChargeCompleteResponse( | data class ChargeCompleteResponse( | ||||||
|     val price: Double, |     val price: BigDecimal, | ||||||
|     val currencyCode: String, |     val currencyCode: String, | ||||||
|     val isFirstCharged: Boolean |     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.AdTrackingHistoryType | ||||||
| import kr.co.vividnext.sodalive.marketing.AdTrackingService | import kr.co.vividnext.sodalive.marketing.AdTrackingService | ||||||
| import kr.co.vividnext.sodalive.member.Member | import kr.co.vividnext.sodalive.member.Member | ||||||
|  | import org.springframework.beans.factory.annotation.Value | ||||||
|  | import org.springframework.http.HttpStatus | ||||||
| import org.springframework.security.core.annotation.AuthenticationPrincipal | import org.springframework.security.core.annotation.AuthenticationPrincipal | ||||||
| import org.springframework.web.bind.annotation.PostMapping | import org.springframework.web.bind.annotation.PostMapping | ||||||
| import org.springframework.web.bind.annotation.RequestBody | import org.springframework.web.bind.annotation.RequestBody | ||||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||||
|  | import org.springframework.web.server.ResponseStatusException | ||||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||||
|  | import javax.servlet.http.HttpServletRequest | ||||||
|  |  | ||||||
| @RestController | @RestController | ||||||
| @RequestMapping("/charge") | @RequestMapping("/charge") | ||||||
| class ChargeController( | class ChargeController( | ||||||
|     private val service: ChargeService, |     private val service: ChargeService, | ||||||
|     private val trackingService: AdTrackingService |     private val trackingService: AdTrackingService, | ||||||
|  |  | ||||||
|  |     @Value("\${payverse.inbound-ip}") | ||||||
|  |     private val payverseInboundIp: String | ||||||
| ) { | ) { | ||||||
|  |  | ||||||
|  |     @PostMapping("/payverse") | ||||||
|  |     fun payverseCharge( | ||||||
|  |         @RequestBody request: PayverseChargeRequest, | ||||||
|  |         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||||
|  |     ) = run { | ||||||
|  |         if (member == null) { | ||||||
|  |             throw SodaException("로그인 정보를 확인해주세요.") | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         ApiResponse.ok(service.payverseCharge(member, request)) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @PostMapping("/payverse/verify") | ||||||
|  |     fun payverseVerify( | ||||||
|  |         @RequestBody verifyRequest: PayverseVerifyRequest, | ||||||
|  |         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||||
|  |     ) = run { | ||||||
|  |         if (member == null) { | ||||||
|  |             throw SodaException("로그인 정보를 확인해주세요.") | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val response = service.payverseVerify(memberId = member.id!!, verifyRequest) | ||||||
|  |         trackingCharge(member, response) | ||||||
|  |         ApiResponse.ok(Unit) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Payverse Webhook 엔드포인트 (payverseVerify 아래) | ||||||
|  |     @PostMapping("/payverse/webhook") | ||||||
|  |     fun payverseWebhook( | ||||||
|  |         @RequestBody request: PayverseWebhookRequest, | ||||||
|  |         servletRequest: HttpServletRequest | ||||||
|  |     ): PayverseWebhookResponse { | ||||||
|  |         val header = servletRequest.getHeader("X-Forwarded-For") | ||||||
|  |         val remoteIp = if (header.isNullOrEmpty()) { | ||||||
|  |             servletRequest.remoteAddr | ||||||
|  |         } else { | ||||||
|  |             header.split(",")[0].trim() // 첫 번째 값이 클라이언트 IP | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (remoteIp != payverseInboundIp) { | ||||||
|  |             throw ResponseStatusException(HttpStatus.NOT_FOUND) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val success = service.payverseWebhook(request) | ||||||
|  |         if (!success) { | ||||||
|  |             throw ResponseStatusException(HttpStatus.NOT_FOUND) | ||||||
|  |         } | ||||||
|  |         return PayverseWebhookResponse(receiveResult = "SUCCESS") | ||||||
|  |     } | ||||||
|  |  | ||||||
|     @PostMapping |     @PostMapping | ||||||
|     fun charge( |     fun charge( | ||||||
|         @RequestBody chargeRequest: ChargeRequest, |         @RequestBody chargeRequest: ChargeRequest, | ||||||
| @@ -111,8 +168,7 @@ class ChargeController( | |||||||
|                 memberId = member.id!!, |                 memberId = member.id!!, | ||||||
|                 chargeId = chargeId, |                 chargeId = chargeId, | ||||||
|                 productId = request.productId, |                 productId = request.productId, | ||||||
|                 purchaseToken = request.purchaseToken, |                 purchaseToken = request.purchaseToken | ||||||
|                 paymentGateway = request.paymentGateway |  | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             trackingCharge(member, response) |             trackingCharge(member, response) | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.charge | |||||||
|  |  | ||||||
| import com.fasterxml.jackson.annotation.JsonProperty | import com.fasterxml.jackson.annotation.JsonProperty | ||||||
| import kr.co.vividnext.sodalive.can.payment.PaymentGateway | import kr.co.vividnext.sodalive.can.payment.PaymentGateway | ||||||
|  | import java.math.BigDecimal | ||||||
|  |  | ||||||
| data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway) | data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway) | ||||||
|  |  | ||||||
| @@ -20,14 +21,14 @@ data class VerifyResult( | |||||||
|     val method: String, |     val method: String, | ||||||
|     val pg: String, |     val pg: String, | ||||||
|     val status: Int, |     val status: Int, | ||||||
|     val price: Int |     val price: BigDecimal | ||||||
| ) | ) | ||||||
|  |  | ||||||
| data class AppleChargeRequest( | data class AppleChargeRequest( | ||||||
|     val title: String, |     val title: String, | ||||||
|     val chargeCan: Int, |     val chargeCan: Int, | ||||||
|     val paymentGateway: PaymentGateway, |     val paymentGateway: PaymentGateway, | ||||||
|     var price: Double? = null, |     var price: BigDecimal? = null, | ||||||
|     var locale: String? = null |     var locale: String? = null | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -38,9 +39,53 @@ data class AppleVerifyResponse(val status: Int) | |||||||
| data class GoogleChargeRequest( | data class GoogleChargeRequest( | ||||||
|     val title: String, |     val title: String, | ||||||
|     val chargeCan: Int, |     val chargeCan: Int, | ||||||
|     val price: Double, |     val price: BigDecimal, | ||||||
|     val currencyCode: String, |     val currencyCode: String, | ||||||
|     val productId: String, |     val productId: String, | ||||||
|     val purchaseToken: String, |     val purchaseToken: String, | ||||||
|     val paymentGateway: PaymentGateway |     val paymentGateway: PaymentGateway | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | data class PayverseChargeRequest( | ||||||
|  |     val canId: Long | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class PayverseChargeResponse( | ||||||
|  |     val chargeId: Long, | ||||||
|  |     val payloadJson: String | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class PayverseVerifyRequest( | ||||||
|  |     val transactionId: String, | ||||||
|  |     val orderId: String | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class PayverseVerifyResponse( | ||||||
|  |     val resultStatus: String, | ||||||
|  |     val tid: String, | ||||||
|  |     val schemeGroup: String, | ||||||
|  |     val schemeCode: String, | ||||||
|  |     val transactionType: String, | ||||||
|  |     val transactionStatus: String, | ||||||
|  |     val transactionMessage: String, | ||||||
|  |     val orderId: String, | ||||||
|  |     val customerId: String, | ||||||
|  |     val requestCurrency: String, | ||||||
|  |     val requestAmount: BigDecimal | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class PayverseWebhookRequest( | ||||||
|  |     val type: String, | ||||||
|  |     val mid: String, | ||||||
|  |     val tid: String, | ||||||
|  |     val schemeGroup: String, | ||||||
|  |     val schemeCode: String, | ||||||
|  |     val orderId: String, | ||||||
|  |     val requestCurrency: String, | ||||||
|  |     val requestAmount: BigDecimal, | ||||||
|  |     val resultStatus: String, | ||||||
|  |     val approvalDay: String, | ||||||
|  |     val sign: String | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class PayverseWebhookResponse(val receiveResult: String) | ||||||
|   | |||||||
| @@ -113,15 +113,18 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha | |||||||
|         val paymentGatewayCondition = when (container) { |         val paymentGatewayCondition = when (container) { | ||||||
|             "aos" -> { |             "aos" -> { | ||||||
|                 payment.paymentGateway.eq(PaymentGateway.PG) |                 payment.paymentGateway.eq(PaymentGateway.PG) | ||||||
|  |                     .or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE)) | ||||||
|                     .or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP)) |                     .or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP)) | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             "ios" -> { |             "ios" -> { | ||||||
|                 payment.paymentGateway.eq(PaymentGateway.PG) |                 payment.paymentGateway.eq(PaymentGateway.PG) | ||||||
|  |                     .or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE)) | ||||||
|                     .or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP)) |                     .or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP)) | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             else -> payment.paymentGateway.eq(PaymentGateway.PG) |             else -> payment.paymentGateway.eq(PaymentGateway.PG) | ||||||
|  |                 .or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE)) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return paymentGatewayCondition.or(payment.paymentGateway.eq(PaymentGateway.POINT_CLICK_AD)) |         return paymentGatewayCondition.or(payment.paymentGateway.eq(PaymentGateway.POINT_CLICK_AD)) | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import kr.co.bootpay.Bootpay | |||||||
| import kr.co.vividnext.sodalive.can.CanRepository | import kr.co.vividnext.sodalive.can.CanRepository | ||||||
| import kr.co.vividnext.sodalive.can.charge.event.ChargeSpringEvent | import kr.co.vividnext.sodalive.can.charge.event.ChargeSpringEvent | ||||||
| import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository | 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.Payment | ||||||
| import kr.co.vividnext.sodalive.can.payment.PaymentGateway | import kr.co.vividnext.sodalive.can.payment.PaymentGateway | ||||||
| import kr.co.vividnext.sodalive.can.payment.PaymentStatus | 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.google.GooglePlayService | ||||||
| import kr.co.vividnext.sodalive.member.Member | import kr.co.vividnext.sodalive.member.Member | ||||||
| import kr.co.vividnext.sodalive.member.MemberRepository | 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.MediaType.Companion.toMediaTypeOrNull | ||||||
| import okhttp3.OkHttpClient | import okhttp3.OkHttpClient | ||||||
| import okhttp3.Request | import okhttp3.Request | ||||||
| import okhttp3.RequestBody.Companion.toRequestBody | import okhttp3.RequestBody.Companion.toRequestBody | ||||||
|  | import org.apache.commons.codec.digest.DigestUtils | ||||||
| import org.json.JSONObject | import org.json.JSONObject | ||||||
| import org.springframework.beans.factory.annotation.Value | import org.springframework.beans.factory.annotation.Value | ||||||
| import org.springframework.context.ApplicationEventPublisher | import org.springframework.context.ApplicationEventPublisher | ||||||
| @@ -27,6 +34,8 @@ import org.springframework.stereotype.Service | |||||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||||
| import java.math.RoundingMode | import java.math.RoundingMode | ||||||
|  | import java.time.LocalDateTime | ||||||
|  | import java.time.format.DateTimeFormatter | ||||||
|  |  | ||||||
| @Service | @Service | ||||||
| @Transactional(readOnly = true) | @Transactional(readOnly = true) | ||||||
| @@ -36,6 +45,9 @@ class ChargeService( | |||||||
|     private val memberRepository: MemberRepository, |     private val memberRepository: MemberRepository, | ||||||
|     private val couponNumberRepository: CanCouponNumberRepository, |     private val couponNumberRepository: CanCouponNumberRepository, | ||||||
|  |  | ||||||
|  |     private val grantLogRepository: PointGrantLogRepository, | ||||||
|  |     private val memberPointRepository: MemberPointRepository, | ||||||
|  |  | ||||||
|     private val objectMapper: ObjectMapper, |     private val objectMapper: ObjectMapper, | ||||||
|     private val okHttpClient: OkHttpClient, |     private val okHttpClient: OkHttpClient, | ||||||
|     private val applicationEventPublisher: ApplicationEventPublisher, |     private val applicationEventPublisher: ApplicationEventPublisher, | ||||||
| @@ -53,34 +65,341 @@ class ChargeService( | |||||||
|     @Value("\${apple.iap-verify-sandbox-url}") |     @Value("\${apple.iap-verify-sandbox-url}") | ||||||
|     private val appleInAppVerifySandBoxUrl: String, |     private val appleInAppVerifySandBoxUrl: String, | ||||||
|     @Value("\${apple.iap-verify-url}") |     @Value("\${apple.iap-verify-url}") | ||||||
|     private val appleInAppVerifyUrl: String |     private val appleInAppVerifyUrl: String, | ||||||
|  |  | ||||||
|  |     @Value("\${payverse.mid}") | ||||||
|  |     private val payverseMid: String, | ||||||
|  |     @Value("\${payverse.client-key}") | ||||||
|  |     private val payverseClientKey: String, | ||||||
|  |     @Value("\${payverse.secret-key}") | ||||||
|  |     private val payverseSecretKey: String, | ||||||
|  |  | ||||||
|  |     @Value("\${payverse.usd-mid}") | ||||||
|  |     private val payverseUsdMid: String, | ||||||
|  |     @Value("\${payverse.usd-client-key}") | ||||||
|  |     private val payverseUsdClientKey: String, | ||||||
|  |     @Value("\${payverse.usd-secret-key}") | ||||||
|  |     private val payverseUsdSecretKey: String, | ||||||
|  |  | ||||||
|  |     @Value("\${payverse.host}") | ||||||
|  |     private val payverseHost: String, | ||||||
|  |  | ||||||
|  |     @Value("\${server.env}") | ||||||
|  |     private val serverEnv: String | ||||||
| ) { | ) { | ||||||
|  |  | ||||||
|     @Transactional |     @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) |         val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber) | ||||||
|             ?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.") |             ?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.") | ||||||
|  |  | ||||||
|         if (canCouponNumber.member != null) { |         if (canCouponNumber.member != null) { | ||||||
|             throw SodaException("이미 사용한 쿠폰번호 입니다.") |             throw SodaException("이미 사용한 쿠폰번호 입니다.") | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         canCouponNumber.member = member |         canCouponNumber.member = member | ||||||
|  |  | ||||||
|         val coupon = canCouponNumber.canCoupon!! |         val coupon = canCouponNumber.canCoupon!! | ||||||
|         val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON) |  | ||||||
|         couponCharge.title = "${coupon.can} 캔" |  | ||||||
|         couponCharge.member = member |  | ||||||
|  |  | ||||||
|         val payment = Payment( |         when (coupon.couponType) { | ||||||
|             status = PaymentStatus.COMPLETE, |             CouponType.CAN -> { | ||||||
|             paymentGateway = PaymentGateway.PG |                 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 |         val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) | ||||||
|         couponCharge.payment = payment |         val sign = DigestUtils.sha512Hex( | ||||||
|         chargeRepository.save(couponCharge) |             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 |     @Transactional | ||||||
| @@ -94,7 +413,7 @@ class ChargeService( | |||||||
|         charge.can = can |         charge.can = can | ||||||
|  |  | ||||||
|         val payment = Payment(paymentGateway = request.paymentGateway) |         val payment = Payment(paymentGateway = request.paymentGateway) | ||||||
|         payment.price = can.price.toDouble() |         payment.price = can.price | ||||||
|         charge.payment = payment |         charge.payment = payment | ||||||
|  |  | ||||||
|         chargeRepository.save(charge) |         chargeRepository.save(charge) | ||||||
| @@ -133,14 +452,14 @@ class ChargeService( | |||||||
|                     ) |                     ) | ||||||
|  |  | ||||||
|                     return ChargeCompleteResponse( |                     return ChargeCompleteResponse( | ||||||
|                         price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), |                         price = charge.payment!!.price, | ||||||
|                         currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", |                         currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", | ||||||
|                         isFirstCharged = chargeRepository.isFirstCharged(memberId) |                         isFirstCharged = chargeRepository.isFirstCharged(memberId) | ||||||
|                     ) |                     ) | ||||||
|                 } else { |                 } else { | ||||||
|                     throw SodaException("결제정보에 오류가 있습니다.") |                     throw SodaException("결제정보에 오류가 있습니다.") | ||||||
|                 } |                 } | ||||||
|             } catch (e: Exception) { |             } catch (_: Exception) { | ||||||
|                 throw SodaException("결제정보에 오류가 있습니다.") |                 throw SodaException("결제정보에 오류가 있습니다.") | ||||||
|             } |             } | ||||||
|         } else { |         } else { | ||||||
| @@ -165,7 +484,7 @@ class ChargeService( | |||||||
|                     VerifyResult::class.java |                     VerifyResult::class.java | ||||||
|                 ) |                 ) | ||||||
|  |  | ||||||
|                 if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) { |                 if (verifyResult.status == 1) { | ||||||
|                     charge.payment?.receiptId = verifyResult.receiptId |                     charge.payment?.receiptId = verifyResult.receiptId | ||||||
|                     charge.payment?.method = if (verifyResult.pg.contains("카카오")) { |                     charge.payment?.method = if (verifyResult.pg.contains("카카오")) { | ||||||
|                         "${verifyResult.pg}-${verifyResult.method}" |                         "${verifyResult.pg}-${verifyResult.method}" | ||||||
| @@ -183,14 +502,14 @@ class ChargeService( | |||||||
|                     ) |                     ) | ||||||
|  |  | ||||||
|                     return ChargeCompleteResponse( |                     return ChargeCompleteResponse( | ||||||
|                         price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), |                         price = charge.payment!!.price, | ||||||
|                         currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", |                         currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", | ||||||
|                         isFirstCharged = chargeRepository.isFirstCharged(memberId) |                         isFirstCharged = chargeRepository.isFirstCharged(memberId) | ||||||
|                     ) |                     ) | ||||||
|                 } else { |                 } else { | ||||||
|                     throw SodaException("결제정보에 오류가 있습니다.") |                     throw SodaException("결제정보에 오류가 있습니다.") | ||||||
|                 } |                 } | ||||||
|             } catch (e: Exception) { |             } catch (_: Exception) { | ||||||
|                 throw SodaException("결제정보에 오류가 있습니다.") |                 throw SodaException("결제정보에 오류가 있습니다.") | ||||||
|             } |             } | ||||||
|         } else { |         } else { | ||||||
| @@ -208,7 +527,7 @@ class ChargeService( | |||||||
|         payment.price = if (request.price != null) { |         payment.price = if (request.price != null) { | ||||||
|             request.price!! |             request.price!! | ||||||
|         } else { |         } else { | ||||||
|             0.toDouble() |             0.toBigDecimal() | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         payment.locale = request.locale |         payment.locale = request.locale | ||||||
| @@ -243,7 +562,7 @@ class ChargeService( | |||||||
|                 ) |                 ) | ||||||
|  |  | ||||||
|                 return ChargeCompleteResponse( |                 return ChargeCompleteResponse( | ||||||
|                     price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), |                     price = charge.payment!!.price, | ||||||
|                     currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", |                     currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", | ||||||
|                     isFirstCharged = chargeRepository.isFirstCharged(memberId) |                     isFirstCharged = chargeRepository.isFirstCharged(memberId) | ||||||
|                 ) |                 ) | ||||||
| @@ -260,7 +579,7 @@ class ChargeService( | |||||||
|         member: Member, |         member: Member, | ||||||
|         title: String, |         title: String, | ||||||
|         chargeCan: Int, |         chargeCan: Int, | ||||||
|         price: Double, |         price: BigDecimal, | ||||||
|         currencyCode: String, |         currencyCode: String, | ||||||
|         productId: String, |         productId: String, | ||||||
|         purchaseToken: String, |         purchaseToken: String, | ||||||
| @@ -288,8 +607,7 @@ class ChargeService( | |||||||
|         memberId: Long, |         memberId: Long, | ||||||
|         chargeId: Long, |         chargeId: Long, | ||||||
|         productId: String, |         productId: String, | ||||||
|         purchaseToken: String, |         purchaseToken: String | ||||||
|         paymentGateway: PaymentGateway |  | ||||||
|     ): ChargeCompleteResponse { |     ): ChargeCompleteResponse { | ||||||
|         val charge = chargeRepository.findByIdOrNull(id = chargeId) |         val charge = chargeRepository.findByIdOrNull(id = chargeId) | ||||||
|             ?: throw SodaException("결제정보에 오류가 있습니다.") |             ?: throw SodaException("결제정보에 오류가 있습니다.") | ||||||
| @@ -311,7 +629,7 @@ class ChargeService( | |||||||
|                 ) |                 ) | ||||||
|  |  | ||||||
|                 return ChargeCompleteResponse( |                 return ChargeCompleteResponse( | ||||||
|                     price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(), |                     price = charge.payment!!.price, | ||||||
|                     currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", |                     currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", | ||||||
|                     isFirstCharged = chargeRepository.isFirstCharged(memberId) |                     isFirstCharged = chargeRepository.isFirstCharged(memberId) | ||||||
|                 ) |                 ) | ||||||
| @@ -393,4 +711,13 @@ class ChargeService( | |||||||
|             throw SodaException("결제를 완료하지 못했습니다.") |             throw SodaException("결제를 완료하지 못했습니다.") | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환 | ||||||
|  |     private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? { | ||||||
|  |         val cardCodes = setOf( | ||||||
|  |             "041", "044", "361", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "381", | ||||||
|  |             "218", "071", "002", "089", "045", "050", "048", "090", "092" | ||||||
|  |         ) | ||||||
|  |         return if (schemeCode != null && cardCodes.contains(schemeCode)) "카드" else null | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,9 +1,10 @@ | |||||||
| package kr.co.vividnext.sodalive.can.charge.temp | package kr.co.vividnext.sodalive.can.charge.temp | ||||||
|  |  | ||||||
| import kr.co.vividnext.sodalive.can.payment.PaymentGateway | import kr.co.vividnext.sodalive.can.payment.PaymentGateway | ||||||
|  | import java.math.BigDecimal | ||||||
|  |  | ||||||
| data class ChargeTempRequest( | data class ChargeTempRequest( | ||||||
|     val can: Int, |     val can: Int, | ||||||
|     val price: Int, |     val price: BigDecimal, | ||||||
|     val paymentGateway: PaymentGateway |     val paymentGateway: PaymentGateway | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -41,7 +41,7 @@ class ChargeTempService( | |||||||
|         charge.member = member |         charge.member = member | ||||||
|  |  | ||||||
|         val payment = Payment(paymentGateway = request.paymentGateway) |         val payment = Payment(paymentGateway = request.paymentGateway) | ||||||
|         payment.price = request.price.toDouble() |         payment.price = request.price | ||||||
|         charge.payment = payment |         charge.payment = payment | ||||||
|  |  | ||||||
|         chargeRepository.save(charge) |         chargeRepository.save(charge) | ||||||
| @@ -66,7 +66,7 @@ class ChargeTempService( | |||||||
|                     VerifyResult::class.java |                     VerifyResult::class.java | ||||||
|                 ) |                 ) | ||||||
|  |  | ||||||
|                 if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) { |                 if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price) { | ||||||
|                     charge.payment?.receiptId = verifyResult.receiptId |                     charge.payment?.receiptId = verifyResult.receiptId | ||||||
|                     charge.payment?.method = verifyResult.method |                     charge.payment?.method = verifyResult.method | ||||||
|                     charge.payment?.status = PaymentStatus.COMPLETE |                     charge.payment?.status = PaymentStatus.COMPLETE | ||||||
| @@ -74,7 +74,7 @@ class ChargeTempService( | |||||||
|                 } else { |                 } else { | ||||||
|                     throw SodaException("결제정보에 오류가 있습니다.") |                     throw SodaException("결제정보에 오류가 있습니다.") | ||||||
|                 } |                 } | ||||||
|             } catch (e: Exception) { |             } catch (_: Exception) { | ||||||
|                 throw SodaException("결제정보에 오류가 있습니다.") |                 throw SodaException("결제정보에 오류가 있습니다.") | ||||||
|             } |             } | ||||||
|         } else { |         } else { | ||||||
|   | |||||||
| @@ -3,13 +3,21 @@ package kr.co.vividnext.sodalive.can.coupon | |||||||
| import kr.co.vividnext.sodalive.common.BaseEntity | import kr.co.vividnext.sodalive.common.BaseEntity | ||||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||||
| import javax.persistence.Entity | import javax.persistence.Entity | ||||||
|  | import javax.persistence.EnumType | ||||||
|  | import javax.persistence.Enumerated | ||||||
|  |  | ||||||
| @Entity | @Entity | ||||||
| data class CanCoupon( | data class CanCoupon( | ||||||
|     val couponName: String, |     val couponName: String, | ||||||
|  |     @Enumerated(EnumType.STRING) | ||||||
|  |     val couponType: CouponType, | ||||||
|     val can: Int, |     val can: Int, | ||||||
|     val couponCount: Int, |     val couponCount: Int, | ||||||
|     var validity: LocalDateTime, |     var validity: LocalDateTime, | ||||||
|     var isActive: Boolean, |     var isActive: Boolean, | ||||||
|     var isMultipleUse: Boolean |     var isMultipleUse: Boolean | ||||||
| ) : BaseEntity() | ) : BaseEntity() | ||||||
|  |  | ||||||
|  | enum class CouponType { | ||||||
|  |     CAN, POINT | ||||||
|  | } | ||||||
|   | |||||||
| @@ -109,11 +109,11 @@ class CanCouponController(private val service: CanCouponService) { | |||||||
|     ) = run { |     ) = run { | ||||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") |         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||||
|  |  | ||||||
|         ApiResponse.ok( |         val completeMessage = service.useCanCoupon( | ||||||
|             service.useCanCoupon( |             couponNumber = request.couponNumber, | ||||||
|                 couponNumber = request.couponNumber, |             memberId = member.id!! | ||||||
|                 memberId = member.id!! |  | ||||||
|             ) |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         ApiResponse.ok(Unit, completeMessage) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -79,6 +79,7 @@ class CanCouponNumberQueryRepositoryImpl(private val queryFactory: JPAQueryFacto | |||||||
|     override fun findByCouponNumber(couponNumber: String): CanCouponNumber? { |     override fun findByCouponNumber(couponNumber: String): CanCouponNumber? { | ||||||
|         return queryFactory |         return queryFactory | ||||||
|             .selectFrom(canCouponNumber) |             .selectFrom(canCouponNumber) | ||||||
|  |             .innerJoin(canCouponNumber.canCoupon, canCoupon) | ||||||
|             .where(canCouponNumber.couponNumber.eq(couponNumber)) |             .where(canCouponNumber.couponNumber.eq(couponNumber)) | ||||||
|             .fetchFirst() |             .fetchFirst() | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| package kr.co.vividnext.sodalive.can.coupon | 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.DateTimePath | ||||||
| import com.querydsl.core.types.dsl.Expressions | import com.querydsl.core.types.dsl.Expressions | ||||||
| import com.querydsl.core.types.dsl.StringTemplate | import com.querydsl.core.types.dsl.StringTemplate | ||||||
| @@ -30,6 +31,9 @@ class CanCouponQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : | |||||||
|                 QGetCouponListItemResponse( |                 QGetCouponListItemResponse( | ||||||
|                     canCoupon.id, |                     canCoupon.id, | ||||||
|                     canCoupon.couponName, |                     canCoupon.couponName, | ||||||
|  |                     CaseBuilder() | ||||||
|  |                         .`when`(canCoupon.couponType.eq(CouponType.POINT)).then("포인트 쿠폰") | ||||||
|  |                         .otherwise("캔 쿠폰"), | ||||||
|                     canCoupon.can, |                     canCoupon.can, | ||||||
|                     canCoupon.couponCount, |                     canCoupon.couponCount, | ||||||
|                     Expressions.ZERO, |                     Expressions.ZERO, | ||||||
|   | |||||||
| @@ -68,15 +68,12 @@ class CanCouponService( | |||||||
|  |  | ||||||
|     fun getCouponList(offset: Long, limit: Long): GetCouponListResponse { |     fun getCouponList(offset: Long, limit: Long): GetCouponListResponse { | ||||||
|         val totalCount = repository.getCouponTotalCount() |         val totalCount = repository.getCouponTotalCount() | ||||||
|  |  | ||||||
|         val items = repository.getCouponList(offset = offset, limit = limit) |         val items = repository.getCouponList(offset = offset, limit = limit) | ||||||
|             .asSequence() |  | ||||||
|             .map { |             .map { | ||||||
|                 val useCouponCount = couponNumberRepository.getUseCouponCount(id = it.id) |                 val useCouponCount = couponNumberRepository.getUseCouponCount(id = it.id) | ||||||
|                 it.useCouponCount = useCouponCount |                 it.useCouponCount = useCouponCount | ||||||
|                 it |                 it | ||||||
|             } |             } | ||||||
|             .toList() |  | ||||||
|  |  | ||||||
|         return GetCouponListResponse(totalCount, items) |         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) |         val member = memberRepository.findByIdOrNull(id = memberId) | ||||||
|             ?: throw SodaException("로그인 정보를 확인해주세요.") |             ?: throw SodaException("로그인 정보를 확인해주세요.") | ||||||
|  |  | ||||||
| @@ -132,7 +129,7 @@ class CanCouponService( | |||||||
|  |  | ||||||
|         issueService.validateAvailableUseCoupon(couponNumber, memberId) |         issueService.validateAvailableUseCoupon(couponNumber, memberId) | ||||||
|  |  | ||||||
|         chargeService.chargeByCoupon(couponNumber, member) |         return chargeService.chargeByCoupon(couponNumber, member) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun insertHyphens(input: String): String { |     private fun insertHyphens(input: String): String { | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty | |||||||
|  |  | ||||||
| data class GenerateCanCouponRequest( | data class GenerateCanCouponRequest( | ||||||
|     @JsonProperty("couponName") val couponName: String, |     @JsonProperty("couponName") val couponName: String, | ||||||
|  |     @JsonProperty("couponType") val couponType: CouponType, | ||||||
|     @JsonProperty("can") val can: Int, |     @JsonProperty("can") val can: Int, | ||||||
|     @JsonProperty("validity") val validity: String, |     @JsonProperty("validity") val validity: String, | ||||||
|     @JsonProperty("isMultipleUse") val isMultipleUse: Boolean, |     @JsonProperty("isMultipleUse") val isMultipleUse: Boolean, | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ data class GetCouponListResponse( | |||||||
| data class GetCouponListItemResponse @QueryProjection constructor( | data class GetCouponListItemResponse @QueryProjection constructor( | ||||||
|     val id: Long, |     val id: Long, | ||||||
|     val couponName: String, |     val couponName: String, | ||||||
|  |     val couponType: String, | ||||||
|     val can: Int, |     val can: Int, | ||||||
|     val couponCount: Int, |     val couponCount: Int, | ||||||
|     var useCouponCount: 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.UseCanCalculateRepository | ||||||
| import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus | import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus | ||||||
| import kr.co.vividnext.sodalive.can.use.UseCanRepository | 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.common.SodaException | ||||||
| import kr.co.vividnext.sodalive.content.AudioContent | import kr.co.vividnext.sodalive.content.AudioContent | ||||||
| import kr.co.vividnext.sodalive.content.order.Order | import kr.co.vividnext.sodalive.content.order.Order | ||||||
| @@ -37,6 +38,8 @@ class CanPaymentService( | |||||||
|         memberId: Long, |         memberId: Long, | ||||||
|         needCan: Int, |         needCan: Int, | ||||||
|         canUsage: CanUsage, |         canUsage: CanUsage, | ||||||
|  |         chatRoomId: Long? = null, | ||||||
|  |         characterId: Long? = null, | ||||||
|         isSecret: Boolean = false, |         isSecret: Boolean = false, | ||||||
|         liveRoom: LiveRoom? = null, |         liveRoom: LiveRoom? = null, | ||||||
|         order: Order? = null, |         order: Order? = null, | ||||||
| @@ -109,6 +112,14 @@ class CanPaymentService( | |||||||
|             recipientId = liveRoom.member!!.id!! |             recipientId = liveRoom.member!!.id!! | ||||||
|             useCan.room = liveRoom |             useCan.room = liveRoom | ||||||
|             useCan.member = member |             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 { |         } else { | ||||||
|             throw SodaException("잘못된 요청입니다.") |             throw SodaException("잘못된 요청입니다.") | ||||||
|         } |         } | ||||||
| @@ -116,6 +127,7 @@ class CanPaymentService( | |||||||
|         useCanRepository.save(useCan) |         useCanRepository.save(useCan) | ||||||
|  |  | ||||||
|         setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG) |         setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG) | ||||||
|  |         setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE) | ||||||
|         setUseCanCalculate( |         setUseCanCalculate( | ||||||
|             recipientId, |             recipientId, | ||||||
|             useRewardCan, |             useRewardCan, | ||||||
| @@ -327,4 +339,100 @@ class CanPaymentService( | |||||||
|             chargeRepository.save(charge) |             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.can.charge.Charge | ||||||
| import kr.co.vividnext.sodalive.common.BaseEntity | import kr.co.vividnext.sodalive.common.BaseEntity | ||||||
|  | import java.math.BigDecimal | ||||||
| import javax.persistence.Column | import javax.persistence.Column | ||||||
| import javax.persistence.Entity | import javax.persistence.Entity | ||||||
| import javax.persistence.EnumType | import javax.persistence.EnumType | ||||||
| @@ -25,7 +26,8 @@ data class Payment( | |||||||
|     var receiptId: String? = null |     var receiptId: String? = null | ||||||
|     var method: 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 locale: String? = null | ||||||
|     var orderId: String? = null |     var orderId: String? = null | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| package kr.co.vividnext.sodalive.can.payment | package kr.co.vividnext.sodalive.can.payment | ||||||
|  |  | ||||||
| enum class PaymentGateway { | enum class PaymentGateway { | ||||||
|     PG, 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, |     SPIN_ROULETTE, | ||||||
|     PAID_COMMUNITY_POST, |     PAID_COMMUNITY_POST, | ||||||
|     ALARM_SLOT, |     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 | package kr.co.vividnext.sodalive.can.use | ||||||
|  |  | ||||||
| import kr.co.vividnext.sodalive.audition.AuditionApplicant | 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.common.BaseEntity | ||||||
| import kr.co.vividnext.sodalive.content.AudioContent | import kr.co.vividnext.sodalive.content.AudioContent | ||||||
| import kr.co.vividnext.sodalive.content.order.Order | import kr.co.vividnext.sodalive.content.order.Order | ||||||
| @@ -28,7 +30,11 @@ data class UseCan( | |||||||
|  |  | ||||||
|     var isRefund: Boolean = false, |     var isRefund: Boolean = false, | ||||||
|  |  | ||||||
|     val isSecret: Boolean = false |     val isSecret: Boolean = false, | ||||||
|  |  | ||||||
|  |     // 채팅 연동을 위한 식별자 (옵션) | ||||||
|  |     var chatRoomId: Long? = null, | ||||||
|  |     var characterId: Long? = null | ||||||
| ) : BaseEntity() { | ) : BaseEntity() { | ||||||
|     @ManyToOne(fetch = FetchType.LAZY) |     @ManyToOne(fetch = FetchType.LAZY) | ||||||
|     @JoinColumn(name = "member_id", nullable = false) |     @JoinColumn(name = "member_id", nullable = false) | ||||||
| @@ -58,6 +64,16 @@ data class UseCan( | |||||||
|     @JoinColumn(name = "audition_applicant_id", nullable = true) |     @JoinColumn(name = "audition_applicant_id", nullable = true) | ||||||
|     var auditionApplicant: AuditionApplicant? = null |     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]) |     @OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL]) | ||||||
|     val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf() |     val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf() | ||||||
| } | } | ||||||
|   | |||||||
| @@ -6,23 +6,56 @@ import org.springframework.data.jpa.repository.JpaRepository | |||||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||||
|  |  | ||||||
| @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 { | 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 { | 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) |         val useCanId = queryFactory.select(useCan.id) | ||||||
|             .from(useCan) |             .from(useCan) | ||||||
|             .where( |             .where( | ||||||
|                 useCan.member.id.eq(memberId) |                 useCan.member.id.eq(memberId) | ||||||
|                     .and(useCan.isRefund.isFalse) |                     .and(useCan.isRefund.isFalse) | ||||||
|                     .and(useCan.communityPost.id.eq(postId)) |                     .and(useCan.communityPost.id.eq(postId)) | ||||||
|  |                     .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) | ||||||
|             ) |             ) | ||||||
|             .fetchFirst() |             .fetchFirst() | ||||||
|  |  | ||||||
|         return useCanId != null && useCanId > 0 |         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() | ||||||
|  | } | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user