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