Compare commits
456 Commits
3c32614d1c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 587f3d6b58 | |||
| 76806e2e90 | |||
| 39c51825da | |||
| 9b6167d46d | |||
| 9a58b7b95f | |||
| 26eae4b06e | |||
| 008ee3b4e5 | |||
| 60989391f6 | |||
| 88d90eec2f | |||
| b6eb13df06 | |||
| 3a57ad23bb | |||
| a6b815ad05 | |||
| d89122802a | |||
| 729552335a | |||
| 690432d6ee | |||
| bc358d18de | |||
| 02ae507c87 | |||
| add88aca35 | |||
| 5818abf69d | |||
| b6971f6a8d | |||
| ee403915f0 | |||
| f83dd47c7c | |||
| 146f733f5d | |||
| 806fcfe7db | |||
| 1a660088de | |||
| 04e7c90407 | |||
| f278497526 | |||
| 5196c80ca8 | |||
| 597bd8f8ae | |||
| e4c1cf5a9a | |||
| 9f6bdf6ed8 | |||
| 4f89b0189e | |||
| 27be9a4fc2 | |||
| 9464cc5ed4 | |||
| 39760e16ff | |||
| bf149c45ad | |||
| 4f52ec0663 | |||
| 3ed306ae8c | |||
| ee35244296 | |||
| fe76ecdfa9 | |||
| 16b6c13309 | |||
| 80c44373c7 | |||
| a538bb766d | |||
| c9c09c2998 | |||
| 26c09de7c9 | |||
| 82bd93c1ae | |||
| e24e8372a8 | |||
| eab7dc4521 | |||
| 3ea33c4c7b | |||
| 5ca666c7fa | |||
| 451a1aa4f2 | |||
| 8fb3bd578f | |||
| 01fad8d93c | |||
| 90555fd34f | |||
| a05ada5df0 | |||
| 0dc430b098 | |||
| 34480385d3 | |||
| 1f2103c7fa | |||
| fd68ed87a3 | |||
| 062c17c51e | |||
| 779fc5c5a5 | |||
| de169b79a1 | |||
| 08ebb311fb | |||
| aa24de0a5a | |||
| 12cdd25be7 | |||
| 59700493eb | |||
| e5937d573a | |||
| 88c3a84972 | |||
| db0d3a6ef3 | |||
| 3d29d27441 | |||
| b5f66603bd | |||
| 6da86e12bd | |||
| 976eeaa443 | |||
| 25d1d813f1 | |||
| 778f0c3ba2 | |||
| 38c50a4f8a | |||
| 9049022a74 | |||
| c497f321bb | |||
| 7b6f3a7a5f | |||
| 84c0768c8b | |||
| 53e9678efa | |||
| efb8d8115f | |||
| e4f547fa92 | |||
| 41183b4648 | |||
| 36e20bf0d1 | |||
| 0308e9ad83 | |||
| 06c0374f16 | |||
| c5bc610e2f | |||
| a86a24ca34 | |||
| cb2e3ea581 | |||
| 42eaf1d5e3 | |||
| 02ef706fc2 | |||
| 085b217abb | |||
| 0866e0972a | |||
| 4b13265737 | |||
| 79cd2b8123 | |||
| 8cc9641bbf | |||
| 32935aed88 | |||
| c72adbfc4b | |||
| bc378cc619 | |||
| 6327a5d2bf | |||
| 2ab2a04748 | |||
| fb0a9e98a1 | |||
| e45fe1bf10 | |||
| 3d852a8356 | |||
| b244944f41 | |||
| 3c7ba669e2 | |||
| 81e7e7129c | |||
| d7ad110b9e | |||
| 0c17ea2dcd | |||
| 78ff13a654 | |||
| 863c285049 | |||
| a3d74c0b57 | |||
| 9016a72046 | |||
| b69756ef81 | |||
| 1a3a9149a2 | |||
| ce120a6d5d | |||
| 08b5fd23ab | |||
| eb18e2d009 | |||
| a27852ed44 | |||
| c7925c1706 | |||
| be59bd7e89 | |||
| 51ce143fc2 | |||
| 89eb11f808 | |||
| 30d89987a4 | |||
| 7959d3e5ed | |||
| 1e29573ef7 | |||
| cc2f533dc6 | |||
| 32b0c19f9d | |||
| 9af2d768e8 | |||
| 5677824cde | |||
| e8f1bc09f9 | |||
| d1a936d55b | |||
| dc97eaa835 | |||
| dcbe57806c | |||
| b14438cc15 | |||
| b27d3bd5c6 | |||
| 03ebc9cfe9 | |||
| 24841b9850 | |||
| d35a3d1a8c | |||
| 60c4e0b528 | |||
| 84f33d1bc2 | |||
| c4e1709b99 | |||
| e7a5fd5819 | |||
| 4bde03643c | |||
| 1bc52b56af | |||
| 9c33fd93f7 | |||
| 3c087bc275 | |||
| 8ad13c289e | |||
| 7577f48a09 | |||
| 0251906964 | |||
| 2723a5f134 | |||
| c3c60605fd | |||
| 238f704b22 | |||
| 5639d8ac8e | |||
| 9aac591591 | |||
| ffa8e5aebb | |||
| cbbfe014cc | |||
| 83028f7817 | |||
| 70d1795557 | |||
| 8c6c681424 | |||
| 50bc9f4ff3 | |||
| f00ea03fad | |||
| f22e7b9ad1 | |||
| c7ec95f4bb | |||
| 229e7a8ccc | |||
| 3c616474ff | |||
| 56eb6b3ce3 | |||
| 545836d43c | |||
| 219f83dec0 | |||
| a76a841238 | |||
| c26680de84 | |||
| 8fffad9d3a | |||
| f4f0f203a2 | |||
| b7196f5a0c | |||
| 5d33a18890 | |||
| 96186a1a50 | |||
| bc8bc479d1 | |||
| 47595b1291 | |||
| 01a88964df | |||
| 3a2b77379f | |||
| dc4e5f75cd | |||
| d0178d551c | |||
| 827333108d | |||
| 587b90bd27 | |||
| 4dc20c5e90 | |||
| ac25782f2b | |||
| 20437d56e7 | |||
| f0b412828a | |||
| 367faac5c3 | |||
| 84deaaa970 | |||
| a2b39466c2 | |||
| 03586c4005 | |||
| 6ea69e1510 | |||
| 553c6dc539 | |||
| 6cc22f5b6d | |||
| 9103d67cc1 | |||
| 25083fb0e4 | |||
| d2dc045255 | |||
| b8621dfbb0 | |||
| 93633940dd | |||
| b6f5325351 | |||
| 7c32c08f1f | |||
| 1d268da08d | |||
| 797666ae0d | |||
| dcf470997e | |||
| 0974d1dbf8 | |||
| 12a35db6cd | |||
| 9abbb05ad8 | |||
| 1ecaf69b0b | |||
| e334d1e5d9 | |||
| b735e861d0 | |||
| 4eb433d372 | |||
| 2416ae61f3 | |||
| 01fb336985 | |||
| b6af88a732 | |||
| 58a2a17d6d | |||
| 79f5a0f520 | |||
| 7f6c0f7f04 | |||
| f658df4dca | |||
| 9d43b8e23a | |||
| 4270aef79b | |||
| 1c0dc82d44 | |||
| c1e325aadf | |||
| cec87da69d | |||
| f68f24cb2c | |||
| ed094347fc | |||
| b8afdffbe1 | |||
| f6ba79f31c | |||
| 5f3b1663d2 | |||
| 66e786b4bb | |||
| f671114574 | |||
| ce37060d94 | |||
| 7d19a4d184 | |||
| 22f28a2f8a | |||
| ceef9ca979 | |||
| efe8f4f939 | |||
| ba692a1195 | |||
| d732bad042 | |||
| 4c935c3bee | |||
| c160dd791f | |||
| 23cd1b4601 | |||
| 031fc8ba1b | |||
| c6853289ad | |||
| 2497bb69bc | |||
| a58a67e0a2 | |||
| 4315fe12a5 | |||
| 42f10a8899 | |||
| 1e4b47f989 | |||
| ff255dbfae | |||
| dbe9b72feb | |||
| 95a714b391 | |||
| 28f58c7f56 | |||
| 8bd46d8f21 | |||
| e1bb8e54ed | |||
| 1de705b063 | |||
| f6926ad356 | |||
| 2cdbbb1b37 | |||
| 4dce8c8f03 | |||
| 97a5bace6f | |||
| d4d51ec48f | |||
| fb91398462 | |||
| 105dadd798 | |||
| 2abf2837d3 | |||
| 422aa67af6 | |||
| 7fffab6985 | |||
| 5a4be3d2c1 | |||
| f39a7681db | |||
| c60a7580ba | |||
| 97edb56edc | |||
| 6ebca8d22b | |||
| 95371ad934 | |||
| 2c176825fd | |||
| fae7de48d3 | |||
| b8230646a2 | |||
| 43279541dd | |||
| b4791977c1 | |||
| ef917ecc25 | |||
| a93faad951 | |||
| fd001d24d3 | |||
| 7aa5884797 | |||
| 5b237a1547 | |||
| 2e37990d87 | |||
| dd07d724a8 | |||
| 03ce8618e7 | |||
| db1a7a7fd6 | |||
| 36a82d7f53 | |||
| 3a34401113 | |||
| 9927268330 | |||
| c45c97e29d | |||
| c64a315226 | |||
| a4cafca6ab | |||
| 46284a0660 | |||
| 05df86e15a | |||
| 8b433027e2 | |||
| 5bd4ff7610 | |||
| d693c397ea | |||
| 1d8d1ec9a5 | |||
| 5e491f11ee | |||
| 7cedea06ac | |||
| 2e5f750e50 | |||
| 20289cad10 | |||
| e0d64c31c7 | |||
| 8c1b95dc97 | |||
| fb5641343e | |||
| 87765941eb | |||
| 1809862c16 | |||
| 300f784f7d | |||
| 67a045eae6 | |||
| 2a79903a28 | |||
| d3222ce083 | |||
| 406a421742 | |||
| 10bf728faf | |||
| 607617747c | |||
| f0a69eb1a2 | |||
| 6b307a6e17 | |||
| 08d08a934a | |||
| c500c12668 | |||
| 62060adeba | |||
| b2fc75edb8 | |||
| a999dd2085 | |||
| 49f95ab100 | |||
| 1a84d5b30c | |||
| 3b65050632 | |||
| d0df31674c | |||
| 1fe88402e2 | |||
| 67097696e6 | |||
| 8e7e77067a | |||
| 9899390b61 | |||
| 80c476a908 | |||
| 59da1d6e49 | |||
| 5aef7dac33 | |||
| faf7aa06b6 | |||
| 38ef6e5583 | |||
| c0b15b5d94 | |||
| 2cfc067ea1 | |||
| a91db4f956 | |||
| 8a09780a02 | |||
| 45e8ec6505 | |||
| 4554b85914 | |||
| 8aa79c4a9c | |||
| c8d3210b57 | |||
| 2282a49563 | |||
| b82fdfb2c8 | |||
| 2d17eac199 | |||
| e482bc3aad | |||
| ec022b74d1 | |||
| dc42c09ce3 | |||
| 046a34d2a4 | |||
| 9ff6ec1888 | |||
| d2950106ec | |||
| 962f800d2e | |||
| 962107e507 | |||
| 039bd11963 | |||
| 5c250ea4ae | |||
| e3405bcec6 | |||
| 0fd1c2235f | |||
| b20c29b022 | |||
| 12d5dcd298 | |||
| 2c305dc6c6 | |||
| 62f76f7433 | |||
| 858ce524f9 | |||
| 3795fb4a40 | |||
| 0c01aeec50 | |||
| 892206744d | |||
| 9e2c1474db | |||
| 16328f73d9 | |||
| e0d4f53cf4 | |||
| e09a59c5b4 | |||
| 049e654535 | |||
| c927dc4ecd | |||
| fe4ecd0ad8 | |||
| 78d476fe80 | |||
| a11c8465d5 | |||
| 366304a9b7 | |||
| 4356663688 | |||
| 26b55e6fcf | |||
| 0d743f7204 | |||
| 6cbe113b3e | |||
| 6409b69d6c | |||
| c5164c76fc | |||
| baade8e138 | |||
| b848d6b4e0 | |||
| d8139d2ab0 | |||
| e96d8f7469 | |||
| 2acffd8afc | |||
| 3c8e72073c | |||
| 724d7a9d9b | |||
| 2da3b0db78 | |||
| 685ad7afaf | |||
| 264cf75964 | |||
| c773dbc7b5 | |||
| 37cbc64f52 | |||
| cb1dde17bb | |||
| c29988acf4 | |||
| eadbf56dae | |||
| 4b3b455135 | |||
| e6ac177396 | |||
| 3d0e29003f | |||
| 78b9b00f77 | |||
| 0ee7faa551 | |||
| e5fdced681 | |||
| afb99fef64 | |||
| 7dfaa36024 | |||
| 0496f665aa | |||
| 0d19e1be74 | |||
| 4aff0111aa | |||
| 63b3ba2bb2 | |||
| 7444b41f60 | |||
| 8e90dbc8b6 | |||
| 9f70722521 | |||
| 52fae596fa | |||
| ccb67957bc | |||
| fb82538d0d | |||
| 72ee39612e | |||
| 51fd5408dc | |||
| 3fae40fbef | |||
| 0745890af0 | |||
| 4abe1730a7 | |||
| 626f0e6989 | |||
| 9f42d9d173 | |||
| f90a93c4bc | |||
| 8000ad6c6a | |||
| 1f1f1bea1a | |||
| d95460c7cd | |||
| a3d93d4b08 | |||
| 07a92af982 | |||
| f4618877d4 | |||
| 2b914fd222 | |||
| 109e42a5a3 | |||
| fa515ad39c | |||
| f09673a795 | |||
| f71536c614 | |||
| 7bdddc7ae8 | |||
| aa8926a624 | |||
| be71e59be2 | |||
| 4d7753378f | |||
| 60257c4ef4 | |||
| 1e0b79bf62 | |||
| 6883434d0d | |||
| eda2193e64 | |||
| 99bf829c88 | |||
| 5feafe1b48 | |||
| c9292b7d04 | |||
| ae7e1a91c1 | |||
| 3e1887e0d1 | |||
| 474646db47 | |||
| 56f7b6c449 | |||
| 76b2b5f7e3 | |||
| e918d809eb | |||
| 7af059e543 | |||
| 897726e1ec | |||
| 8b98a2dd07 | |||
| cca75420f0 | |||
| 86c627ed1d | |||
| d55514e3a7 |
@@ -1,7 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
data class AdminCanChargeRequest(
|
data class AdminCanChargeRequest(
|
||||||
val memberId: Long,
|
val memberIds: List<Long>,
|
||||||
val method: String,
|
val method: String,
|
||||||
val can: Int
|
val can: Int
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.CanResponse
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
@@ -13,6 +15,11 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
@RequestMapping("/admin/can")
|
@RequestMapping("/admin/can")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
class AdminCanController(private val service: AdminCanService) {
|
class AdminCanController(private val service: AdminCanService) {
|
||||||
|
@GetMapping
|
||||||
|
fun getCans(): ApiResponse<List<CanResponse>> {
|
||||||
|
return ApiResponse.ok(service.getCans())
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.can.Can
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
|
import kr.co.vividnext.sodalive.can.CanResponse
|
||||||
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
|
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||||
|
import kr.co.vividnext.sodalive.can.QCanResponse
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
interface AdminCanRepository : JpaRepository<Can, Long>
|
interface AdminCanRepository : JpaRepository<Can, Long>, AdminCanQueryRepository
|
||||||
|
|
||||||
|
interface AdminCanQueryRepository {
|
||||||
|
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AdminCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminCanQueryRepository {
|
||||||
|
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QCanResponse(
|
||||||
|
can1.id,
|
||||||
|
can1.title,
|
||||||
|
can1.can,
|
||||||
|
can1.rewardCan,
|
||||||
|
can1.price.intValue(),
|
||||||
|
can1.currency,
|
||||||
|
can1.price.stringValue()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(can1)
|
||||||
|
.where(can1.status.eq(status))
|
||||||
|
.orderBy(can1.currency.asc(), can1.price.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.admin.can
|
|||||||
import kr.co.vividnext.sodalive.can.Can
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
import kr.co.vividnext.sodalive.can.CanStatus
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class AdminCanRequest(
|
data class AdminCanRequest(
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val rewardCan: Int,
|
val rewardCan: Int,
|
||||||
val price: Int
|
val price: BigDecimal,
|
||||||
|
val currency: String
|
||||||
) {
|
) {
|
||||||
fun toEntity(): Can {
|
fun toEntity(): Can {
|
||||||
var title = "${can.moneyFormat()} 캔"
|
var title = "${can.moneyFormat()} 캔"
|
||||||
@@ -20,6 +22,7 @@ data class AdminCanRequest(
|
|||||||
can = can,
|
can = can,
|
||||||
rewardCan = rewardCan,
|
rewardCan = rewardCan,
|
||||||
price = price,
|
price = price,
|
||||||
|
currency = currency,
|
||||||
status = CanStatus.SALE
|
status = CanStatus.SALE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.can.CanResponse
|
||||||
import kr.co.vividnext.sodalive.can.CanStatus
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||||
@@ -20,6 +21,10 @@ class AdminCanService(
|
|||||||
private val chargeRepository: ChargeRepository,
|
private val chargeRepository: ChargeRepository,
|
||||||
private val memberRepository: AdminMemberRepository
|
private val memberRepository: AdminMemberRepository
|
||||||
) {
|
) {
|
||||||
|
fun getCans(): List<CanResponse> {
|
||||||
|
return repository.findAllByStatus(status = CanStatus.SALE)
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun saveCan(request: AdminCanRequest) {
|
fun saveCan(request: AdminCanRequest) {
|
||||||
repository.save(request.toEntity())
|
repository.save(request.toEntity())
|
||||||
@@ -35,12 +40,16 @@ class AdminCanService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun charge(request: AdminCanChargeRequest) {
|
fun charge(request: AdminCanChargeRequest) {
|
||||||
val member = memberRepository.findByIdOrNull(request.memberId)
|
|
||||||
?: throw SodaException("잘못된 회원번호 입니다.")
|
|
||||||
|
|
||||||
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
|
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
|
||||||
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
|
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
|
||||||
|
|
||||||
|
val ids = request.memberIds.distinct()
|
||||||
|
if (ids.isEmpty()) throw SodaException("회원번호를 입력하세요.")
|
||||||
|
|
||||||
|
val members = memberRepository.findAllById(ids).toList()
|
||||||
|
if (members.size != ids.size) throw SodaException("잘못된 회원번호 입니다.")
|
||||||
|
|
||||||
|
members.forEach { member ->
|
||||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||||
charge.title = "${request.can.moneyFormat()} 캔"
|
charge.title = "${request.can.moneyFormat()} 캔"
|
||||||
charge.member = member
|
charge.member = member
|
||||||
@@ -53,4 +62,5 @@ class AdminCanService(
|
|||||||
|
|
||||||
member.pgRewardCan += charge.rewardCan
|
member.pgRewardCan += charge.rewardCan
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
|
|||||||
@GetMapping("/detail")
|
@GetMapping("/detail")
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@RequestParam paymentGateway: PaymentGateway
|
@RequestParam paymentGateway: PaymentGateway,
|
||||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
|
@RequestParam(value = "currency", required = false) currency: String? = null
|
||||||
|
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import com.querydsl.core.BooleanBuilder
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||||
@@ -14,7 +15,7 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
|
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||||
val formattedDate = Expressions.stringTemplate(
|
val formattedDate = Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
Expressions.dateTimeTemplate(
|
Expressions.dateTimeTemplate(
|
||||||
@@ -26,15 +27,16 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
),
|
),
|
||||||
"%Y-%m-%d"
|
"%Y-%m-%d"
|
||||||
)
|
)
|
||||||
|
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetChargeStatusQueryDto(
|
QGetChargeStatusResponse(
|
||||||
formattedDate,
|
formattedDate,
|
||||||
payment.price.sum(),
|
payment.price.sum(),
|
||||||
can1.price.sum(),
|
|
||||||
payment.id.count(),
|
payment.id.count(),
|
||||||
payment.paymentGateway
|
payment.paymentGateway.stringValue(),
|
||||||
|
currency.coalesce("KRW")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(payment)
|
.from(payment)
|
||||||
@@ -46,15 +48,46 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
)
|
)
|
||||||
.groupBy(formattedDate, payment.paymentGateway)
|
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
|
||||||
.orderBy(formattedDate.desc())
|
.orderBy(formattedDate.desc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getChargeStatusSummary(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||||
|
val currency = Expressions.stringTemplate(
|
||||||
|
"substring({0}, length({0}) - 2, 3)",
|
||||||
|
payment.locale
|
||||||
|
).coalesce("KRW")
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetChargeStatusResponse(
|
||||||
|
Expressions.stringTemplate("'합계'"), // date
|
||||||
|
payment.price.sum(),
|
||||||
|
payment.id.count(),
|
||||||
|
Expressions.stringTemplate("''"),
|
||||||
|
currency
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(payment)
|
||||||
|
.innerJoin(payment.charge, charge)
|
||||||
|
.leftJoin(charge.can, can1)
|
||||||
|
.where(
|
||||||
|
charge.createdAt.goe(startDate)
|
||||||
|
.and(charge.createdAt.loe(endDate))
|
||||||
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
|
)
|
||||||
|
.groupBy(currency)
|
||||||
|
.orderBy(currency.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
startDate: LocalDateTime,
|
startDate: LocalDateTime,
|
||||||
endDate: LocalDateTime,
|
endDate: LocalDateTime,
|
||||||
paymentGateway: PaymentGateway
|
paymentGateway: PaymentGateway,
|
||||||
|
currency: String? = null
|
||||||
): List<GetChargeStatusDetailQueryDto> {
|
): List<GetChargeStatusDetailQueryDto> {
|
||||||
val formattedDate = Expressions.stringTemplate(
|
val formattedDate = Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
@@ -67,6 +100,20 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
),
|
),
|
||||||
"%Y-%m-%d %H:%i:%s"
|
"%Y-%m-%d %H:%i:%s"
|
||||||
)
|
)
|
||||||
|
val currencyExpr = Expressions.stringTemplate(
|
||||||
|
"substring({0}, length({0}) - 2, 3)",
|
||||||
|
payment.locale
|
||||||
|
).coalesce("KRW")
|
||||||
|
val whereBuilder = BooleanBuilder()
|
||||||
|
whereBuilder.and(charge.createdAt.goe(startDate))
|
||||||
|
.and(charge.createdAt.loe(endDate))
|
||||||
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
|
.and(payment.paymentGateway.eq(paymentGateway))
|
||||||
|
|
||||||
|
if (currency != null) {
|
||||||
|
whereBuilder.and(currencyExpr.eq(currency))
|
||||||
|
}
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
@@ -75,8 +122,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
member.nickname,
|
member.nickname,
|
||||||
payment.method.coalesce(""),
|
payment.method.coalesce(""),
|
||||||
payment.price,
|
payment.price,
|
||||||
can1.price,
|
currencyExpr,
|
||||||
payment.locale.coalesce(""),
|
|
||||||
formattedDate
|
formattedDate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -84,13 +130,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
.innerJoin(charge.member, member)
|
.innerJoin(charge.member, member)
|
||||||
.innerJoin(charge.payment, payment)
|
.innerJoin(charge.payment, payment)
|
||||||
.leftJoin(charge.can, can1)
|
.leftJoin(charge.can, can1)
|
||||||
.where(
|
.where(whereBuilder)
|
||||||
charge.createdAt.goe(startDate)
|
|
||||||
.and(charge.createdAt.loe(endDate))
|
|
||||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
|
||||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
|
||||||
.and(payment.paymentGateway.eq(paymentGateway))
|
|
||||||
)
|
|
||||||
.orderBy(formattedDate.desc())
|
.orderBy(formattedDate.desc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,48 +20,17 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
var totalChargeAmount = 0
|
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
|
||||||
var totalChargeCount = 0L
|
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
|
||||||
|
chargeStatusList.addAll(0, summaryRows)
|
||||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
|
|
||||||
.asSequence()
|
|
||||||
.map {
|
|
||||||
val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) {
|
|
||||||
it.pgChargeAmount
|
|
||||||
} else {
|
|
||||||
it.appleChargeAmount.toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
val chargeCount = it.chargeCount
|
|
||||||
|
|
||||||
totalChargeAmount += chargeAmount
|
|
||||||
totalChargeCount += chargeCount
|
|
||||||
|
|
||||||
GetChargeStatusResponse(
|
|
||||||
date = it.date,
|
|
||||||
chargeAmount = chargeAmount,
|
|
||||||
chargeCount = chargeCount,
|
|
||||||
pg = it.paymentGateWay.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.toMutableList()
|
|
||||||
|
|
||||||
chargeStatusList.add(
|
|
||||||
0,
|
|
||||||
GetChargeStatusResponse(
|
|
||||||
date = "합계",
|
|
||||||
chargeAmount = totalChargeAmount,
|
|
||||||
chargeCount = totalChargeCount,
|
|
||||||
pg = ""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return chargeStatusList.toList()
|
return chargeStatusList.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
startDateStr: String,
|
startDateStr: String,
|
||||||
paymentGateway: PaymentGateway
|
paymentGateway: PaymentGateway,
|
||||||
|
currency: String? = null
|
||||||
): List<GetChargeStatusDetailResponse> {
|
): List<GetChargeStatusDetailResponse> {
|
||||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||||
@@ -74,18 +43,16 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
|
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
|
||||||
.asSequence()
|
|
||||||
.map {
|
.map {
|
||||||
GetChargeStatusDetailResponse(
|
GetChargeStatusDetailResponse(
|
||||||
memberId = it.memberId,
|
memberId = it.memberId,
|
||||||
nickname = it.nickname,
|
nickname = it.nickname,
|
||||||
method = it.method,
|
method = it.method,
|
||||||
amount = it.appleChargeAmount.toInt(),
|
amount = it.amount,
|
||||||
locale = it.locale,
|
locale = it.locale,
|
||||||
datetime = it.datetime
|
datetime = it.datetime
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.toList()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val method: String,
|
val method: String,
|
||||||
val appleChargeAmount: Double,
|
val amount: BigDecimal,
|
||||||
val pgChargeAmount: Int,
|
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: String
|
val datetime: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class GetChargeStatusDetailResponse(
|
data class GetChargeStatusDetailResponse(
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val method: String,
|
val method: String,
|
||||||
val amount: Int,
|
val amount: BigDecimal,
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: String
|
val datetime: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
|
||||||
|
|
||||||
data class GetChargeStatusQueryDto @QueryProjection constructor(
|
|
||||||
val date: String,
|
|
||||||
val appleChargeAmount: Double,
|
|
||||||
val pgChargeAmount: Int,
|
|
||||||
val chargeCount: Long,
|
|
||||||
val paymentGateWay: PaymentGateway
|
|
||||||
)
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
data class GetChargeStatusResponse(
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
data class GetChargeStatusResponse @QueryProjection constructor(
|
||||||
val date: String,
|
val date: String,
|
||||||
val chargeAmount: Int,
|
val chargeAmount: BigDecimal,
|
||||||
val chargeCount: Long,
|
val chargeCount: Long,
|
||||||
val pg: String
|
val pg: String,
|
||||||
|
val currency: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
@@ -19,4 +21,9 @@ class AdminContentSeriesController(private val service: AdminContentSeriesServic
|
|||||||
fun searchSeriesList(
|
fun searchSeriesList(
|
||||||
@RequestParam(value = "search_word") searchWord: String
|
@RequestParam(value = "search_word") searchWord: String
|
||||||
) = ApiResponse.ok(service.searchSeriesList(searchWord))
|
) = ApiResponse.ok(service.searchSeriesList(searchWord))
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
fun modifySeries(
|
||||||
|
@RequestBody request: AdminModifySeriesRequest
|
||||||
|
) = ApiResponse.ok(service.modifySeries(request), "시리즈가 수정되었습니다.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.content.series
|
package kr.co.vividnext.sodalive.admin.content.series
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AdminContentSeriesService(private val repository: AdminContentSeriesRepository) {
|
class AdminContentSeriesService(
|
||||||
|
private val repository: AdminContentSeriesRepository,
|
||||||
|
private val genreRepository: AdminContentSeriesGenreRepository
|
||||||
|
) {
|
||||||
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
|
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
|
||||||
val totalCount = repository.getSeriesTotalCount()
|
val totalCount = repository.getSeriesTotalCount()
|
||||||
val items = repository.getSeriesList(
|
val items = repository.getSeriesList(
|
||||||
@@ -12,10 +19,53 @@ class AdminContentSeriesService(private val repository: AdminContentSeriesReposi
|
|||||||
limit = pageable.pageSize.toLong()
|
limit = pageable.pageSize.toLong()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (items.isNotEmpty()) {
|
||||||
|
val ids = items.map { it.id }
|
||||||
|
val seriesList = repository.findAllById(ids)
|
||||||
|
val seriesMap = seriesList.associateBy { it.id }
|
||||||
|
|
||||||
|
items.forEach { item ->
|
||||||
|
val s = seriesMap[item.id]
|
||||||
|
if (s != null) {
|
||||||
|
item.publishedDaysOfWeek = s.publishedDaysOfWeek.toList().sortedBy { it.ordinal }
|
||||||
|
item.isOriginal = s.isOriginal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return GetAdminSeriesListResponse(totalCount, items)
|
return GetAdminSeriesListResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
|
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
|
||||||
return repository.searchSeriesList(searchWord)
|
return repository.searchSeriesList(searchWord)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun modifySeries(request: AdminModifySeriesRequest) {
|
||||||
|
val series = repository.findByIdAndActiveTrue(request.seriesId)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
|
if (request.publishedDaysOfWeek != null) {
|
||||||
|
val days = request.publishedDaysOfWeek
|
||||||
|
if (days.contains(SeriesPublishedDaysOfWeek.RANDOM) && days.size > 1) {
|
||||||
|
throw SodaException("랜덤과 연재요일 동시에 선택할 수 없습니다.")
|
||||||
|
}
|
||||||
|
series.publishedDaysOfWeek.clear()
|
||||||
|
series.publishedDaysOfWeek.addAll(days)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.genreId != null) {
|
||||||
|
val genre = genreRepository.findActiveSeriesGenreById(request.genreId)
|
||||||
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
series.genre = genre
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isOriginal != null) {
|
||||||
|
series.isOriginal = request.isOriginal
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.isAdult != null) {
|
||||||
|
series.isAdult = request.isAdult
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.series
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
|
||||||
|
data class AdminModifySeriesRequest(
|
||||||
|
val seriesId: Long,
|
||||||
|
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>?,
|
||||||
|
val genreId: Long?,
|
||||||
|
val isOriginal: Boolean?,
|
||||||
|
val isAdult: Boolean?
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.content.series
|
package kr.co.vividnext.sodalive.admin.content.series
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
|
||||||
data class GetAdminSeriesListResponse(
|
data class GetAdminSeriesListResponse(
|
||||||
val totalCount: Int,
|
val totalCount: Int,
|
||||||
@@ -17,7 +18,10 @@ data class GetAdminSeriesListItem @QueryProjection constructor(
|
|||||||
val numberOfWorks: Long,
|
val numberOfWorks: Long,
|
||||||
val state: String,
|
val state: String,
|
||||||
val isAdult: Boolean
|
val isAdult: Boolean
|
||||||
)
|
) {
|
||||||
|
var publishedDaysOfWeek: List<SeriesPublishedDaysOfWeek> = emptyList()
|
||||||
|
var isOriginal: Boolean = false
|
||||||
|
}
|
||||||
|
|
||||||
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
|
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.series.banner
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.banner.UpdateBannerOrdersRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpdateRequest
|
||||||
|
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
|
||||||
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/audio-content/series/banner")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class AdminContentSeriesBannerController(
|
||||||
|
private val bannerService: ContentSeriesBannerService,
|
||||||
|
private val s3Uploader: S3Uploader,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
|
private val s3Bucket: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 활성화된 배너 목록 조회 API
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun getBannerList(
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int
|
||||||
|
) = run {
|
||||||
|
val pageable = PageRequest.of(page, size)
|
||||||
|
val banners = bannerService.getActiveBanners(pageable)
|
||||||
|
val response = SeriesBannerListPageResponse(
|
||||||
|
totalCount = banners.totalElements,
|
||||||
|
content = banners.content.map { SeriesBannerResponse.from(it, imageHost) }
|
||||||
|
)
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 상세 조회 API
|
||||||
|
*/
|
||||||
|
@GetMapping("/{bannerId}")
|
||||||
|
fun getBannerDetail(@PathVariable bannerId: Long) = run {
|
||||||
|
val banner = bannerService.getBannerById(bannerId)
|
||||||
|
val response = SeriesBannerResponse.from(banner, imageHost)
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 등록 API
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun registerBanner(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java)
|
||||||
|
|
||||||
|
val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "")
|
||||||
|
val imagePath = saveImage(banner.id!!, image)
|
||||||
|
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
|
||||||
|
val response = SeriesBannerResponse.from(updatedBanner, imageHost)
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 수정 API
|
||||||
|
*/
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun updateBanner(
|
||||||
|
@RequestPart("image") image: MultipartFile,
|
||||||
|
@RequestPart("request") requestString: String
|
||||||
|
) = run {
|
||||||
|
val objectMapper = ObjectMapper()
|
||||||
|
val request = objectMapper.readValue(requestString, SeriesBannerUpdateRequest::class.java)
|
||||||
|
// 배너 존재 확인
|
||||||
|
bannerService.getBannerById(request.bannerId)
|
||||||
|
val imagePath = saveImage(request.bannerId, image)
|
||||||
|
val updated = bannerService.updateBanner(
|
||||||
|
bannerId = request.bannerId,
|
||||||
|
imagePath = imagePath,
|
||||||
|
seriesId = request.seriesId
|
||||||
|
)
|
||||||
|
val response = SeriesBannerResponse.from(updated, imageHost)
|
||||||
|
ApiResponse.ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 삭제 API (소프트 삭제)
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{bannerId}")
|
||||||
|
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
||||||
|
bannerService.deleteBanner(bannerId)
|
||||||
|
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배너 정렬 순서 일괄 변경 API
|
||||||
|
*/
|
||||||
|
@PutMapping("/orders")
|
||||||
|
fun updateBannerOrders(
|
||||||
|
@RequestBody request: UpdateBannerOrdersRequest
|
||||||
|
) = run {
|
||||||
|
bannerService.updateBannerOrders(request.ids)
|
||||||
|
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
||||||
|
try {
|
||||||
|
val metadata = ObjectMetadata()
|
||||||
|
metadata.contentLength = image.size
|
||||||
|
val fileName = generateFileName("series-banner")
|
||||||
|
return s3Uploader.upload(
|
||||||
|
inputStream = image.inputStream,
|
||||||
|
bucket = s3Bucket,
|
||||||
|
filePath = "series_banner/$bannerId/$fileName",
|
||||||
|
metadata = metadata
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.series.banner.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
|
||||||
|
|
||||||
|
// 시리즈 배너 등록 요청 DTO
|
||||||
|
data class SeriesBannerRegisterRequest(
|
||||||
|
@JsonProperty("seriesId") val seriesId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
// 시리즈 배너 수정 요청 DTO
|
||||||
|
data class SeriesBannerUpdateRequest(
|
||||||
|
@JsonProperty("bannerId") val bannerId: Long,
|
||||||
|
@JsonProperty("seriesId") val seriesId: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// 시리즈 배너 응답 DTO
|
||||||
|
data class SeriesBannerResponse(
|
||||||
|
val id: Long,
|
||||||
|
val imagePath: String,
|
||||||
|
val seriesId: Long,
|
||||||
|
val seriesTitle: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(banner: SeriesBanner, imageHost: String): SeriesBannerResponse {
|
||||||
|
return SeriesBannerResponse(
|
||||||
|
id = banner.id!!,
|
||||||
|
imagePath = "$imageHost/${banner.imagePath}",
|
||||||
|
seriesId = banner.series.id!!,
|
||||||
|
seriesTitle = banner.series.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시리즈 배너 목록 페이지 응답 DTO
|
||||||
|
data class SeriesBannerListPageResponse(
|
||||||
|
val totalCount: Long,
|
||||||
|
val content: List<SeriesBannerResponse>
|
||||||
|
)
|
||||||
@@ -8,6 +8,7 @@ interface AdminContentSeriesGenreRepository : JpaRepository<SeriesGenre, Long>,
|
|||||||
|
|
||||||
interface AdminContentSeriesGenreQueryRepository {
|
interface AdminContentSeriesGenreQueryRepository {
|
||||||
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
|
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
|
||||||
|
fun findActiveSeriesGenreById(id: Long): SeriesGenre?
|
||||||
}
|
}
|
||||||
|
|
||||||
class AdminContentSeriesGenreQueryRepositoryImpl(
|
class AdminContentSeriesGenreQueryRepositoryImpl(
|
||||||
@@ -21,4 +22,14 @@ class AdminContentSeriesGenreQueryRepositoryImpl(
|
|||||||
.orderBy(seriesGenre.orders.asc())
|
.orderBy(seriesGenre.orders.asc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findActiveSeriesGenreById(id: Long): SeriesGenre? {
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(seriesGenre)
|
||||||
|
.where(
|
||||||
|
seriesGenre.id.eq(id)
|
||||||
|
.and(seriesGenre.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ class AdminMemberController(private val service: AdminMemberService) {
|
|||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = ApiResponse.ok(service.searchMember(searchWord, pageable))
|
) = ApiResponse.ok(service.searchMember(searchWord, pageable))
|
||||||
|
|
||||||
|
@GetMapping("/search-by-nickname")
|
||||||
|
fun searchMemberByNickname(
|
||||||
|
@RequestParam(value = "search_word") searchWord: String,
|
||||||
|
@RequestParam(value = "size", required = false) size: Int?
|
||||||
|
) = ApiResponse.ok(service.searchMemberByNickname(searchWord = searchWord, size = size ?: 20))
|
||||||
|
|
||||||
@GetMapping("/creator/all/list")
|
@GetMapping("/creator/all/list")
|
||||||
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
|
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface AdminMemberQueryRepository {
|
|||||||
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
|
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
|
||||||
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
|
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
|
||||||
fun findByIdAndActive(memberId: Long): Member?
|
fun findByIdAndActive(memberId: Long): Member?
|
||||||
|
fun searchMemberByNickname(searchWord: String, limit: Long = 20): List<AdminSimpleMemberResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
|
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
|
||||||
@@ -121,4 +122,22 @@ class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
|
|||||||
.orderBy(member.id.desc())
|
.orderBy(member.id.desc())
|
||||||
.fetchFirst()
|
.fetchFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun searchMemberByNickname(searchWord: String, limit: Long): List<AdminSimpleMemberResponse> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QAdminSimpleMemberResponse(
|
||||||
|
member.id,
|
||||||
|
member.nickname
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.nickname.contains(searchWord)
|
||||||
|
.and(member.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.orderBy(member.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,12 @@ class AdminMemberService(
|
|||||||
return repository.getCreatorAllList()
|
return repository.getCreatorAllList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun searchMemberByNickname(searchWord: String, size: Int = 20): List<AdminSimpleMemberResponse> {
|
||||||
|
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
||||||
|
val limit = if (size <= 0) 20 else size
|
||||||
|
return repository.searchMemberByNickname(searchWord = searchWord, limit = limit.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun resetPassword(request: ResetPasswordRequest) {
|
fun resetPassword(request: ResetPasswordRequest) {
|
||||||
val member = repository.findByIdAndActive(memberId = request.memberId)
|
val member = repository.findByIdAndActive(memberId = request.memberId)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.member
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자용 간단 회원 응답 DTO
|
||||||
|
* 닉네임 검색 결과로 사용되며 charge 등에서 memberId 선택에 활용된다.
|
||||||
|
*/
|
||||||
|
data class AdminSimpleMemberResponse @QueryProjection constructor(
|
||||||
|
val id: Long,
|
||||||
|
val nickname: String
|
||||||
|
)
|
||||||
@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.admin.statistics.ad
|
|||||||
import com.querydsl.core.types.dsl.CaseBuilder
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
import com.querydsl.core.types.dsl.DateTimePath
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.core.types.dsl.NumberExpression
|
|
||||||
import com.querydsl.core.types.dsl.StringTemplate
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||||
@@ -67,7 +66,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val firstPaymentTotalAmount = CaseBuilder()
|
val firstPaymentTotalAmount = CaseBuilder()
|
||||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(Expressions.constant(0.0))
|
.otherwise(0.toBigDecimal())
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
val repeatPaymentCount = CaseBuilder()
|
val repeatPaymentCount = CaseBuilder()
|
||||||
@@ -79,7 +78,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val repeatPaymentTotalAmount = CaseBuilder()
|
val repeatPaymentTotalAmount = CaseBuilder()
|
||||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(Expressions.constant(0.0))
|
.otherwise(0.toBigDecimal())
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
val allPaymentCount = CaseBuilder()
|
val allPaymentCount = CaseBuilder()
|
||||||
@@ -97,7 +96,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||||
)
|
)
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(Expressions.constant(0.0))
|
.otherwise(0.toBigDecimal())
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
@@ -111,11 +110,11 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
loginCount,
|
loginCount,
|
||||||
signUpCount,
|
signUpCount,
|
||||||
firstPaymentCount,
|
firstPaymentCount,
|
||||||
roundedValueDecimalPlaces2(firstPaymentTotalAmount),
|
firstPaymentTotalAmount,
|
||||||
repeatPaymentCount,
|
repeatPaymentCount,
|
||||||
roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
|
repeatPaymentTotalAmount,
|
||||||
allPaymentCount,
|
allPaymentCount,
|
||||||
roundedValueDecimalPlaces2(allPaymentTotalAmount)
|
allPaymentTotalAmount
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(adTrackingHistory)
|
.from(adTrackingHistory)
|
||||||
@@ -148,13 +147,4 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
"%Y-%m-%d"
|
"%Y-%m-%d"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun roundedValueDecimalPlaces2(valueExpression: NumberExpression<Double>): NumberExpression<Double> {
|
|
||||||
return Expressions.numberTemplate(
|
|
||||||
Double::class.java,
|
|
||||||
"ROUND({0}, {1})",
|
|
||||||
valueExpression,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.statistics.ad
|
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class GetAdminAdStatisticsResponse(
|
data class GetAdminAdStatisticsResponse(
|
||||||
val totalCount: Int,
|
val totalCount: Int,
|
||||||
@@ -16,9 +17,9 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
|
|||||||
val loginCount: Int,
|
val loginCount: Int,
|
||||||
val signUpCount: Int,
|
val signUpCount: Int,
|
||||||
val firstPaymentCount: Int,
|
val firstPaymentCount: Int,
|
||||||
val firstPaymentTotalAmount: Double,
|
val firstPaymentTotalAmount: BigDecimal,
|
||||||
val repeatPaymentCount: Int,
|
val repeatPaymentCount: Int,
|
||||||
val repeatPaymentTotalAmount: Double,
|
val repeatPaymentTotalAmount: BigDecimal,
|
||||||
val allPaymentCount: Int,
|
val allPaymentCount: Int,
|
||||||
val allPaymentTotalAmount: Double
|
val allPaymentTotalAmount: BigDecimal
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.api.home
|
package kr.co.vividnext.sodalive.api.home
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.audition.GetAuditionListItem
|
import kr.co.vividnext.sodalive.audition.GetAuditionListItem
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||||
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
|
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
|
||||||
@@ -21,8 +22,11 @@ data class GetHomeResponse(
|
|||||||
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
|
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
|
||||||
val auditionList: List<GetAuditionListItem>,
|
val auditionList: List<GetAuditionListItem>,
|
||||||
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
|
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
|
||||||
|
val popularCharacters: List<Character>,
|
||||||
val contentRanking: List<GetAudioContentRankingItem>,
|
val contentRanking: List<GetAudioContentRankingItem>,
|
||||||
val recommendChannelList: List<RecommendChannelResponse>,
|
val recommendChannelList: List<RecommendChannelResponse>,
|
||||||
val freeContentList: List<AudioContentMainItem>,
|
val freeContentList: List<AudioContentMainItem>,
|
||||||
|
val pointAvailableContentList: List<AudioContentMainItem>,
|
||||||
|
val recommendContentList: List<AudioContentMainItem>,
|
||||||
val curationList: List<GetContentCurationResponse>
|
val curationList: List<GetContentCurationResponse>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
|||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
@@ -63,4 +64,44 @@ class HomeController(private val service: HomeService) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 추천 콘텐츠만 새로고침하기 위한 엔드포인트
|
||||||
|
@GetMapping("/recommend-contents")
|
||||||
|
fun getRecommendContents(
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
service.getRecommendContentList(
|
||||||
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
member = member
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 콘텐츠 랭킹 엔드포인트
|
||||||
|
@GetMapping("/content-ranking")
|
||||||
|
fun getContentRanking(
|
||||||
|
@RequestParam("sort", required = false) sort: ContentRankingSortType? = null,
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@RequestParam("offset", required = false) offset: Long? = null,
|
||||||
|
@RequestParam("limit", required = false) limit: Long? = null,
|
||||||
|
@RequestParam("theme", required = false) theme: String? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
service.getContentRankingBySort(
|
||||||
|
sort = sort ?: ContentRankingSortType.REVENUE,
|
||||||
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
offset = offset,
|
||||||
|
limit = limit,
|
||||||
|
theme = theme,
|
||||||
|
member = member
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package kr.co.vividnext.sodalive.api.home
|
package kr.co.vividnext.sodalive.api.home
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.audition.AuditionService
|
import kr.co.vividnext.sodalive.audition.AuditionService
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
||||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
||||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||||
@@ -17,6 +19,7 @@ import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
|||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberService
|
import kr.co.vividnext.sodalive.member.MemberService
|
||||||
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
|
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
|
||||||
|
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
|
||||||
import kr.co.vividnext.sodalive.rank.RankingRepository
|
import kr.co.vividnext.sodalive.rank.RankingRepository
|
||||||
import kr.co.vividnext.sodalive.rank.RankingService
|
import kr.co.vividnext.sodalive.rank.RankingService
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
@@ -39,6 +42,7 @@ class HomeService(
|
|||||||
private val contentThemeService: AudioContentThemeService,
|
private val contentThemeService: AudioContentThemeService,
|
||||||
private val recommendChannelService: RecommendChannelQueryService,
|
private val recommendChannelService: RecommendChannelQueryService,
|
||||||
|
|
||||||
|
private val characterService: ChatCharacterService,
|
||||||
private val rankingService: RankingService,
|
private val rankingService: RankingService,
|
||||||
private val rankingRepository: RankingRepository,
|
private val rankingRepository: RankingRepository,
|
||||||
private val explorerQueryRepository: ExplorerQueryRepository,
|
private val explorerQueryRepository: ExplorerQueryRepository,
|
||||||
@@ -46,6 +50,11 @@ class HomeService(
|
|||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val RECOMMEND_TARGET_SIZE = 30
|
||||||
|
private const val RECOMMEND_MAX_ATTEMPTS = 3
|
||||||
|
}
|
||||||
|
|
||||||
fun fetchData(
|
fun fetchData(
|
||||||
timezone: String,
|
timezone: String,
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
@@ -115,7 +124,8 @@ class HomeService(
|
|||||||
|
|
||||||
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType
|
contentType = contentType,
|
||||||
|
orderByRandom = true
|
||||||
)
|
)
|
||||||
|
|
||||||
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
||||||
@@ -127,6 +137,9 @@ class HomeService(
|
|||||||
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 인기 캐릭터 조회
|
||||||
|
val popularCharacters = characterService.getPopularCharacters()
|
||||||
|
|
||||||
val currentDateTime = LocalDateTime.now()
|
val currentDateTime = LocalDateTime.now()
|
||||||
val startDate = currentDateTime
|
val startDate = currentDateTime
|
||||||
.withHour(15)
|
.withHour(15)
|
||||||
@@ -143,11 +156,9 @@ class HomeService(
|
|||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
startDate = startDate.minusDays(1),
|
startDate = startDate.minusDays(1),
|
||||||
endDate = endDate,
|
endDate = endDate,
|
||||||
sortType = "매출"
|
sort = ContentRankingSortType.REVENUE
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO 오디오 북
|
|
||||||
|
|
||||||
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
@@ -162,7 +173,24 @@ class HomeService(
|
|||||||
),
|
),
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
isFree = true,
|
isFree = true,
|
||||||
isAdult = isAdult
|
isAdult = isAdult,
|
||||||
|
orderByRandom = true
|
||||||
|
).filter {
|
||||||
|
if (memberId != null) {
|
||||||
|
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
|
||||||
|
val pointAvailableContentList = contentService.getLatestContentByTheme(
|
||||||
|
theme = emptyList(),
|
||||||
|
contentType = contentType,
|
||||||
|
isFree = false,
|
||||||
|
isAdult = isAdult,
|
||||||
|
orderByRandom = true,
|
||||||
|
isPointAvailableOnly = true
|
||||||
).filter {
|
).filter {
|
||||||
if (memberId != null) {
|
if (memberId != null) {
|
||||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||||
@@ -188,9 +216,16 @@ class HomeService(
|
|||||||
originalAudioDramaList = originalAudioDramaList,
|
originalAudioDramaList = originalAudioDramaList,
|
||||||
auditionList = auditionList,
|
auditionList = auditionList,
|
||||||
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
||||||
|
popularCharacters = popularCharacters,
|
||||||
contentRanking = contentRanking,
|
contentRanking = contentRanking,
|
||||||
recommendChannelList = recommendChannelList,
|
recommendChannelList = recommendChannelList,
|
||||||
freeContentList = freeContentList,
|
freeContentList = freeContentList,
|
||||||
|
pointAvailableContentList = pointAvailableContentList,
|
||||||
|
recommendContentList = getRecommendContentList(
|
||||||
|
isAdultContentVisible = isAdultContentVisible,
|
||||||
|
contentType = contentType,
|
||||||
|
member = member
|
||||||
|
),
|
||||||
curationList = curationList
|
curationList = curationList
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -245,6 +280,40 @@ class HomeService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getContentRankingBySort(
|
||||||
|
sort: ContentRankingSortType,
|
||||||
|
isAdultContentVisible: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
offset: Long?,
|
||||||
|
limit: Long?,
|
||||||
|
theme: String?,
|
||||||
|
member: Member?
|
||||||
|
): List<GetAudioContentRankingItem> {
|
||||||
|
val memberId = member?.id
|
||||||
|
val isAdult = member?.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
|
val currentDateTime = LocalDateTime.now()
|
||||||
|
val startDate = currentDateTime
|
||||||
|
.withHour(15)
|
||||||
|
.withMinute(0)
|
||||||
|
.withSecond(0)
|
||||||
|
.minusWeeks(1)
|
||||||
|
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||||
|
val endDate = startDate.plusDays(6)
|
||||||
|
|
||||||
|
return rankingService.getContentRanking(
|
||||||
|
memberId = memberId,
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType,
|
||||||
|
startDate = startDate.minusDays(1),
|
||||||
|
endDate = endDate,
|
||||||
|
offset = offset ?: 0,
|
||||||
|
limit = limit ?: 12,
|
||||||
|
sort = sort,
|
||||||
|
theme = theme ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek {
|
private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek {
|
||||||
val systemTime = LocalDateTime.now()
|
val systemTime = LocalDateTime.now()
|
||||||
val zoneId = ZoneId.of(timezone)
|
val zoneId = ZoneId.of(timezone)
|
||||||
@@ -262,4 +331,46 @@ class HomeService(
|
|||||||
|
|
||||||
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
|
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 추천 콘텐츠 조회 로직은 변경 가능성을 고려하여 별도 메서드로 추출한다.
|
||||||
|
fun getRecommendContentList(
|
||||||
|
isAdultContentVisible: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
member: Member?
|
||||||
|
): List<AudioContentMainItem> {
|
||||||
|
val memberId = member?.id
|
||||||
|
val isAdult = member?.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
|
// Set + List 조합으로 중복 제거 및 순서 보존, 각 시도마다 limit=60으로 조회
|
||||||
|
val seen = HashSet<Long>(RECOMMEND_TARGET_SIZE * 2)
|
||||||
|
val result = ArrayList<AudioContentMainItem>(RECOMMEND_TARGET_SIZE)
|
||||||
|
var attempt = 0
|
||||||
|
while (attempt < RECOMMEND_MAX_ATTEMPTS && result.size < RECOMMEND_TARGET_SIZE) {
|
||||||
|
attempt += 1
|
||||||
|
val batch = contentService.getLatestContentByTheme(
|
||||||
|
theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회
|
||||||
|
contentType = contentType,
|
||||||
|
offset = 0,
|
||||||
|
limit = (RECOMMEND_TARGET_SIZE * RECOMMEND_MAX_ATTEMPTS).toLong(), // 60개 조회
|
||||||
|
isFree = false,
|
||||||
|
isAdult = isAdult,
|
||||||
|
orderByRandom = true
|
||||||
|
).filter {
|
||||||
|
if (memberId != null) {
|
||||||
|
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (item in batch) {
|
||||||
|
if (result.size >= RECOMMEND_TARGET_SIZE) break
|
||||||
|
if (seen.add(item.contentId)) {
|
||||||
|
result.add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.can
|
package kr.co.vividnext.sodalive.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import javax.persistence.Column
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.EnumType
|
import javax.persistence.EnumType
|
||||||
import javax.persistence.Enumerated
|
import javax.persistence.Enumerated
|
||||||
@@ -10,7 +12,10 @@ data class Can(
|
|||||||
var title: String,
|
var title: String,
|
||||||
var can: Int,
|
var can: Int,
|
||||||
var rewardCan: Int,
|
var rewardCan: Int,
|
||||||
var price: Int,
|
@Column(precision = 10, scale = 4, nullable = false)
|
||||||
|
var price: BigDecimal,
|
||||||
|
@Column(length = 3, nullable = false, columnDefinition = "CHAR(3)")
|
||||||
|
var currency: String,
|
||||||
@Enumerated(value = EnumType.STRING)
|
@Enumerated(value = EnumType.STRING)
|
||||||
var status: CanStatus
|
var status: CanStatus
|
||||||
) : BaseEntity()
|
) : BaseEntity()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.can
|
package kr.co.vividnext.sodalive.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.GeoCountry
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
@@ -9,13 +10,15 @@ import org.springframework.web.bind.annotation.GetMapping
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/can")
|
@RequestMapping("/can")
|
||||||
class CanController(private val service: CanService) {
|
class CanController(private val service: CanService) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
fun getCans(request: HttpServletRequest): ApiResponse<List<CanResponse>> {
|
||||||
return ApiResponse.ok(service.getCans())
|
val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER
|
||||||
|
return ApiResponse.ok(service.getCans(geoCountry))
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository
|
|||||||
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
|
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
|
||||||
|
|
||||||
interface CanQueryRepository {
|
interface CanQueryRepository {
|
||||||
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse>
|
||||||
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
|
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
|
||||||
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
|
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
|
||||||
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
|
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
|
||||||
@@ -32,7 +32,7 @@ interface CanQueryRepository {
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
|
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
|
||||||
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QCanResponse(
|
QCanResponse(
|
||||||
@@ -40,11 +40,16 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
|
|||||||
can1.title,
|
can1.title,
|
||||||
can1.can,
|
can1.can,
|
||||||
can1.rewardCan,
|
can1.rewardCan,
|
||||||
can1.price
|
can1.price.intValue(),
|
||||||
|
can1.currency,
|
||||||
|
can1.price.stringValue()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(can1)
|
.from(can1)
|
||||||
.where(can1.status.eq(status))
|
.where(
|
||||||
|
can1.status.eq(status),
|
||||||
|
can1.currency.eq(currency)
|
||||||
|
)
|
||||||
.orderBy(can1.can.asc())
|
.orderBy(can1.can.asc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ data class CanResponse @QueryProjection constructor(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val rewardCan: Int,
|
val rewardCan: Int,
|
||||||
val price: Int
|
val price: Int,
|
||||||
|
val currency: String,
|
||||||
|
val priceStr: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.can
|
|||||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.common.GeoCountry
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -11,8 +12,12 @@ import java.time.format.DateTimeFormatter
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class CanService(private val repository: CanRepository) {
|
class CanService(private val repository: CanRepository) {
|
||||||
fun getCans(): List<CanResponse> {
|
fun getCans(geoCountry: GeoCountry): List<CanResponse> {
|
||||||
return repository.findAllByStatus(status = CanStatus.SALE)
|
val currency = when (geoCountry) {
|
||||||
|
GeoCountry.KR -> "KRW"
|
||||||
|
else -> "USD"
|
||||||
|
}
|
||||||
|
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
|
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
|
||||||
@@ -35,6 +40,7 @@ class CanService(private val repository: CanRepository) {
|
|||||||
"aos" -> {
|
"aos" -> {
|
||||||
it.useCanCalculates.any { useCanCalculate ->
|
it.useCanCalculates.any { useCanCalculate ->
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||||
|
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
|
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,12 +48,14 @@ class CanService(private val repository: CanRepository) {
|
|||||||
"ios" -> {
|
"ios" -> {
|
||||||
it.useCanCalculates.any { useCanCalculate ->
|
it.useCanCalculates.any { useCanCalculate ->
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||||
|
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
|
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> it.useCanCalculates.any { useCanCalculate ->
|
else -> it.useCanCalculates.any { useCanCalculate ->
|
||||||
useCanCalculate.paymentGateway == PaymentGateway.PG
|
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||||
|
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package kr.co.vividnext.sodalive.can.charge
|
package kr.co.vividnext.sodalive.can.charge
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class ChargeCompleteResponse(
|
data class ChargeCompleteResponse(
|
||||||
val price: Double,
|
val price: BigDecimal,
|
||||||
val currencyCode: String,
|
val currencyCode: String,
|
||||||
val isFirstCharged: Boolean
|
val isFirstCharged: Boolean
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ class ChargeController(
|
|||||||
@RequestBody request: PayverseWebhookRequest,
|
@RequestBody request: PayverseWebhookRequest,
|
||||||
servletRequest: HttpServletRequest
|
servletRequest: HttpServletRequest
|
||||||
): PayverseWebhookResponse {
|
): PayverseWebhookResponse {
|
||||||
val remoteIp = servletRequest.remoteAddr ?: ""
|
val header = servletRequest.getHeader("X-Forwarded-For")
|
||||||
|
val remoteIp = if (header.isNullOrEmpty()) {
|
||||||
print("Payverse Webhook Request: $remoteIp")
|
servletRequest.remoteAddr
|
||||||
print("Payverse Webhook Request: $payverseInboundIp")
|
} else {
|
||||||
|
header.split(",")[0].trim() // 첫 번째 값이 클라이언트 IP
|
||||||
|
}
|
||||||
|
|
||||||
if (remoteIp != payverseInboundIp) {
|
if (remoteIp != payverseInboundIp) {
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||||
@@ -166,8 +168,7 @@ class ChargeController(
|
|||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
chargeId = chargeId,
|
chargeId = chargeId,
|
||||||
productId = request.productId,
|
productId = request.productId,
|
||||||
purchaseToken = request.purchaseToken,
|
purchaseToken = request.purchaseToken
|
||||||
paymentGateway = request.paymentGateway
|
|
||||||
)
|
)
|
||||||
|
|
||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ data class VerifyResult(
|
|||||||
val method: String,
|
val method: String,
|
||||||
val pg: String,
|
val pg: String,
|
||||||
val status: Int,
|
val status: Int,
|
||||||
val price: Int
|
val price: BigDecimal
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AppleChargeRequest(
|
data class AppleChargeRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val chargeCan: Int,
|
val chargeCan: Int,
|
||||||
val paymentGateway: PaymentGateway,
|
val paymentGateway: PaymentGateway,
|
||||||
var price: Double? = null,
|
var price: BigDecimal? = null,
|
||||||
var locale: String? = null
|
var locale: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ data class AppleVerifyResponse(val status: Int)
|
|||||||
data class GoogleChargeRequest(
|
data class GoogleChargeRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val chargeCan: Int,
|
val chargeCan: Int,
|
||||||
val price: Double,
|
val price: BigDecimal,
|
||||||
val currencyCode: String,
|
val currencyCode: String,
|
||||||
val productId: String,
|
val productId: String,
|
||||||
val purchaseToken: String,
|
val purchaseToken: String,
|
||||||
@@ -70,8 +70,8 @@ data class PayverseVerifyResponse(
|
|||||||
val transactionMessage: String,
|
val transactionMessage: String,
|
||||||
val orderId: String,
|
val orderId: String,
|
||||||
val customerId: String,
|
val customerId: String,
|
||||||
val processingCurrency: String,
|
val requestCurrency: String,
|
||||||
val processingAmount: BigDecimal
|
val requestAmount: BigDecimal
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PayverseWebhookRequest(
|
data class PayverseWebhookRequest(
|
||||||
@@ -81,7 +81,6 @@ data class PayverseWebhookRequest(
|
|||||||
val schemeGroup: String,
|
val schemeGroup: String,
|
||||||
val schemeCode: String,
|
val schemeCode: String,
|
||||||
val orderId: String,
|
val orderId: String,
|
||||||
val productName: String,
|
|
||||||
val requestCurrency: String,
|
val requestCurrency: String,
|
||||||
val requestAmount: BigDecimal,
|
val requestAmount: BigDecimal,
|
||||||
val resultStatus: String,
|
val resultStatus: String,
|
||||||
|
|||||||
@@ -113,15 +113,18 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha
|
|||||||
val paymentGatewayCondition = when (container) {
|
val paymentGatewayCondition = when (container) {
|
||||||
"aos" -> {
|
"aos" -> {
|
||||||
payment.paymentGateway.eq(PaymentGateway.PG)
|
payment.paymentGateway.eq(PaymentGateway.PG)
|
||||||
|
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||||
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
||||||
}
|
}
|
||||||
|
|
||||||
"ios" -> {
|
"ios" -> {
|
||||||
payment.paymentGateway.eq(PaymentGateway.PG)
|
payment.paymentGateway.eq(PaymentGateway.PG)
|
||||||
|
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||||
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> payment.paymentGateway.eq(PaymentGateway.PG)
|
else -> payment.paymentGateway.eq(PaymentGateway.PG)
|
||||||
|
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||||
}
|
}
|
||||||
|
|
||||||
return paymentGatewayCondition.or(payment.paymentGateway.eq(PaymentGateway.POINT_CLICK_AD))
|
return paymentGatewayCondition.or(payment.paymentGateway.eq(PaymentGateway.POINT_CLICK_AD))
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ class ChargeService(
|
|||||||
private val payverseClientKey: String,
|
private val payverseClientKey: String,
|
||||||
@Value("\${payverse.secret-key}")
|
@Value("\${payverse.secret-key}")
|
||||||
private val payverseSecretKey: String,
|
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}")
|
@Value("\${payverse.host}")
|
||||||
private val payverseHost: String,
|
private val payverseHost: String,
|
||||||
|
|
||||||
@@ -94,11 +102,20 @@ class ChargeService(
|
|||||||
return when (charge.payment?.status) {
|
return when (charge.payment?.status) {
|
||||||
PaymentStatus.REQUEST -> {
|
PaymentStatus.REQUEST -> {
|
||||||
// 성공 조건 검증
|
// 성공 조건 검증
|
||||||
|
val mid = if (request.requestCurrency == "KRW") {
|
||||||
|
payverseMid
|
||||||
|
} else {
|
||||||
|
payverseUsdMid
|
||||||
|
}
|
||||||
val expectedSign = DigestUtils.sha512Hex(
|
val expectedSign = DigestUtils.sha512Hex(
|
||||||
String.format(
|
String.format(
|
||||||
"||%s||%s||%s||%s||%s||",
|
"||%s||%s||%s||%s||%s||",
|
||||||
payverseSecretKey,
|
if (request.requestCurrency == "KRW") {
|
||||||
payverseMid,
|
payverseSecretKey
|
||||||
|
} else {
|
||||||
|
payverseUsdSecretKey
|
||||||
|
},
|
||||||
|
mid,
|
||||||
request.orderId,
|
request.orderId,
|
||||||
request.requestAmount,
|
request.requestAmount,
|
||||||
request.approvalDay
|
request.approvalDay
|
||||||
@@ -106,12 +123,12 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val isAmountMatch = request.requestAmount.compareTo(
|
val isAmountMatch = request.requestAmount.compareTo(
|
||||||
BigDecimal.valueOf(charge.payment!!.price)
|
charge.payment!!.price
|
||||||
) == 0
|
) == 0
|
||||||
|
|
||||||
val isSuccess = request.resultStatus == "SUCCESS" &&
|
val isSuccess = request.resultStatus == "SUCCESS" &&
|
||||||
request.mid == payverseMid &&
|
request.mid == mid &&
|
||||||
charge.title == request.productName &&
|
request.orderId.toLongOrNull() == charge.id &&
|
||||||
isAmountMatch &&
|
isAmountMatch &&
|
||||||
request.sign == expectedSign
|
request.sign == expectedSign
|
||||||
|
|
||||||
@@ -219,29 +236,58 @@ class ChargeService(
|
|||||||
val can = canRepository.findByIdOrNull(request.canId)
|
val can = canRepository.findByIdOrNull(request.canId)
|
||||||
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
|
?: 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)
|
val charge = Charge(can.can, can.rewardCan)
|
||||||
charge.title = can.title
|
charge.title = can.title
|
||||||
charge.member = member
|
charge.member = member
|
||||||
charge.can = can
|
charge.can = can
|
||||||
|
|
||||||
val payment = Payment(paymentGateway = PaymentGateway.PAYVERSE)
|
val payment = Payment(paymentGateway = PaymentGateway.PAYVERSE)
|
||||||
payment.price = can.price.toDouble()
|
payment.price = can.price
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
val savedCharge = chargeRepository.save(charge)
|
val savedCharge = chargeRepository.save(charge)
|
||||||
|
|
||||||
val chargeId = savedCharge.id!!
|
val chargeId = savedCharge.id!!
|
||||||
val amount = BigDecimal(savedCharge.payment!!.price)
|
val amount = BigDecimal(
|
||||||
|
savedCharge.payment!!.price
|
||||||
|
.setScale(4, RoundingMode.HALF_UP)
|
||||||
|
.stripTrailingZeros()
|
||||||
|
.toPlainString()
|
||||||
|
)
|
||||||
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
val reqDate = savedCharge.createdAt!!.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||||
val sign = DigestUtils.sha512Hex(
|
val sign = DigestUtils.sha512Hex(
|
||||||
String.format("||%s||%s||%s||%s||%s||", payverseSecretKey, payverseMid, chargeId, amount, reqDate)
|
String.format(
|
||||||
|
"||%s||%s||%s||%s||%s||",
|
||||||
|
secretKey,
|
||||||
|
mid,
|
||||||
|
chargeId,
|
||||||
|
amount,
|
||||||
|
reqDate
|
||||||
|
)
|
||||||
)
|
)
|
||||||
val customerId = "${serverEnv}_user_${member.id!!}"
|
val customerId = "${serverEnv}_user_${member.id!!}"
|
||||||
val requestCurrency = "KRW"
|
|
||||||
|
|
||||||
val payload = linkedMapOf(
|
val payload = linkedMapOf(
|
||||||
"mid" to payverseMid,
|
"mid" to mid,
|
||||||
"clientKey" to payverseClientKey,
|
"clientKey" to clientKey,
|
||||||
"orderId" to chargeId.toString(),
|
"orderId" to chargeId.toString(),
|
||||||
"customerId" to customerId,
|
"customerId" to customerId,
|
||||||
"productName" to can.title,
|
"productName" to can.title,
|
||||||
@@ -262,6 +308,18 @@ class ChargeService(
|
|||||||
val member = memberRepository.findByIdOrNull(memberId)
|
val member = memberRepository.findByIdOrNull(memberId)
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
?: 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) {
|
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
@@ -274,8 +332,8 @@ class ChargeService(
|
|||||||
val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}"
|
val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}"
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.addHeader("mid", payverseMid)
|
.addHeader("mid", mid)
|
||||||
.addHeader("clientKey", payverseClientKey)
|
.addHeader("clientKey", clientKey)
|
||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -287,10 +345,12 @@ class ChargeService(
|
|||||||
val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.")
|
val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
|
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
|
||||||
|
|
||||||
|
val customerId = "${serverEnv}_user_${member.id!!}"
|
||||||
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
|
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
|
||||||
verifyResponse.transactionStatus == "SUCCESS" &&
|
verifyResponse.transactionStatus == "SUCCESS" &&
|
||||||
verifyResponse.orderId.toLongOrNull() == charge.id &&
|
verifyResponse.orderId.toLongOrNull() == charge.id &&
|
||||||
verifyResponse.processingAmount.compareTo(BigDecimal.valueOf(charge.can!!.price.toLong())) == 0
|
verifyResponse.customerId == customerId &&
|
||||||
|
verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
// verify 함수의 232~248 라인과 동일 처리
|
// verify 함수의 232~248 라인과 동일 처리
|
||||||
@@ -303,7 +363,7 @@ class ChargeService(
|
|||||||
charge.payment?.method = mappedMethod ?: verifyResponse.schemeCode
|
charge.payment?.method = mappedMethod ?: verifyResponse.schemeCode
|
||||||
charge.payment?.status = PaymentStatus.COMPLETE
|
charge.payment?.status = PaymentStatus.COMPLETE
|
||||||
// 통화코드 설정
|
// 통화코드 설정
|
||||||
charge.payment?.locale = verifyResponse.processingCurrency
|
charge.payment?.locale = verifyResponse.requestCurrency
|
||||||
|
|
||||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||||
|
|
||||||
@@ -315,7 +375,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -330,7 +390,7 @@ class ChargeService(
|
|||||||
PaymentStatus.COMPLETE -> {
|
PaymentStatus.COMPLETE -> {
|
||||||
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
|
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -353,7 +413,7 @@ class ChargeService(
|
|||||||
charge.can = can
|
charge.can = can
|
||||||
|
|
||||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||||
payment.price = can.price.toDouble()
|
payment.price = can.price
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -392,7 +452,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -424,7 +484,7 @@ class ChargeService(
|
|||||||
VerifyResult::class.java
|
VerifyResult::class.java
|
||||||
)
|
)
|
||||||
|
|
||||||
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
|
if (verifyResult.status == 1) {
|
||||||
charge.payment?.receiptId = verifyResult.receiptId
|
charge.payment?.receiptId = verifyResult.receiptId
|
||||||
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
|
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
|
||||||
"${verifyResult.pg}-${verifyResult.method}"
|
"${verifyResult.pg}-${verifyResult.method}"
|
||||||
@@ -442,7 +502,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -467,7 +527,7 @@ class ChargeService(
|
|||||||
payment.price = if (request.price != null) {
|
payment.price = if (request.price != null) {
|
||||||
request.price!!
|
request.price!!
|
||||||
} else {
|
} else {
|
||||||
0.toDouble()
|
0.toBigDecimal()
|
||||||
}
|
}
|
||||||
|
|
||||||
payment.locale = request.locale
|
payment.locale = request.locale
|
||||||
@@ -502,7 +562,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
@@ -519,7 +579,7 @@ class ChargeService(
|
|||||||
member: Member,
|
member: Member,
|
||||||
title: String,
|
title: String,
|
||||||
chargeCan: Int,
|
chargeCan: Int,
|
||||||
price: Double,
|
price: BigDecimal,
|
||||||
currencyCode: String,
|
currencyCode: String,
|
||||||
productId: String,
|
productId: String,
|
||||||
purchaseToken: String,
|
purchaseToken: String,
|
||||||
@@ -547,8 +607,7 @@ class ChargeService(
|
|||||||
memberId: Long,
|
memberId: Long,
|
||||||
chargeId: Long,
|
chargeId: Long,
|
||||||
productId: String,
|
productId: String,
|
||||||
purchaseToken: String,
|
purchaseToken: String
|
||||||
paymentGateway: PaymentGateway
|
|
||||||
): ChargeCompleteResponse {
|
): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
||||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
@@ -570,7 +629,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return ChargeCompleteResponse(
|
return ChargeCompleteResponse(
|
||||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
price = charge.payment!!.price,
|
||||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.can.charge.temp
|
package kr.co.vividnext.sodalive.can.charge.temp
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
data class ChargeTempRequest(
|
data class ChargeTempRequest(
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val price: Int,
|
val price: BigDecimal,
|
||||||
val paymentGateway: PaymentGateway
|
val paymentGateway: PaymentGateway
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class ChargeTempService(
|
|||||||
charge.member = member
|
charge.member = member
|
||||||
|
|
||||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||||
payment.price = request.price.toDouble()
|
payment.price = request.price
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -66,7 +66,7 @@ class ChargeTempService(
|
|||||||
VerifyResult::class.java
|
VerifyResult::class.java
|
||||||
)
|
)
|
||||||
|
|
||||||
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) {
|
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price) {
|
||||||
charge.payment?.receiptId = verifyResult.receiptId
|
charge.payment?.receiptId = verifyResult.receiptId
|
||||||
charge.payment?.method = verifyResult.method
|
charge.payment?.method = verifyResult.method
|
||||||
charge.payment?.status = PaymentStatus.COMPLETE
|
charge.payment?.status = PaymentStatus.COMPLETE
|
||||||
@@ -74,7 +74,7 @@ class ChargeTempService(
|
|||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException("결제정보에 오류가 있습니다.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ class CanPaymentService(
|
|||||||
useCanRepository.save(useCan)
|
useCanRepository.save(useCan)
|
||||||
|
|
||||||
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||||
|
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
||||||
setUseCanCalculate(
|
setUseCanCalculate(
|
||||||
recipientId,
|
recipientId,
|
||||||
useRewardCan,
|
useRewardCan,
|
||||||
@@ -379,6 +380,7 @@ class CanPaymentService(
|
|||||||
useCanRepository.save(useCan)
|
useCanRepository.save(useCan)
|
||||||
|
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||||
@@ -428,6 +430,7 @@ class CanPaymentService(
|
|||||||
useCanRepository.save(useCan)
|
useCanRepository.save(useCan)
|
||||||
|
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||||
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.payment
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import java.math.BigDecimal
|
||||||
import javax.persistence.Column
|
import javax.persistence.Column
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.EnumType
|
import javax.persistence.EnumType
|
||||||
@@ -25,7 +26,8 @@ data class Payment(
|
|||||||
var receiptId: String? = null
|
var receiptId: String? = null
|
||||||
var method: String? = null
|
var method: String? = null
|
||||||
|
|
||||||
var price: Double = 0.toDouble()
|
@Column(precision = 10, scale = 4, nullable = false)
|
||||||
|
var price: BigDecimal = 0.toBigDecimal()
|
||||||
var locale: String? = null
|
var locale: String? = null
|
||||||
var orderId: String? = null
|
var orderId: String? = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ class ChatCharacterController(
|
|||||||
size = 50
|
size = 50
|
||||||
).content
|
).content
|
||||||
|
|
||||||
|
// 추천 캐릭터 조회
|
||||||
|
// 최근 대화한 캐릭터를 제외한 랜덤 30개 조회
|
||||||
|
// Controller에서는 호출만
|
||||||
|
// 세부로직은 추후에 변경될 수 있으므로 Service에 별도로 생성
|
||||||
|
val excludeIds = recentCharacters.map { it.characterId }
|
||||||
|
val recommendCharacters = service.getRecommendCharacters(excludeIds, 30)
|
||||||
|
|
||||||
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
||||||
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
||||||
.map { agg ->
|
.map { agg ->
|
||||||
@@ -85,7 +92,8 @@ class ChatCharacterController(
|
|||||||
characterId = it.id!!,
|
characterId = it.id!!,
|
||||||
name = it.name,
|
name = it.name,
|
||||||
description = it.description,
|
description = it.description,
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
||||||
|
new = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -98,6 +106,7 @@ class ChatCharacterController(
|
|||||||
recentCharacters = recentCharacters,
|
recentCharacters = recentCharacters,
|
||||||
popularCharacters = popularCharacters,
|
popularCharacters = popularCharacters,
|
||||||
newCharacters = newCharacters,
|
newCharacters = newCharacters,
|
||||||
|
recommendCharacters = recommendCharacters,
|
||||||
curationSections = curationSections
|
curationSections = curationSections
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -193,4 +202,23 @@ class ChatCharacterController(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추천 캐릭터 새로고침 API
|
||||||
|
* - 최근 대화한 캐릭터를 제외하고 랜덤 20개 반환
|
||||||
|
* - 비회원 또는 본인인증되지 않은 경우: 최근 대화 목록 없음 → 전체 활성 캐릭터 중 랜덤 20개
|
||||||
|
*/
|
||||||
|
@GetMapping("/recommend")
|
||||||
|
fun getRecommendCharacters(
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
val recent = if (member == null || member.auth == null) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
chatRoomService
|
||||||
|
.listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려
|
||||||
|
.map { it.characterId }
|
||||||
|
}
|
||||||
|
ApiResponse.ok(service.getRecommendCharacters(recent, 20))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ data class CharacterMainResponse(
|
|||||||
val recentCharacters: List<RecentCharacter>,
|
val recentCharacters: List<RecentCharacter>,
|
||||||
val popularCharacters: List<Character>,
|
val popularCharacters: List<Character>,
|
||||||
val newCharacters: List<Character>,
|
val newCharacters: List<Character>,
|
||||||
|
val recommendCharacters: List<Character>,
|
||||||
val curationSections: List<CurationSection>
|
val curationSections: List<CurationSection>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +21,8 @@ data class Character(
|
|||||||
@JsonProperty("characterId") val characterId: Long,
|
@JsonProperty("characterId") val characterId: Long,
|
||||||
@JsonProperty("name") val name: String,
|
@JsonProperty("name") val name: String,
|
||||||
@JsonProperty("description") val description: String,
|
@JsonProperty("description") val description: String,
|
||||||
@JsonProperty("imageUrl") val imageUrl: String
|
@JsonProperty("imageUrl") val imageUrl: String,
|
||||||
|
@JsonProperty("isNew") val new: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RecentCharacter(
|
data class RecentCharacter(
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import org.springframework.data.domain.Page
|
|||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository {
|
interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository {
|
||||||
@@ -26,6 +28,21 @@ interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, Charac
|
|||||||
"WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true"
|
"WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true"
|
||||||
)
|
)
|
||||||
fun findMaxSortOrderByCharacterId(characterId: Long): Int
|
fun findMaxSortOrderByCharacterId(characterId: Long): Int
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select distinct c.id
|
||||||
|
from CharacterImage ci
|
||||||
|
join ci.chatCharacter c
|
||||||
|
where ci.isActive = true
|
||||||
|
and ci.createdAt >= :since
|
||||||
|
and c.id in :characterIds
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findCharacterIdsWithRecentImages(
|
||||||
|
@Param("characterIds") characterIds: List<Long>,
|
||||||
|
@Param("since") since: LocalDateTime
|
||||||
|
): List<Long>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CharacterImageQueryRepository {
|
interface CharacterImageQueryRepository {
|
||||||
|
|||||||
@@ -74,5 +74,29 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
|||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
): List<ChatCharacter>
|
): List<ChatCharacter>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 캐릭터 무작위 조회
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT c FROM ChatCharacter c
|
||||||
|
WHERE c.isActive = true
|
||||||
|
ORDER BY function('RAND')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findRandomActive(pageable: Pageable): List<ChatCharacter>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제외할 캐릭터를 뺀 활성 캐릭터 무작위 조회
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT c FROM ChatCharacter c
|
||||||
|
WHERE c.isActive = true AND c.id NOT IN :excludeIds
|
||||||
|
ORDER BY function('RAND')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter>
|
||||||
|
|
||||||
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
|
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
|
|||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
|
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
@@ -34,10 +35,42 @@ class ChatCharacterService(
|
|||||||
private val hobbyRepository: ChatCharacterHobbyRepository,
|
private val hobbyRepository: ChatCharacterHobbyRepository,
|
||||||
private val goalRepository: ChatCharacterGoalRepository,
|
private val goalRepository: ChatCharacterGoalRepository,
|
||||||
private val popularCharacterQuery: PopularCharacterQuery,
|
private val popularCharacterQuery: PopularCharacterQuery,
|
||||||
|
private val imageRepository: CharacterImageRepository,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getRecommendCharacters(excludeCharacterIds: List<Long> = emptyList(), limit: Int = 20): List<Character> {
|
||||||
|
val safeLimit = if (limit <= 0) 20 else if (limit > 50) 50 else limit
|
||||||
|
val chars = if (excludeCharacterIds.isNotEmpty()) {
|
||||||
|
chatCharacterRepository.findRandomActiveExcluding(excludeCharacterIds, PageRequest.of(0, safeLimit))
|
||||||
|
} else {
|
||||||
|
chatCharacterRepository.findRandomActive(PageRequest.of(0, safeLimit))
|
||||||
|
}
|
||||||
|
|
||||||
|
val recentSet = if (chars.isNotEmpty()) {
|
||||||
|
imageRepository
|
||||||
|
.findCharacterIdsWithRecentImages(
|
||||||
|
chars.map { it.id!! },
|
||||||
|
LocalDateTime.now().minusDays(3)
|
||||||
|
)
|
||||||
|
.toSet()
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
return chars.map {
|
||||||
|
Character(
|
||||||
|
characterId = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
||||||
|
new = recentSet.contains(it.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회
|
* UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회
|
||||||
* Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용
|
* Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용
|
||||||
@@ -51,12 +84,25 @@ class ChatCharacterService(
|
|||||||
val window = RankingWindowCalculator.now("popular-character")
|
val window = RankingWindowCalculator.now("popular-character")
|
||||||
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
|
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
|
||||||
val list = loadCharactersInOrder(topIds)
|
val list = loadCharactersInOrder(topIds)
|
||||||
|
|
||||||
|
val recentSet = if (list.isNotEmpty()) {
|
||||||
|
imageRepository
|
||||||
|
.findCharacterIdsWithRecentImages(
|
||||||
|
list.map { it.id!! },
|
||||||
|
LocalDateTime.now().minusDays(3)
|
||||||
|
)
|
||||||
|
.toSet()
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
return list.map {
|
return list.map {
|
||||||
Character(
|
Character(
|
||||||
characterId = it.id!!,
|
characterId = it.id!!,
|
||||||
name = it.name,
|
name = it.name,
|
||||||
description = it.description,
|
description = it.description,
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
||||||
|
new = recentSet.contains(it.id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,15 +137,28 @@ class ChatCharacterService(
|
|||||||
content = emptyList()
|
content = emptyList()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val fallback = chatCharacterRepository.findByIsActiveTrue(
|
val chars = chatCharacterRepository.findByIsActiveTrue(
|
||||||
PageRequest.of(0, 20, Sort.by("createdAt").descending())
|
PageRequest.of(0, 20, Sort.by("createdAt").descending())
|
||||||
|
).content
|
||||||
|
|
||||||
|
val recentSet = if (chars.isNotEmpty()) {
|
||||||
|
imageRepository
|
||||||
|
.findCharacterIdsWithRecentImages(
|
||||||
|
chars.map { it.id!! },
|
||||||
|
LocalDateTime.now().minusDays(3)
|
||||||
)
|
)
|
||||||
val content = fallback.content.map {
|
.toSet()
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = chars.map {
|
||||||
Character(
|
Character(
|
||||||
characterId = it.id!!,
|
characterId = it.id!!,
|
||||||
name = it.name,
|
name = it.name,
|
||||||
description = it.description,
|
description = it.description,
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
||||||
|
new = recentSet.contains(it.id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return RecentCharactersResponse(
|
return RecentCharactersResponse(
|
||||||
@@ -108,16 +167,29 @@ class ChatCharacterService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val pageResult = chatCharacterRepository.findRecentSince(
|
val chars = chatCharacterRepository.findRecentSince(
|
||||||
since,
|
since,
|
||||||
PageRequest.of(safePage, safeSize)
|
PageRequest.of(safePage, safeSize)
|
||||||
|
).content
|
||||||
|
|
||||||
|
val recentSet = if (chars.isNotEmpty()) {
|
||||||
|
imageRepository
|
||||||
|
.findCharacterIdsWithRecentImages(
|
||||||
|
chars.map { it.id!! },
|
||||||
|
LocalDateTime.now().minusDays(3)
|
||||||
)
|
)
|
||||||
val content = pageResult.content.map {
|
.toSet()
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = chars.map {
|
||||||
Character(
|
Character(
|
||||||
characterId = it.id!!,
|
characterId = it.id!!,
|
||||||
name = it.name,
|
name = it.name,
|
||||||
description = it.description,
|
description = it.description,
|
||||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
||||||
|
new = recentSet.contains(it.id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.original.controller
|
package kr.co.vividnext.sodalive.chat.original.controller
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
|
||||||
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse
|
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse
|
||||||
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse
|
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse
|
||||||
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse
|
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse
|
||||||
@@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.PathVariable
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앱용 원작(오리지널 작품) 공개 API
|
* 앱용 원작(오리지널 작품) 공개 API
|
||||||
@@ -25,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
@RequestMapping("/api/chat/original")
|
@RequestMapping("/api/chat/original")
|
||||||
class OriginalWorkController(
|
class OriginalWorkController(
|
||||||
private val queryService: OriginalWorkQueryService,
|
private val queryService: OriginalWorkQueryService,
|
||||||
|
private val characterImageRepository: CharacterImageRepository,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
@@ -65,17 +70,34 @@ class OriginalWorkController(
|
|||||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
|
|
||||||
val ow = queryService.getOriginalWork(id)
|
val ow = queryService.getOriginalWork(id)
|
||||||
val pageRes = queryService.getActiveCharactersPage(id, page = 0, size = 20)
|
val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content
|
||||||
val characters = pageRes.content.map {
|
|
||||||
|
val recentSet = if (chars.isNotEmpty()) {
|
||||||
|
characterImageRepository
|
||||||
|
.findCharacterIdsWithRecentImages(
|
||||||
|
chars.map { it.id!! },
|
||||||
|
LocalDateTime.now().minusDays(3)
|
||||||
|
)
|
||||||
|
.toSet()
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
OriginalWorkDetailResponse.from(
|
||||||
|
ow,
|
||||||
|
imageHost,
|
||||||
|
chars.map<ChatCharacter, Character> {
|
||||||
val path = it.imagePath ?: "profile/default-profile.png"
|
val path = it.imagePath ?: "profile/default-profile.png"
|
||||||
Character(
|
Character(
|
||||||
characterId = it.id!!,
|
characterId = it.id!!,
|
||||||
name = it.name,
|
name = it.name,
|
||||||
description = it.description,
|
description = it.description,
|
||||||
imageUrl = "$imageHost/$path"
|
imageUrl = "$imageHost/$path",
|
||||||
|
new = recentSet.contains(it.id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val response = OriginalWorkDetailResponse.from(ow, imageHost, characters)
|
)
|
||||||
ApiResponse.ok(response)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class OriginalWorkQueryService(
|
|||||||
val safePage = if (page < 0) 0 else page
|
val safePage = if (page < 0) 0 else page
|
||||||
val safeSize = when {
|
val safeSize = when {
|
||||||
size <= 0 -> 20
|
size <= 0 -> 20
|
||||||
size > 50 -> 50
|
size > 20 -> 20
|
||||||
else -> size
|
else -> size
|
||||||
}
|
}
|
||||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class ChatRoomQuotaController(
|
|||||||
): ApiResponse<PurchaseRoomQuotaResponse> = run {
|
): ApiResponse<PurchaseRoomQuotaResponse> = run {
|
||||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
if (req.container.isBlank()) throw SodaException("container를 확인해주세요.")
|
if (req.container.isBlank()) throw SodaException("잘못된 접근입니다")
|
||||||
|
|
||||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||||
@@ -79,7 +79,7 @@ class ChatRoomQuotaController(
|
|||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
chatRoomId = chatRoomId,
|
chatRoomId = chatRoomId,
|
||||||
characterId = characterId,
|
characterId = characterId,
|
||||||
addPaid = 40,
|
addPaid = 12,
|
||||||
container = req.container
|
container = req.container
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -126,13 +126,13 @@ class ChatRoomQuotaService(
|
|||||||
memberId: Long,
|
memberId: Long,
|
||||||
chatRoomId: Long,
|
chatRoomId: Long,
|
||||||
characterId: Long,
|
characterId: Long,
|
||||||
addPaid: Int = 40,
|
addPaid: Int = 12,
|
||||||
container: String
|
container: String
|
||||||
): RoomQuotaStatus {
|
): RoomQuotaStatus {
|
||||||
// 요구사항: 30캔 결제 및 UseCan에 방/캐릭터 기록
|
// 요구사항: 10캔 결제 및 UseCan에 방/캐릭터 기록
|
||||||
canPaymentService.spendCan(
|
canPaymentService.spendCan(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
needCan = 30,
|
needCan = 10,
|
||||||
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
|
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
|
||||||
chatRoomId = chatRoomId,
|
chatRoomId = chatRoomId,
|
||||||
characterId = characterId,
|
characterId = characterId,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package kr.co.vividnext.sodalive.common
|
||||||
|
|
||||||
|
const val WAF_GEO_HEADER = "x-amzn-waf-geo-country"
|
||||||
|
|
||||||
|
enum class GeoCountry { KR, OTHER }
|
||||||
|
|
||||||
|
fun parseGeo(headerValue: String?): GeoCountry =
|
||||||
|
if (headerValue?.trim()?.uppercase() == "KR") GeoCountry.KR else GeoCountry.OTHER
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package kr.co.vividnext.sodalive.common
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
|
import javax.servlet.FilterChain
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class GeoCountryFilter : OncePerRequestFilter() {
|
||||||
|
override fun doFilterInternal(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
filterChain: FilterChain
|
||||||
|
) {
|
||||||
|
val country = parseGeo(request.getHeader(WAF_GEO_HEADER))
|
||||||
|
request.setAttribute("geoCountry", country)
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.common
|
|||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.dao.DataIntegrityViolationException
|
import org.springframework.dao.DataIntegrityViolationException
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.ResponseEntity
|
|
||||||
import org.springframework.security.access.AccessDeniedException
|
import org.springframework.security.access.AccessDeniedException
|
||||||
import org.springframework.security.authentication.BadCredentialsException
|
import org.springframework.security.authentication.BadCredentialsException
|
||||||
import org.springframework.security.authentication.InternalAuthenticationServiceException
|
import org.springframework.security.authentication.InternalAuthenticationServiceException
|
||||||
@@ -26,13 +25,6 @@ class SodaExceptionHandler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResponseStatusException은 ApiResponse로 래핑하지 않고 그대로 전달
|
|
||||||
@ExceptionHandler(ResponseStatusException::class)
|
|
||||||
fun handleResponseStatusException(e: ResponseStatusException): ResponseEntity<Void> {
|
|
||||||
// 별도 바디 없이 상태코드만 반환하여 기본 예외 형태를 유지
|
|
||||||
return ResponseEntity.status(e.status).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExceptionHandler(MaxUploadSizeExceededException::class)
|
@ExceptionHandler(MaxUploadSizeExceededException::class)
|
||||||
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
|
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
|
||||||
logger.error("API error", e)
|
logger.error("API error", e)
|
||||||
@@ -72,6 +64,7 @@ class SodaExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(Exception::class)
|
@ExceptionHandler(Exception::class)
|
||||||
fun handleException(e: Exception) = run {
|
fun handleException(e: Exception) = run {
|
||||||
|
if (e is ResponseStatusException) throw e
|
||||||
logger.error("API error", e)
|
logger.error("API error", e)
|
||||||
ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
|
ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class SecurityConfig(
|
|||||||
.antMatchers("/api/home").permitAll()
|
.antMatchers("/api/home").permitAll()
|
||||||
.antMatchers("/api/home/latest-content").permitAll()
|
.antMatchers("/api/home/latest-content").permitAll()
|
||||||
.antMatchers("/api/home/day-of-week-series").permitAll()
|
.antMatchers("/api/home/day-of-week-series").permitAll()
|
||||||
|
.antMatchers("/api/home/content-ranking").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/live").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/live").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/faq").permitAll()
|
.antMatchers(HttpMethod.GET, "/faq").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/faq/category").permitAll()
|
.antMatchers(HttpMethod.GET, "/faq/category").permitAll()
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ enum class PurchaseOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class SortType {
|
enum class SortType {
|
||||||
NEWEST, PRICE_HIGH, PRICE_LOW
|
NEWEST, PRICE_HIGH, PRICE_LOW, POPULARITY
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
|
|||||||
@@ -237,6 +237,33 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
ApiResponse.ok(service.unpinAtTheTop(contentId = id, member = member))
|
ApiResponse.ok(service.unpinAtTheTop(contentId = id, member = member))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/all")
|
||||||
|
fun getAllContents(
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@RequestParam("isFree", required = false) isFree: Boolean? = null,
|
||||||
|
@RequestParam("isPointAvailableOnly", required = false) isPointAvailableOnly: Boolean? = null,
|
||||||
|
@RequestParam("sort-type", required = false) sortType: SortType? = SortType.NEWEST,
|
||||||
|
@RequestParam("theme", required = false) theme: String? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
|
pageable: Pageable
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
service.getLatestContentByTheme(
|
||||||
|
theme = if (theme == null) listOf() else listOf(theme),
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong(),
|
||||||
|
sortType = sortType ?: SortType.NEWEST,
|
||||||
|
isFree = isFree ?: false,
|
||||||
|
isAdult = (isAdultContentVisible ?: true) && member.auth != null,
|
||||||
|
isPointAvailableOnly = isPointAvailableOnly ?: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/replay-live")
|
@GetMapping("/replay-live")
|
||||||
fun replayLive(
|
fun replayLive(
|
||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ interface AudioContentQueryRepository {
|
|||||||
): Int
|
): Int
|
||||||
|
|
||||||
fun findByThemeFor2Weeks(
|
fun findByThemeFor2Weeks(
|
||||||
isFree: Boolean = false,
|
|
||||||
cloudfrontHost: String,
|
cloudfrontHost: String,
|
||||||
memberId: Long,
|
memberId: Long,
|
||||||
theme: List<String> = emptyList(),
|
theme: List<String> = emptyList(),
|
||||||
@@ -120,7 +119,6 @@ interface AudioContentQueryRepository {
|
|||||||
): List<GetAudioContentMainItem>
|
): List<GetAudioContentMainItem>
|
||||||
|
|
||||||
fun totalCountNewContentFor2Weeks(
|
fun totalCountNewContentFor2Weeks(
|
||||||
isFree: Boolean = false,
|
|
||||||
theme: List<String> = emptyList(),
|
theme: List<String> = emptyList(),
|
||||||
memberId: Long,
|
memberId: Long,
|
||||||
isAdult: Boolean,
|
isAdult: Boolean,
|
||||||
@@ -182,8 +180,11 @@ interface AudioContentQueryRepository {
|
|||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Long,
|
limit: Long,
|
||||||
|
sortType: SortType,
|
||||||
isFree: Boolean,
|
isFree: Boolean,
|
||||||
isAdult: Boolean
|
isAdult: Boolean,
|
||||||
|
orderByRandom: Boolean = false,
|
||||||
|
isPointAvailableOnly: Boolean = false
|
||||||
): List<AudioContentMainItem>
|
): List<AudioContentMainItem>
|
||||||
|
|
||||||
fun findContentByCurationId(
|
fun findContentByCurationId(
|
||||||
@@ -193,6 +194,11 @@ interface AudioContentQueryRepository {
|
|||||||
offset: Long = 0,
|
offset: Long = 0,
|
||||||
limit: Long = 20
|
limit: Long = 20
|
||||||
): List<GetAudioContentMainItem>
|
): List<GetAudioContentMainItem>
|
||||||
|
|
||||||
|
fun findLatestContentByCreatorId(
|
||||||
|
creatorId: Long,
|
||||||
|
isAdult: Boolean = false
|
||||||
|
): AudioContent?
|
||||||
}
|
}
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@@ -236,6 +242,7 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
SortType.NEWEST -> audioContent.releaseDate.desc()
|
SortType.NEWEST -> audioContent.releaseDate.desc()
|
||||||
SortType.PRICE_HIGH -> audioContent.price.desc()
|
SortType.PRICE_HIGH -> audioContent.price.desc()
|
||||||
SortType.PRICE_LOW -> audioContent.price.asc()
|
SortType.PRICE_LOW -> audioContent.price.asc()
|
||||||
|
SortType.POPULARITY -> audioContent.playCount.desc()
|
||||||
}
|
}
|
||||||
|
|
||||||
var where = audioContent.member.id.eq(creatorId)
|
var where = audioContent.member.id.eq(creatorId)
|
||||||
@@ -457,6 +464,12 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
audioContent.releaseDate.asc(),
|
audioContent.releaseDate.asc(),
|
||||||
audioContent.id.asc()
|
audioContent.id.asc()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SortType.POPULARITY -> listOf(
|
||||||
|
audioContent.playCount.desc(),
|
||||||
|
audioContent.releaseDate.asc(),
|
||||||
|
audioContent.id.asc()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var where = audioContent.isActive.isTrue
|
var where = audioContent.isActive.isTrue
|
||||||
@@ -688,7 +701,6 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun totalCountNewContentFor2Weeks(
|
override fun totalCountNewContentFor2Weeks(
|
||||||
isFree: Boolean,
|
|
||||||
theme: List<String>,
|
theme: List<String>,
|
||||||
memberId: Long,
|
memberId: Long,
|
||||||
isAdult: Boolean,
|
isAdult: Boolean,
|
||||||
@@ -725,10 +737,6 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
where = where.and(audioContentTheme.theme.`in`(theme))
|
where = where.and(audioContentTheme.theme.`in`(theme))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFree) {
|
|
||||||
where = where.and(audioContent.price.loe(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(audioContent.id)
|
.select(audioContent.id)
|
||||||
.from(audioContent)
|
.from(audioContent)
|
||||||
@@ -740,7 +748,6 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun findByThemeFor2Weeks(
|
override fun findByThemeFor2Weeks(
|
||||||
isFree: Boolean,
|
|
||||||
cloudfrontHost: String,
|
cloudfrontHost: String,
|
||||||
memberId: Long,
|
memberId: Long,
|
||||||
theme: List<String>,
|
theme: List<String>,
|
||||||
@@ -780,10 +787,6 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
where = where.and(audioContentTheme.theme.`in`(theme))
|
where = where.and(audioContentTheme.theme.`in`(theme))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFree) {
|
|
||||||
where = where.and(audioContent.price.loe(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetAudioContentMainItem(
|
QGetAudioContentMainItem(
|
||||||
@@ -1302,8 +1305,11 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Long,
|
limit: Long,
|
||||||
|
sortType: SortType,
|
||||||
isFree: Boolean,
|
isFree: Boolean,
|
||||||
isAdult: Boolean
|
isAdult: Boolean,
|
||||||
|
orderByRandom: Boolean,
|
||||||
|
isPointAvailableOnly: Boolean
|
||||||
): List<AudioContentMainItem> {
|
): List<AudioContentMainItem> {
|
||||||
var where = audioContent.isActive.isTrue
|
var where = audioContent.isActive.isTrue
|
||||||
.and(audioContent.duration.isNotNull)
|
.and(audioContent.duration.isNotNull)
|
||||||
@@ -1338,6 +1344,31 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
where = where.and(audioContent.price.loe(0))
|
where = where.and(audioContent.price.loe(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPointAvailableOnly) {
|
||||||
|
where = where.and(audioContent.isPointAvailable.isTrue)
|
||||||
|
}
|
||||||
|
|
||||||
|
val orderBy = if (orderByRandom) {
|
||||||
|
Expressions.numberTemplate(Double::class.java, "function('rand')").asc()
|
||||||
|
} else {
|
||||||
|
when (sortType) {
|
||||||
|
SortType.NEWEST -> audioContent.releaseDate.desc()
|
||||||
|
SortType.PRICE_HIGH -> if (isFree) {
|
||||||
|
audioContent.releaseDate.desc()
|
||||||
|
} else {
|
||||||
|
audioContent.price.desc()
|
||||||
|
}
|
||||||
|
|
||||||
|
SortType.PRICE_LOW -> if (isFree) {
|
||||||
|
audioContent.releaseDate.asc()
|
||||||
|
} else {
|
||||||
|
audioContent.price.desc()
|
||||||
|
}
|
||||||
|
|
||||||
|
SortType.POPULARITY -> audioContent.playCount.desc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QAudioContentMainItem(
|
QAudioContentMainItem(
|
||||||
@@ -1355,7 +1386,7 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
.where(where)
|
.where(where)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.orderBy(audioContent.id.desc())
|
.orderBy(orderBy)
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1416,4 +1447,26 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findLatestContentByCreatorId(
|
||||||
|
creatorId: Long,
|
||||||
|
isAdult: Boolean
|
||||||
|
): AudioContent? {
|
||||||
|
var where = audioContent.member.id.eq(creatorId)
|
||||||
|
.and(audioContent.isActive.isTrue)
|
||||||
|
.and(audioContent.duration.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.loe(LocalDateTime.now()))
|
||||||
|
|
||||||
|
if (!isAdult) {
|
||||||
|
where = where.and(audioContent.isAdult.isFalse)
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(audioContent)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(audioContent.releaseDate.desc())
|
||||||
|
.limit(1)
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ class AudioContentService(
|
|||||||
|
|
||||||
// Check if the time difference is greater than 30 seconds (30000 milliseconds)
|
// Check if the time difference is greater than 30 seconds (30000 milliseconds)
|
||||||
return date2.time - date1.time
|
return date2.time - date1.time
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// Handle invalid time formats or parsing errors
|
// Handle invalid time formats or parsing errors
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -749,6 +749,49 @@ class AudioContentService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLatestCreatorAudioContent(
|
||||||
|
creatorId: Long,
|
||||||
|
member: Member,
|
||||||
|
isAdultContentVisible: Boolean
|
||||||
|
): GetAudioContentListItem? {
|
||||||
|
val isAdult = member.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
|
val audioContent = repository.findLatestContentByCreatorId(creatorId, isAdult) ?: return null
|
||||||
|
|
||||||
|
val commentCount = commentRepository
|
||||||
|
.totalCountCommentByContentId(
|
||||||
|
audioContent.id!!,
|
||||||
|
memberId = member.id!!,
|
||||||
|
isContentCreator = creatorId == member.id!!
|
||||||
|
)
|
||||||
|
|
||||||
|
val likeCount = audioContentLikeRepository
|
||||||
|
.totalCountAudioContentLike(audioContent.id!!)
|
||||||
|
|
||||||
|
val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType(
|
||||||
|
memberId = member.id!!,
|
||||||
|
contentId = audioContent.id!!
|
||||||
|
)
|
||||||
|
|
||||||
|
return GetAudioContentListItem(
|
||||||
|
contentId = audioContent.id!!,
|
||||||
|
coverImageUrl = "$coverImageHost/${audioContent.coverImage}",
|
||||||
|
title = audioContent.title,
|
||||||
|
price = audioContent.price,
|
||||||
|
themeStr = audioContent.theme!!.theme,
|
||||||
|
duration = audioContent.duration,
|
||||||
|
likeCount = likeCount,
|
||||||
|
commentCount = commentCount,
|
||||||
|
isPin = false,
|
||||||
|
isAdult = audioContent.isAdult,
|
||||||
|
isScheduledToOpen = audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now(),
|
||||||
|
isRented = isExistsAudioContent && orderType == OrderType.RENTAL,
|
||||||
|
isOwned = isExistsAudioContent && orderType == OrderType.KEEP,
|
||||||
|
isSoldOut = audioContent.remaining != null && audioContent.remaining!! <= 0,
|
||||||
|
isPointAvailable = audioContent.isPointAvailable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun getAudioContentList(
|
fun getAudioContentList(
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
sortType: SortType,
|
sortType: SortType,
|
||||||
@@ -945,16 +988,22 @@ class AudioContentService(
|
|||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
offset: Long = 0,
|
offset: Long = 0,
|
||||||
limit: Long = 20,
|
limit: Long = 20,
|
||||||
|
sortType: SortType = SortType.NEWEST,
|
||||||
isFree: Boolean = false,
|
isFree: Boolean = false,
|
||||||
isAdult: Boolean = false
|
isAdult: Boolean = false,
|
||||||
|
orderByRandom: Boolean = false,
|
||||||
|
isPointAvailableOnly: Boolean = false
|
||||||
): List<AudioContentMainItem> {
|
): List<AudioContentMainItem> {
|
||||||
return repository.getLatestContentByTheme(
|
return repository.getLatestContentByTheme(
|
||||||
theme = theme,
|
theme = theme,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
offset = offset,
|
offset = offset,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
|
sortType = sortType,
|
||||||
isFree = isFree,
|
isFree = isFree,
|
||||||
isAdult = isAdult
|
isAdult = isAdult,
|
||||||
|
orderByRandom = orderByRandom,
|
||||||
|
isPointAvailableOnly = isPointAvailableOnly
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ class AudioContentMainController(
|
|||||||
|
|
||||||
@GetMapping("/new/all")
|
@GetMapping("/new/all")
|
||||||
fun getNewContentAllByTheme(
|
fun getNewContentAllByTheme(
|
||||||
@RequestParam("isFree", required = false) isFree: Boolean? = null,
|
|
||||||
@RequestParam("theme") theme: String,
|
@RequestParam("theme") theme: String,
|
||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
@@ -110,7 +109,6 @@ class AudioContentMainController(
|
|||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getNewContentFor2WeeksByTheme(
|
service.getNewContentFor2WeeksByTheme(
|
||||||
isFree = isFree ?: false,
|
|
||||||
theme = theme,
|
theme = theme,
|
||||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
contentType = contentType ?: ContentType.ALL,
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
|||||||
@@ -28,16 +28,6 @@ class AudioContentMainService(
|
|||||||
@Cacheable(cacheNames = ["default"], key = "'themeList:' + ':' + #isAdult")
|
@Cacheable(cacheNames = ["default"], key = "'themeList:' + ':' + #isAdult")
|
||||||
fun getThemeList(isAdult: Boolean, contentType: ContentType): List<String> {
|
fun getThemeList(isAdult: Boolean, contentType: ContentType): List<String> {
|
||||||
return audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
|
return audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
|
||||||
.filter {
|
|
||||||
it != "모닝콜" &&
|
|
||||||
it != "알람" &&
|
|
||||||
it != "슬립콜" &&
|
|
||||||
it != "다시듣기" &&
|
|
||||||
it != "ASMR" &&
|
|
||||||
it != "릴레이" &&
|
|
||||||
it != "챌린지" &&
|
|
||||||
it != "자기소개"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -64,7 +54,6 @@ class AudioContentMainService(
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getNewContentFor2WeeksByTheme(
|
fun getNewContentFor2WeeksByTheme(
|
||||||
isFree: Boolean,
|
|
||||||
theme: String,
|
theme: String,
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
@@ -75,31 +64,19 @@ class AudioContentMainService(
|
|||||||
val themeList = if (theme.isBlank()) {
|
val themeList = if (theme.isBlank()) {
|
||||||
audioContentThemeRepository.getActiveThemeOfContent(
|
audioContentThemeRepository.getActiveThemeOfContent(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
isFree = isFree,
|
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
).filter {
|
)
|
||||||
it != "모닝콜" &&
|
|
||||||
it != "알람" &&
|
|
||||||
it != "슬립콜" &&
|
|
||||||
it != "다시듣기" &&
|
|
||||||
it != "ASMR" &&
|
|
||||||
it != "릴레이" &&
|
|
||||||
it != "챌린지" &&
|
|
||||||
it != "자기소개"
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
listOf(theme)
|
listOf(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
val totalCount = repository.totalCountNewContentFor2Weeks(
|
val totalCount = repository.totalCountNewContentFor2Weeks(
|
||||||
isFree,
|
|
||||||
themeList,
|
themeList,
|
||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
)
|
)
|
||||||
val items = repository.findByThemeFor2Weeks(
|
val items = repository.findByThemeFor2Weeks(
|
||||||
isFree,
|
|
||||||
cloudfrontHost = imageHost,
|
cloudfrontHost = imageHost,
|
||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
theme = themeList,
|
theme = themeList,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class AudioContentCurationQueryRepository(private val queryFactory: JPAQueryFact
|
|||||||
SortType.NEWEST -> audioContent.createdAt.desc()
|
SortType.NEWEST -> audioContent.createdAt.desc()
|
||||||
SortType.PRICE_HIGH -> audioContent.price.desc()
|
SortType.PRICE_HIGH -> audioContent.price.desc()
|
||||||
SortType.PRICE_LOW -> audioContent.price.asc()
|
SortType.PRICE_LOW -> audioContent.price.asc()
|
||||||
|
SortType.POPULARITY -> audioContent.playCount.desc()
|
||||||
}
|
}
|
||||||
|
|
||||||
var where = audioContent.isActive.isTrue
|
var where = audioContent.isActive.isTrue
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ class OrderQueryRepositoryImpl(
|
|||||||
return queryFactory.select(order.id)
|
return queryFactory.select(order.id)
|
||||||
.from(order)
|
.from(order)
|
||||||
.where(where)
|
.where(where)
|
||||||
|
.distinct()
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
class ContentSeriesController(private val service: ContentSeriesService) {
|
class ContentSeriesController(private val service: ContentSeriesService) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getSeriesList(
|
fun getSeriesList(
|
||||||
@RequestParam creatorId: Long,
|
@RequestParam(required = false) creatorId: Long?,
|
||||||
|
@RequestParam(name = "isOriginal", required = false) isOriginal: Boolean? = null,
|
||||||
|
@RequestParam(name = "isCompleted", required = false) isCompleted: Boolean? = null,
|
||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
@@ -29,6 +31,8 @@ class ContentSeriesController(private val service: ContentSeriesService) {
|
|||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getSeriesList(
|
service.getSeriesList(
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
|
isOriginal = isOriginal ?: false,
|
||||||
|
isCompleted = isCompleted ?: false,
|
||||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
contentType = contentType ?: ContentType.ALL,
|
contentType = contentType ?: ContentType.ALL,
|
||||||
member = member,
|
member = member,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
|||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.QSeriesKeyword.seriesKeyword
|
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.QSeriesKeyword.seriesKeyword
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
@@ -23,10 +24,35 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
interface ContentSeriesRepository : JpaRepository<Series, Long>, ContentSeriesQueryRepository
|
interface ContentSeriesRepository : JpaRepository<Series, Long>, ContentSeriesQueryRepository
|
||||||
|
|
||||||
interface ContentSeriesQueryRepository {
|
interface ContentSeriesQueryRepository {
|
||||||
fun getSeriesTotalCount(creatorId: Long, isAuth: Boolean, contentType: ContentType): Int
|
fun getSeriesTotalCount(
|
||||||
|
creatorId: Long?,
|
||||||
|
isAuth: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
isOriginal: Boolean,
|
||||||
|
isCompleted: Boolean
|
||||||
|
): Int
|
||||||
|
|
||||||
fun getSeriesList(
|
fun getSeriesList(
|
||||||
imageHost: String,
|
imageHost: String,
|
||||||
creatorId: Long,
|
creatorId: Long?,
|
||||||
|
isAuth: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
isOriginal: Boolean,
|
||||||
|
isCompleted: Boolean,
|
||||||
|
orderByRandom: Boolean,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<Series>
|
||||||
|
|
||||||
|
fun getSeriesByGenreTotalCount(
|
||||||
|
genreId: Long,
|
||||||
|
isAuth: Boolean,
|
||||||
|
contentType: ContentType
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun getSeriesByGenreList(
|
||||||
|
imageHost: String,
|
||||||
|
genreId: Long,
|
||||||
isAuth: Boolean,
|
isAuth: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
@@ -40,6 +66,7 @@ interface ContentSeriesQueryRepository {
|
|||||||
fun getOriginalAudioDramaList(
|
fun getOriginalAudioDramaList(
|
||||||
isAdult: Boolean,
|
isAdult: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
|
orderByRandom: Boolean = false,
|
||||||
offset: Long = 0,
|
offset: Long = 0,
|
||||||
limit: Long = 20
|
limit: Long = 20
|
||||||
): List<Series>
|
): List<Series>
|
||||||
@@ -59,9 +86,26 @@ interface ContentSeriesQueryRepository {
|
|||||||
class ContentSeriesQueryRepositoryImpl(
|
class ContentSeriesQueryRepositoryImpl(
|
||||||
private val queryFactory: JPAQueryFactory
|
private val queryFactory: JPAQueryFactory
|
||||||
) : ContentSeriesQueryRepository {
|
) : ContentSeriesQueryRepository {
|
||||||
override fun getSeriesTotalCount(creatorId: Long, isAuth: Boolean, contentType: ContentType): Int {
|
override fun getSeriesTotalCount(
|
||||||
var where = series.member.id.eq(creatorId)
|
creatorId: Long?,
|
||||||
.and(series.isActive.isTrue)
|
isAuth: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
isOriginal: Boolean,
|
||||||
|
isCompleted: Boolean
|
||||||
|
): Int {
|
||||||
|
var where = series.isActive.isTrue
|
||||||
|
|
||||||
|
if (creatorId != null) {
|
||||||
|
where = where.and(series.member.id.eq(creatorId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOriginal) {
|
||||||
|
where = where.and(series.isOriginal.isTrue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
where = where.and(series.state.eq(SeriesState.COMPLETE))
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuth) {
|
if (!isAuth) {
|
||||||
where = where.and(series.isAdult.isFalse)
|
where = where.and(series.isAdult.isFalse)
|
||||||
@@ -92,14 +136,74 @@ class ContentSeriesQueryRepositoryImpl(
|
|||||||
|
|
||||||
override fun getSeriesList(
|
override fun getSeriesList(
|
||||||
imageHost: String,
|
imageHost: String,
|
||||||
creatorId: Long,
|
creatorId: Long?,
|
||||||
isAuth: Boolean,
|
isAuth: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
|
isOriginal: Boolean,
|
||||||
|
isCompleted: Boolean,
|
||||||
|
orderByRandom: Boolean,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Long
|
limit: Long
|
||||||
): List<Series> {
|
): List<Series> {
|
||||||
var where = series.member.id.eq(creatorId)
|
var where = series.isActive.isTrue
|
||||||
.and(series.isActive.isTrue)
|
|
||||||
|
if (creatorId != null) {
|
||||||
|
where = where.and(series.member.id.eq(creatorId))
|
||||||
|
}
|
||||||
|
if (isOriginal) {
|
||||||
|
where = where.and(series.isOriginal.isTrue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
where = where.and(series.state.eq(SeriesState.COMPLETE))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuth) {
|
||||||
|
where = where.and(series.isAdult.isFalse)
|
||||||
|
} else {
|
||||||
|
if (contentType != ContentType.ALL) {
|
||||||
|
where = where.and(
|
||||||
|
series.member.isNull.or(
|
||||||
|
series.member.auth.gender.eq(
|
||||||
|
if (contentType == ContentType.MALE) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val orderBy = if (orderByRandom) {
|
||||||
|
listOf(Expressions.numberTemplate(Double::class.java, "function('rand')").asc())
|
||||||
|
} else if (creatorId != null) {
|
||||||
|
listOf(series.orders.asc(), series.createdAt.asc())
|
||||||
|
} else {
|
||||||
|
listOf(audioContent.releaseDate.max().desc(), series.createdAt.asc())
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.selectFrom(series)
|
||||||
|
.innerJoin(series.member, member)
|
||||||
|
.innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id))
|
||||||
|
.innerJoin(seriesContent.content, audioContent)
|
||||||
|
.where(where)
|
||||||
|
.groupBy(series.id)
|
||||||
|
.orderBy(*orderBy.toTypedArray())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSeriesByGenreTotalCount(
|
||||||
|
genreId: Long,
|
||||||
|
isAuth: Boolean,
|
||||||
|
contentType: ContentType
|
||||||
|
): Int {
|
||||||
|
var where = series.isActive.isTrue
|
||||||
|
.and(series.genre.id.eq(genreId))
|
||||||
|
|
||||||
if (!isAuth) {
|
if (!isAuth) {
|
||||||
where = where.and(series.isAdult.isFalse)
|
where = where.and(series.isAdult.isFalse)
|
||||||
@@ -120,10 +224,57 @@ class ContentSeriesQueryRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.selectFrom(series)
|
.select(series.id)
|
||||||
|
.from(seriesContent)
|
||||||
|
.innerJoin(seriesContent.series, series)
|
||||||
|
.innerJoin(seriesContent.content, audioContent)
|
||||||
.innerJoin(series.member, member)
|
.innerJoin(series.member, member)
|
||||||
|
.innerJoin(series.genre, seriesGenre)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(series.orders.asc(), series.createdAt.asc())
|
.groupBy(series.id)
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSeriesByGenreList(
|
||||||
|
imageHost: String,
|
||||||
|
genreId: Long,
|
||||||
|
isAuth: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
offset: Long,
|
||||||
|
limit: Long
|
||||||
|
): List<Series> {
|
||||||
|
var where = series.isActive.isTrue
|
||||||
|
.and(series.genre.id.eq(genreId))
|
||||||
|
|
||||||
|
if (!isAuth) {
|
||||||
|
where = where.and(series.isAdult.isFalse)
|
||||||
|
} else {
|
||||||
|
if (contentType != ContentType.ALL) {
|
||||||
|
where = where.and(
|
||||||
|
series.member.isNull.or(
|
||||||
|
series.member.auth.gender.eq(
|
||||||
|
if (contentType == ContentType.MALE) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(series)
|
||||||
|
.from(seriesContent)
|
||||||
|
.innerJoin(seriesContent.series, series)
|
||||||
|
.innerJoin(seriesContent.content, audioContent)
|
||||||
|
.innerJoin(series.member, member)
|
||||||
|
.innerJoin(series.genre, seriesGenre)
|
||||||
|
.where(where)
|
||||||
|
.groupBy(series.id)
|
||||||
|
.orderBy(audioContent.releaseDate.max().desc(), series.createdAt.asc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.fetch()
|
.fetch()
|
||||||
@@ -216,6 +367,7 @@ class ContentSeriesQueryRepositoryImpl(
|
|||||||
override fun getOriginalAudioDramaList(
|
override fun getOriginalAudioDramaList(
|
||||||
isAdult: Boolean,
|
isAdult: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
|
orderByRandom: Boolean,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Long
|
limit: Long
|
||||||
): List<Series> {
|
): List<Series> {
|
||||||
@@ -244,7 +396,13 @@ class ContentSeriesQueryRepositoryImpl(
|
|||||||
.selectFrom(series)
|
.selectFrom(series)
|
||||||
.innerJoin(series.member, member)
|
.innerJoin(series.member, member)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(series.id.desc())
|
.orderBy(
|
||||||
|
if (orderByRandom) {
|
||||||
|
Expressions.numberTemplate(Double::class.java, "function('rand')").asc()
|
||||||
|
} else {
|
||||||
|
series.id.desc()
|
||||||
|
}
|
||||||
|
)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.fetch()
|
.fetch()
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ class ContentSeriesService(
|
|||||||
fun getOriginalAudioDramaList(
|
fun getOriginalAudioDramaList(
|
||||||
isAdult: Boolean,
|
isAdult: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
|
orderByRandom: Boolean = false,
|
||||||
offset: Long = 0,
|
offset: Long = 0,
|
||||||
limit: Long = 20
|
limit: Long = 20
|
||||||
): List<GetSeriesListResponse.SeriesListItem> {
|
): List<GetSeriesListResponse.SeriesListItem> {
|
||||||
val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, offset, limit)
|
val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, orderByRandom, offset, limit)
|
||||||
return seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType)
|
return seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,25 +50,63 @@ class ContentSeriesService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getSeriesList(
|
fun getSeriesList(
|
||||||
creatorId: Long,
|
creatorId: Long?,
|
||||||
|
isOriginal: Boolean = false,
|
||||||
|
isCompleted: Boolean = false,
|
||||||
|
orderByRandom: Boolean = false,
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
member: Member,
|
member: Member,
|
||||||
offset: Long = 0,
|
offset: Long = 0,
|
||||||
limit: Long = 10
|
limit: Long = 20
|
||||||
): GetSeriesListResponse {
|
): GetSeriesListResponse {
|
||||||
val isAuth = member.auth != null && isAdultContentVisible
|
val isAuth = member.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
val totalCount = repository.getSeriesTotalCount(
|
val totalCount = repository.getSeriesTotalCount(
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
isAuth = isAuth,
|
isAuth = isAuth,
|
||||||
contentType = contentType
|
contentType = contentType,
|
||||||
|
isOriginal = isOriginal,
|
||||||
|
isCompleted = isCompleted
|
||||||
)
|
)
|
||||||
|
|
||||||
val rawItems = repository.getSeriesList(
|
val rawItems = repository.getSeriesList(
|
||||||
imageHost = coverImageHost,
|
imageHost = coverImageHost,
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
isAuth = isAuth,
|
isAuth = isAuth,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
|
isOriginal = isOriginal,
|
||||||
|
isCompleted = isCompleted,
|
||||||
|
orderByRandom = orderByRandom,
|
||||||
|
offset = offset,
|
||||||
|
limit = limit
|
||||||
|
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
|
||||||
|
|
||||||
|
val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType)
|
||||||
|
return GetSeriesListResponse(totalCount, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSeriesListByGenre(
|
||||||
|
genreId: Long,
|
||||||
|
isAdultContentVisible: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
member: Member,
|
||||||
|
offset: Long = 0,
|
||||||
|
limit: Long = 20
|
||||||
|
): GetSeriesListResponse {
|
||||||
|
val isAuth = member.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
|
val totalCount = repository.getSeriesByGenreTotalCount(
|
||||||
|
genreId = genreId,
|
||||||
|
isAuth = isAuth,
|
||||||
|
contentType = contentType
|
||||||
|
)
|
||||||
|
|
||||||
|
val rawItems = repository.getSeriesByGenreList(
|
||||||
|
imageHost = coverImageHost,
|
||||||
|
genreId = genreId,
|
||||||
|
isAuth = isAuth,
|
||||||
|
contentType = contentType,
|
||||||
offset = offset,
|
offset = offset,
|
||||||
limit = limit
|
limit = limit
|
||||||
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
|
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
|
||||||
@@ -201,7 +240,7 @@ class ContentSeriesService(
|
|||||||
val seriesList = repository.getRecommendSeriesList(
|
val seriesList = repository.getRecommendSeriesList(
|
||||||
isAuth = isAuth,
|
isAuth = isAuth,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
limit = 10
|
limit = 20
|
||||||
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
|
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
|
||||||
|
|
||||||
return seriesToSeriesListItem(seriesList = seriesList, isAdult = isAuth, contentType = contentType)
|
return seriesToSeriesListItem(seriesList = seriesList, isAdult = isAuth, contentType = contentType)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.series.main
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
|
||||||
|
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
||||||
|
|
||||||
|
data class SeriesHomeResponse(
|
||||||
|
val banners: List<SeriesBannerResponse>,
|
||||||
|
val completedSeriesList: List<GetSeriesListResponse.SeriesListItem>,
|
||||||
|
val recommendSeriesList: List<GetSeriesListResponse.SeriesListItem>
|
||||||
|
)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.series.main
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||||
|
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.data.domain.PageRequest
|
||||||
|
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("/audio-content/series/main")
|
||||||
|
class SeriesMainController(
|
||||||
|
private val contentSeriesService: ContentSeriesService,
|
||||||
|
private val bannerService: ContentSeriesBannerService,
|
||||||
|
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun fetchData(
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
|
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
|
||||||
|
.content
|
||||||
|
.map {
|
||||||
|
SeriesBannerResponse.from(it, imageHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
val completedSeriesList = contentSeriesService.getSeriesList(
|
||||||
|
creatorId = null,
|
||||||
|
isCompleted = true,
|
||||||
|
orderByRandom = true,
|
||||||
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
member = member
|
||||||
|
).items
|
||||||
|
|
||||||
|
val recommendSeriesList = contentSeriesService.getRecommendSeriesList(
|
||||||
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
member = member
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
SeriesHomeResponse(
|
||||||
|
banners = banners,
|
||||||
|
completedSeriesList = completedSeriesList,
|
||||||
|
recommendSeriesList = recommendSeriesList
|
||||||
|
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recommend")
|
||||||
|
fun getRecommendSeriesList(
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
contentSeriesService.getRecommendSeriesList(
|
||||||
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
member = member
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/day-of-week")
|
||||||
|
fun getDayOfWeekSeriesList(
|
||||||
|
@RequestParam("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
val pageable = PageRequest.of(page, size)
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
contentSeriesService.getDayOfWeekSeriesList(
|
||||||
|
memberId = member.id,
|
||||||
|
isAdult = member.auth != null && (isAdultContentVisible ?: true),
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
dayOfWeek = dayOfWeek,
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/genre-list")
|
||||||
|
fun getGenreList(
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
|
val memberId = member.id!!
|
||||||
|
val isAdult = member.auth != null && (isAdultContentVisible ?: true)
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
contentSeriesService.getGenreList(
|
||||||
|
memberId = memberId,
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType ?: ContentType.ALL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/list-by-genre")
|
||||||
|
fun getSeriesListByGenre(
|
||||||
|
@RequestParam("genreId") genreId: Long,
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@RequestParam(defaultValue = "20") size: Int,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
val pageable = PageRequest.of(page, size)
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
contentSeriesService.getSeriesListByGenre(
|
||||||
|
genreId = genreId,
|
||||||
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
|
contentType = contentType ?: ContentType.ALL,
|
||||||
|
member = member,
|
||||||
|
offset = pageable.offset,
|
||||||
|
limit = pageable.pageSize.toLong()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.series.main.banner
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class ContentSeriesBannerService(
|
||||||
|
private val bannerRepository: SeriesBannerRepository,
|
||||||
|
private val seriesRepository: AdminContentSeriesRepository
|
||||||
|
) {
|
||||||
|
fun getActiveBanners(pageable: Pageable): Page<SeriesBanner> {
|
||||||
|
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBannerById(bannerId: Long): SeriesBanner {
|
||||||
|
return bannerRepository.findById(bannerId)
|
||||||
|
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun registerBanner(seriesId: Long, imagePath: String): SeriesBanner {
|
||||||
|
val series = seriesRepository.findByIdAndActiveTrue(seriesId)
|
||||||
|
?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId")
|
||||||
|
|
||||||
|
val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1
|
||||||
|
|
||||||
|
val banner = SeriesBanner(
|
||||||
|
imagePath = imagePath,
|
||||||
|
series = series,
|
||||||
|
sortOrder = finalSortOrder
|
||||||
|
)
|
||||||
|
return bannerRepository.save(banner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateBanner(
|
||||||
|
bannerId: Long,
|
||||||
|
imagePath: String? = null,
|
||||||
|
seriesId: Long? = null
|
||||||
|
): SeriesBanner {
|
||||||
|
val banner = bannerRepository.findById(bannerId)
|
||||||
|
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") }
|
||||||
|
if (!banner.isActive) throw SodaException("비활성화된 배너는 수정할 수 없습니다: $bannerId")
|
||||||
|
|
||||||
|
if (imagePath != null) banner.imagePath = imagePath
|
||||||
|
|
||||||
|
if (seriesId != null) {
|
||||||
|
val series = seriesRepository.findByIdAndActiveTrue(seriesId)
|
||||||
|
?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId")
|
||||||
|
banner.series = series
|
||||||
|
}
|
||||||
|
|
||||||
|
return bannerRepository.save(banner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun deleteBanner(bannerId: Long) {
|
||||||
|
val banner = bannerRepository.findById(bannerId)
|
||||||
|
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") }
|
||||||
|
banner.isActive = false
|
||||||
|
bannerRepository.save(banner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun updateBannerOrders(ids: List<Long>): List<SeriesBanner> {
|
||||||
|
val updated = mutableListOf<SeriesBanner>()
|
||||||
|
for (index in ids.indices) {
|
||||||
|
val banner = bannerRepository.findById(ids[index])
|
||||||
|
.orElseThrow { SodaException("배너를 찾을 수 없습니다: ${ids[index]}") }
|
||||||
|
if (!banner.isActive) throw SodaException("비활성화된 배너는 수정할 수 없습니다: ${ids[index]}")
|
||||||
|
banner.sortOrder = index + 1
|
||||||
|
updated.add(bannerRepository.save(banner))
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.series.main.banner
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.JoinColumn
|
||||||
|
import javax.persistence.ManyToOne
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시리즈 배너 엔티티
|
||||||
|
* 이미지와 시리즈 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다.
|
||||||
|
* 정렬 순서(sortOrder)를 통해 배너의 표시 순서를 결정합니다.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
class SeriesBanner(
|
||||||
|
// 배너 이미지 경로
|
||||||
|
var imagePath: String? = null,
|
||||||
|
|
||||||
|
// 연관된 캐릭터
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "series_id")
|
||||||
|
var series: Series,
|
||||||
|
|
||||||
|
// 정렬 순서 (낮을수록 먼저 표시)
|
||||||
|
var sortOrder: Int = 0,
|
||||||
|
|
||||||
|
// 활성화 여부 (소프트 삭제용)
|
||||||
|
var isActive: Boolean = true
|
||||||
|
) : BaseEntity()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.series.main.banner
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page
|
||||||
|
import org.springframework.data.domain.Pageable
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface SeriesBannerRepository : JpaRepository<SeriesBanner, Long> {
|
||||||
|
fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<SeriesBanner>
|
||||||
|
|
||||||
|
@Query("SELECT MAX(b.sortOrder) FROM SeriesBanner b WHERE b.isActive = true")
|
||||||
|
fun findMaxSortOrder(): Int?
|
||||||
|
}
|
||||||
@@ -27,6 +27,26 @@ class AudioContentThemeController(private val service: AudioContentThemeService)
|
|||||||
ApiResponse.ok(service.getThemes())
|
ApiResponse.ok(service.getThemes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/active")
|
||||||
|
fun getActiveThemes(
|
||||||
|
@RequestParam("isFree", required = false) isFree: Boolean? = null,
|
||||||
|
@RequestParam("isPointAvailableOnly", required = false) isPointAvailableOnly: Boolean? = null,
|
||||||
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
service.getActiveThemeOfContent(
|
||||||
|
isAdult = member.auth != null && (isAdultContentVisible ?: true),
|
||||||
|
isFree = isFree ?: false,
|
||||||
|
isPointAvailableOnly = isPointAvailableOnly ?: false,
|
||||||
|
contentType = contentType ?: ContentType.ALL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/content")
|
@GetMapping("/{id}/content")
|
||||||
fun getContentByTheme(
|
fun getContentByTheme(
|
||||||
@PathVariable id: Long,
|
@PathVariable id: Long,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.content.theme
|
package kr.co.vividnext.sodalive.content.theme
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.CaseBuilder
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
@@ -32,6 +33,7 @@ class AudioContentThemeQueryRepository(
|
|||||||
fun getActiveThemeOfContent(
|
fun getActiveThemeOfContent(
|
||||||
isAdult: Boolean = false,
|
isAdult: Boolean = false,
|
||||||
isFree: Boolean = false,
|
isFree: Boolean = false,
|
||||||
|
isPointAvailableOnly: Boolean = false,
|
||||||
contentType: ContentType
|
contentType: ContentType
|
||||||
): List<String> {
|
): List<String> {
|
||||||
var where = audioContent.isActive.isTrue
|
var where = audioContent.isActive.isTrue
|
||||||
@@ -59,15 +61,31 @@ class AudioContentThemeQueryRepository(
|
|||||||
where = where.and(audioContent.price.loe(0))
|
where = where.and(audioContent.price.loe(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
return queryFactory
|
if (isPointAvailableOnly) {
|
||||||
|
where = where.and(audioContent.isPointAvailable.isTrue)
|
||||||
|
}
|
||||||
|
|
||||||
|
val query = queryFactory
|
||||||
.select(audioContentTheme.theme)
|
.select(audioContentTheme.theme)
|
||||||
.from(audioContent)
|
.from(audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.innerJoin(audioContent.theme, audioContentTheme)
|
.innerJoin(audioContent.theme, audioContentTheme)
|
||||||
.where(where)
|
.where(where)
|
||||||
.groupBy(audioContentTheme.id)
|
.groupBy(audioContentTheme.id)
|
||||||
.orderBy(audioContentTheme.orders.asc())
|
|
||||||
.fetch()
|
if (isFree) {
|
||||||
|
query.orderBy(
|
||||||
|
CaseBuilder()
|
||||||
|
.`when`(audioContentTheme.theme.eq("자기소개")).then(0)
|
||||||
|
.otherwise(1)
|
||||||
|
.asc(),
|
||||||
|
audioContentTheme.orders.asc()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
query.orderBy(audioContentTheme.orders.asc())
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findThemeByIdAndActive(id: Long): AudioContentTheme? {
|
fun findThemeByIdAndActive(id: Long): AudioContentTheme? {
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ class AudioContentThemeService(
|
|||||||
fun getActiveThemeOfContent(
|
fun getActiveThemeOfContent(
|
||||||
isAdult: Boolean = false,
|
isAdult: Boolean = false,
|
||||||
isFree: Boolean = false,
|
isFree: Boolean = false,
|
||||||
|
isPointAvailableOnly: Boolean = false,
|
||||||
contentType: ContentType
|
contentType: ContentType
|
||||||
): List<String> {
|
): List<String> {
|
||||||
return queryRepository.getActiveThemeOfContent(
|
return queryRepository.getActiveThemeOfContent(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
isFree = isFree,
|
isFree = isFree,
|
||||||
|
isPointAvailableOnly = isPointAvailableOnly,
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate
|
|||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
||||||
import kr.co.vividnext.sodalive.explorer.QCreatorRanking.creatorRanking
|
import kr.co.vividnext.sodalive.explorer.QCreatorRanking.creatorRanking
|
||||||
import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListDto
|
import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListDto
|
||||||
import kr.co.vividnext.sodalive.explorer.follower.QGetFollowerListDto
|
import kr.co.vividnext.sodalive.explorer.follower.QGetFollowerListDto
|
||||||
@@ -39,6 +40,7 @@ import java.time.LocalDate
|
|||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class ExplorerQueryRepository(
|
class ExplorerQueryRepository(
|
||||||
@@ -353,7 +355,6 @@ class ExplorerQueryRepository(
|
|||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
userMember: Member,
|
userMember: Member,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
limit: Int,
|
|
||||||
offset: Long = 0
|
offset: Long = 0
|
||||||
): List<LiveRoomResponse> {
|
): List<LiveRoomResponse> {
|
||||||
var where = liveRoom.member.id.eq(creatorId)
|
var where = liveRoom.member.id.eq(creatorId)
|
||||||
@@ -392,6 +393,14 @@ class ExplorerQueryRepository(
|
|||||||
val beginDateTime = it.beginDateTime
|
val beginDateTime = it.beginDateTime
|
||||||
.atZone(ZoneId.of("UTC"))
|
.atZone(ZoneId.of("UTC"))
|
||||||
.withZoneSameInstant(ZoneId.of(timezone))
|
.withZoneSameInstant(ZoneId.of(timezone))
|
||||||
|
.format(
|
||||||
|
DateTimeFormatter
|
||||||
|
.ofPattern("yyyy년 MM월 dd일 (E) a hh시 mm분")
|
||||||
|
.withLocale(Locale.KOREAN)
|
||||||
|
)
|
||||||
|
|
||||||
|
val beginDateTimeUtc = it.beginDateTime
|
||||||
|
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
|
|
||||||
val isPaid = if (it.channelName != null) {
|
val isPaid = if (it.channelName != null) {
|
||||||
val useCan = queryFactory
|
val useCan = queryFactory
|
||||||
@@ -415,9 +424,8 @@ class ExplorerQueryRepository(
|
|||||||
title = it.title,
|
title = it.title,
|
||||||
content = it.notice,
|
content = it.notice,
|
||||||
isPaid = isPaid,
|
isPaid = isPaid,
|
||||||
beginDateTime = beginDateTime.format(
|
beginDateTime = beginDateTime,
|
||||||
DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")
|
beginDateTimeUtc = beginDateTimeUtc,
|
||||||
),
|
|
||||||
isAdult = it.isAdult,
|
isAdult = it.isAdult,
|
||||||
price = it.price,
|
price = it.price,
|
||||||
channelName = it.channelName,
|
channelName = it.channelName,
|
||||||
@@ -653,6 +661,28 @@ class ExplorerQueryRepository(
|
|||||||
.fetchFirst()
|
.fetchFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getOwnedContentCount(creatorId: Long, memberId: Long): Long {
|
||||||
|
// 활성 주문 + 대여의 경우 유효기간 내 주문만 포함, 동일 콘텐츠 중복 구매는 1개로 카운트
|
||||||
|
return queryFactory
|
||||||
|
.select(audioContent.id)
|
||||||
|
.from(order)
|
||||||
|
.innerJoin(order.audioContent, audioContent)
|
||||||
|
.where(
|
||||||
|
order.isActive.isTrue,
|
||||||
|
order.member.id.eq(memberId),
|
||||||
|
audioContent.member.id.eq(creatorId),
|
||||||
|
order.type.eq(kr.co.vividnext.sodalive.content.order.OrderType.KEEP)
|
||||||
|
.or(
|
||||||
|
order.type.eq(kr.co.vividnext.sodalive.content.order.OrderType.RENTAL)
|
||||||
|
.and(order.endDate.after(LocalDateTime.now()))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.fetch()
|
||||||
|
.size
|
||||||
|
.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
fun getVisibleDonationRank(creatorId: Long): Boolean {
|
fun getVisibleDonationRank(creatorId: Long): Boolean {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(member.isVisibleDonationRank)
|
.select(member.isVisibleDonationRank)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class ExplorerService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getExplorer(member: Member, growthRankingCreatorsLimit: Long = 20): GetExplorerResponse {
|
fun getExplorer(member: Member): GetExplorerResponse {
|
||||||
val sections = mutableListOf<GetExplorerSectionResponse>()
|
val sections = mutableListOf<GetExplorerSectionResponse>()
|
||||||
|
|
||||||
// 인기 크리에이터
|
// 인기 크리에이터
|
||||||
@@ -209,8 +209,7 @@ class ExplorerService(
|
|||||||
queryRepository.getLiveRoomList(
|
queryRepository.getLiveRoomList(
|
||||||
creatorId,
|
creatorId,
|
||||||
userMember = member,
|
userMember = member,
|
||||||
timezone = timezone,
|
timezone = timezone
|
||||||
limit = 3
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
listOf()
|
listOf()
|
||||||
@@ -231,6 +230,27 @@ class ExplorerService(
|
|||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 크리에이터의 최신 오디오 콘텐츠 1개
|
||||||
|
val latestContent = if (isCreator) {
|
||||||
|
audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 크리에이터의 전체 콘텐츠 개수
|
||||||
|
val totalContentCount = if (isCreator) {
|
||||||
|
queryRepository.getContentCount(creatorId) ?: 0
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조회하는 유저가 소장 중인 크리에이터의 콘텐츠 개수
|
||||||
|
val ownedContentCount = if (isCreator) {
|
||||||
|
queryRepository.getOwnedContentCount(creatorId, member.id!!)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
// 공지사항
|
// 공지사항
|
||||||
val notice = if (isCreator) {
|
val notice = if (isCreator) {
|
||||||
queryRepository.getNoticeString(creatorId)
|
queryRepository.getNoticeString(creatorId)
|
||||||
@@ -311,6 +331,9 @@ class ExplorerService(
|
|||||||
similarCreatorList = similarCreatorList,
|
similarCreatorList = similarCreatorList,
|
||||||
liveRoomList = liveRoomList,
|
liveRoomList = liveRoomList,
|
||||||
contentList = contentList,
|
contentList = contentList,
|
||||||
|
latestContent = latestContent,
|
||||||
|
totalContentCount = totalContentCount,
|
||||||
|
ownedContentCount = ownedContentCount,
|
||||||
notice = notice,
|
notice = notice,
|
||||||
communityPostList = communityPostList,
|
communityPostList = communityPostList,
|
||||||
cheers = cheers,
|
cheers = cheers,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ data class GetCreatorProfileResponse(
|
|||||||
val similarCreatorList: List<SimilarCreatorResponse>,
|
val similarCreatorList: List<SimilarCreatorResponse>,
|
||||||
val liveRoomList: List<LiveRoomResponse>,
|
val liveRoomList: List<LiveRoomResponse>,
|
||||||
val contentList: List<GetAudioContentListItem>,
|
val contentList: List<GetAudioContentListItem>,
|
||||||
|
val latestContent: GetAudioContentListItem?,
|
||||||
|
val totalContentCount: Long,
|
||||||
|
val ownedContentCount: Long,
|
||||||
val notice: String,
|
val notice: String,
|
||||||
val communityPostList: List<GetCommunityPostListResponse>,
|
val communityPostList: List<GetCommunityPostListResponse>,
|
||||||
val cheers: GetCheersResponse,
|
val cheers: GetCheersResponse,
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.explorer
|
package kr.co.vividnext.sodalive.explorer
|
||||||
|
|
||||||
data class GetLiveRoomAllResponse(
|
|
||||||
val totalCount: Int,
|
|
||||||
val liveRoomList: List<LiveRoomResponse>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LiveRoomResponse(
|
data class LiveRoomResponse(
|
||||||
val roomId: Long,
|
val roomId: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val isPaid: Boolean,
|
val isPaid: Boolean,
|
||||||
val beginDateTime: String,
|
val beginDateTime: String,
|
||||||
|
val beginDateTimeUtc: String,
|
||||||
val coverImageUrl: String,
|
val coverImageUrl: String,
|
||||||
val isAdult: Boolean,
|
val isAdult: Boolean,
|
||||||
val price: Int,
|
val price: Int,
|
||||||
|
|||||||
@@ -1083,7 +1083,7 @@ class LiveRoomService(
|
|||||||
val host = room.member ?: throw SodaException("잘못된 요청입니다.")
|
val host = room.member ?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (host.role != MemberRole.CREATOR) {
|
if (host.role != MemberRole.CREATOR) {
|
||||||
throw SodaException("비비드넥스트와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.")
|
throw SodaException("주식회사 소다라이브와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
canPaymentService.spendCan(
|
canPaymentService.spendCan(
|
||||||
@@ -1129,7 +1129,7 @@ class LiveRoomService(
|
|||||||
val host = room.member ?: throw SodaException("잘못된 요청입니다.")
|
val host = room.member ?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (host.role != MemberRole.CREATOR) {
|
if (host.role != MemberRole.CREATOR) {
|
||||||
throw SodaException("비비드넥스트와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.")
|
throw SodaException("주식회사 소다라이브와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
canPaymentService.spendCan(
|
canPaymentService.spendCan(
|
||||||
@@ -1272,12 +1272,12 @@ class LiveRoomService(
|
|||||||
val host = room.member ?: throw SodaException("잘못된 요청입니다.")
|
val host = room.member ?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (host.role != MemberRole.CREATOR) {
|
if (host.role != MemberRole.CREATOR) {
|
||||||
throw SodaException("비비드넥스트와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.")
|
throw SodaException("주식회사 소다라이브와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
canPaymentService.spendCan(
|
canPaymentService.spendCan(
|
||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
needCan = 1,
|
needCan = request.heartCount ?: 1,
|
||||||
canUsage = CanUsage.HEART,
|
canUsage = CanUsage.HEART,
|
||||||
isSecret = false,
|
isSecret = false,
|
||||||
liveRoom = room,
|
liveRoom = room,
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ package kr.co.vividnext.sodalive.live.room.like
|
|||||||
|
|
||||||
data class LiveRoomLikeHeartRequest(
|
data class LiveRoomLikeHeartRequest(
|
||||||
val roomId: Long,
|
val roomId: Long,
|
||||||
val container: String
|
val container: String,
|
||||||
|
val heartCount: Int? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class NewRouletteService(
|
|||||||
val host = room.member ?: throw SodaException("잘못된 요청입니다.")
|
val host = room.member ?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (host.role != MemberRole.CREATOR) {
|
if (host.role != MemberRole.CREATOR) {
|
||||||
throw SodaException("비비드넥스트와 계약한\n크리에이터의 룰렛만 사용하실 수 있습니다.")
|
throw SodaException("주식회사 소다라이브와 계약한\n크리에이터의 룰렛만 사용하실 수 있습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 2 - 룰렛 데이터 가져오기
|
// STEP 2 - 룰렛 데이터 가져오기
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class RouletteService(
|
|||||||
val host = room.member ?: throw SodaException("잘못된 요청입니다.")
|
val host = room.member ?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (host.role != MemberRole.CREATOR) {
|
if (host.role != MemberRole.CREATOR) {
|
||||||
throw SodaException("비비드넥스트와 계약한\n크리에이터의 룰렛만 사용하실 수 있습니다.")
|
throw SodaException("주식회사 소다라이브와 계약한\n크리에이터의 룰렛만 사용하실 수 있습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 2 - 룰렛 데이터 가져오기
|
// STEP 2 - 룰렛 데이터 가져오기
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.marketing
|
package kr.co.vividnext.sodalive.marketing
|
||||||
|
|
||||||
|
import java.math.BigDecimal
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
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
|
||||||
@@ -19,7 +21,8 @@ data class AdTrackingHistory(
|
|||||||
val pidName: String,
|
val pidName: String,
|
||||||
@Enumerated(value = EnumType.STRING)
|
@Enumerated(value = EnumType.STRING)
|
||||||
val type: AdTrackingHistoryType,
|
val type: AdTrackingHistoryType,
|
||||||
val price: Double = 0.toDouble(),
|
@Column(precision = 10, scale = 4, nullable = false)
|
||||||
|
val price: BigDecimal = 0.toBigDecimal(),
|
||||||
val locale: String? = null,
|
val locale: String? = null,
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val createdAt: LocalDateTime = LocalDateTime.now(),
|
val createdAt: LocalDateTime = LocalDateTime.now(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AdTrackingService(
|
class AdTrackingService(
|
||||||
@@ -17,7 +18,7 @@ class AdTrackingService(
|
|||||||
pid: String,
|
pid: String,
|
||||||
type: AdTrackingHistoryType,
|
type: AdTrackingHistoryType,
|
||||||
memberId: Long,
|
memberId: Long,
|
||||||
price: Double? = null,
|
price: BigDecimal? = null,
|
||||||
locale: String? = null
|
locale: String? = null
|
||||||
) {
|
) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
@@ -30,7 +31,7 @@ class AdTrackingService(
|
|||||||
pid = pid,
|
pid = pid,
|
||||||
pidName = mediaPartner.pidName,
|
pidName = mediaPartner.pidName,
|
||||||
type = type,
|
type = type,
|
||||||
price = price ?: 0.toDouble(),
|
price = price ?: 0.toBigDecimal(),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
memberId = memberId
|
memberId = memberId
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package kr.co.vividnext.sodalive.rank
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 랭킹 정렬 기준
|
||||||
|
*/
|
||||||
|
enum class ContentRankingSortType {
|
||||||
|
// 매출: order.can.sum.desc
|
||||||
|
REVENUE,
|
||||||
|
|
||||||
|
// 판매량: order.id.count.desc
|
||||||
|
SALES_COUNT,
|
||||||
|
|
||||||
|
// 댓글 수: audioContentComment.id.count.desc
|
||||||
|
COMMENT_COUNT,
|
||||||
|
|
||||||
|
// 좋아요 수: audioContentLike.id.count.desc
|
||||||
|
LIKE_COUNT,
|
||||||
|
|
||||||
|
// 후원: audioContentComment.donationCan.sum.desc
|
||||||
|
DONATION
|
||||||
|
}
|
||||||
@@ -132,6 +132,14 @@ class RankingRepository(
|
|||||||
.innerJoin(audioContent.theme, audioContentTheme)
|
.innerJoin(audioContent.theme, audioContentTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"판매량" -> {
|
||||||
|
select
|
||||||
|
.from(order)
|
||||||
|
.innerJoin(order.audioContent, audioContent)
|
||||||
|
.innerJoin(audioContent.member, member)
|
||||||
|
.innerJoin(audioContent.theme, audioContentTheme)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
select
|
select
|
||||||
.from(order)
|
.from(order)
|
||||||
@@ -184,6 +192,18 @@ class RankingRepository(
|
|||||||
.orderBy(audioContentLike.id.count().desc(), audioContent.createdAt.asc())
|
.orderBy(audioContentLike.id.count().desc(), audioContent.createdAt.asc())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"판매량" -> {
|
||||||
|
select
|
||||||
|
.where(
|
||||||
|
where
|
||||||
|
.and(order.isActive.isTrue)
|
||||||
|
.and(order.createdAt.goe(startDate))
|
||||||
|
.and(order.createdAt.lt(endDate))
|
||||||
|
)
|
||||||
|
.groupBy(audioContent.id)
|
||||||
|
.orderBy(order.id.count().desc(), audioContent.createdAt.asc())
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
select
|
select
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -76,6 +76,38 @@ class RankingService(
|
|||||||
return contentList
|
return contentList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun toSortString(sort: ContentRankingSortType): String = when (sort) {
|
||||||
|
ContentRankingSortType.REVENUE -> "매출"
|
||||||
|
ContentRankingSortType.SALES_COUNT -> "판매량"
|
||||||
|
ContentRankingSortType.COMMENT_COUNT -> "댓글"
|
||||||
|
ContentRankingSortType.LIKE_COUNT -> "좋아요"
|
||||||
|
ContentRankingSortType.DONATION -> "후원"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getContentRanking(
|
||||||
|
memberId: Long?,
|
||||||
|
isAdult: Boolean,
|
||||||
|
contentType: ContentType,
|
||||||
|
startDate: LocalDateTime,
|
||||||
|
endDate: LocalDateTime,
|
||||||
|
offset: Long = 0,
|
||||||
|
limit: Long = 12,
|
||||||
|
sort: ContentRankingSortType,
|
||||||
|
theme: String = ""
|
||||||
|
): List<GetAudioContentRankingItem> {
|
||||||
|
return getContentRanking(
|
||||||
|
memberId = memberId,
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType,
|
||||||
|
startDate = startDate,
|
||||||
|
endDate = endDate,
|
||||||
|
offset = offset,
|
||||||
|
limit = limit,
|
||||||
|
sortType = toSortString(sort),
|
||||||
|
theme = theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun getSeriesRanking(
|
fun getSeriesRanking(
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
isAdult: Boolean,
|
isAdult: Boolean,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
server:
|
server:
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
env: ${SERVER_ENV}
|
env: ${SERVER_ENV}
|
||||||
|
forward-headers-strategy: framework
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
@@ -14,11 +15,14 @@ weraser:
|
|||||||
apiKey: ${WERASER_API_KEY}
|
apiKey: ${WERASER_API_KEY}
|
||||||
|
|
||||||
payverse:
|
payverse:
|
||||||
|
host: ${PAYVERSE_HOST}
|
||||||
|
inboundIp: ${PAYVERSE_INBOUND_IP}
|
||||||
mid: ${PAYVERSE_MID}
|
mid: ${PAYVERSE_MID}
|
||||||
clientKey: ${PAYVERSE_CLIENT_KEY}
|
clientKey: ${PAYVERSE_CLIENT_KEY}
|
||||||
secretKey: ${PAYVERSE_SECRET_KEY}
|
secretKey: ${PAYVERSE_SECRET_KEY}
|
||||||
host: ${PAYVERSE_HOST}
|
usdMid: ${PAYVERSE_USD_MID}
|
||||||
inboundIp: ${PAYVERSE_INBOUND_IP}
|
usdClientKey: ${PAYVERSE_USD_CLIENT_KEY}
|
||||||
|
usdSecretKey: ${PAYVERSE_USD_SECRET_KEY}
|
||||||
|
|
||||||
bootpay:
|
bootpay:
|
||||||
applicationId: ${BOOTPAY_APPLICATION_ID}
|
applicationId: ${BOOTPAY_APPLICATION_ID}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.content.series
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
|
||||||
|
class AdminContentSeriesServiceTest {
|
||||||
|
private lateinit var seriesRepository: AdminContentSeriesRepository
|
||||||
|
private lateinit var genreRepository: AdminContentSeriesGenreRepository
|
||||||
|
private lateinit var service: AdminContentSeriesService
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
seriesRepository = Mockito.mock(AdminContentSeriesRepository::class.java)
|
||||||
|
genreRepository = Mockito.mock(AdminContentSeriesGenreRepository::class.java)
|
||||||
|
service = AdminContentSeriesService(seriesRepository, genreRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldModifySeriesFieldsByAdmin() {
|
||||||
|
// given
|
||||||
|
val series = Series(
|
||||||
|
title = "title",
|
||||||
|
introduction = "intro",
|
||||||
|
state = SeriesState.PROCEEDING
|
||||||
|
)
|
||||||
|
series.id = 1L
|
||||||
|
series.genre = SeriesGenre(genre = "Old", isAdult = false)
|
||||||
|
series.publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON)
|
||||||
|
series.isAdult = false
|
||||||
|
series.isOriginal = false
|
||||||
|
|
||||||
|
Mockito.`when`(seriesRepository.findByIdAndActiveTrue(1L)).thenReturn(series)
|
||||||
|
|
||||||
|
val newGenre = SeriesGenre(genre = "New", isAdult = false)
|
||||||
|
newGenre.id = 10L
|
||||||
|
Mockito.`when`(genreRepository.findActiveSeriesGenreById(10L)).thenReturn(newGenre)
|
||||||
|
|
||||||
|
val request = AdminModifySeriesRequest(
|
||||||
|
seriesId = 1L,
|
||||||
|
publishedDaysOfWeek = setOf(SeriesPublishedDaysOfWeek.WED),
|
||||||
|
genreId = 10L,
|
||||||
|
isOriginal = true,
|
||||||
|
isAdult = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
service.modifySeries(request)
|
||||||
|
|
||||||
|
// then
|
||||||
|
assertEquals(setOf(SeriesPublishedDaysOfWeek.WED), series.publishedDaysOfWeek)
|
||||||
|
assertEquals(newGenre, series.genre)
|
||||||
|
assertEquals(true, series.isOriginal)
|
||||||
|
assertEquals(true, series.isAdult)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldThrowWhenRandomAndOtherDaysSelectedTogether() {
|
||||||
|
// given
|
||||||
|
val series = Series(
|
||||||
|
title = "title",
|
||||||
|
introduction = "intro",
|
||||||
|
state = SeriesState.PROCEEDING
|
||||||
|
)
|
||||||
|
series.id = 2L
|
||||||
|
series.genre = SeriesGenre(genre = "Old", isAdult = false)
|
||||||
|
|
||||||
|
Mockito.`when`(seriesRepository.findByIdAndActiveTrue(2L)).thenReturn(series)
|
||||||
|
|
||||||
|
val request = AdminModifySeriesRequest(
|
||||||
|
seriesId = 2L,
|
||||||
|
publishedDaysOfWeek = setOf(SeriesPublishedDaysOfWeek.RANDOM, SeriesPublishedDaysOfWeek.MON),
|
||||||
|
genreId = null,
|
||||||
|
isOriginal = null,
|
||||||
|
isAdult = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// expect
|
||||||
|
assertThrows(SodaException::class.java) {
|
||||||
|
service.modifySeries(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldThrowWhenGenreNotFound() {
|
||||||
|
// given
|
||||||
|
val series = Series(
|
||||||
|
title = "title",
|
||||||
|
introduction = "intro",
|
||||||
|
state = SeriesState.PROCEEDING
|
||||||
|
)
|
||||||
|
series.id = 3L
|
||||||
|
series.genre = SeriesGenre(genre = "Old", isAdult = false)
|
||||||
|
Mockito.`when`(seriesRepository.findByIdAndActiveTrue(3L)).thenReturn(series)
|
||||||
|
|
||||||
|
// genre not found
|
||||||
|
Mockito.`when`(genreRepository.findActiveSeriesGenreById(999L)).thenReturn(null)
|
||||||
|
|
||||||
|
val request = AdminModifySeriesRequest(
|
||||||
|
seriesId = 3L,
|
||||||
|
publishedDaysOfWeek = null,
|
||||||
|
genreId = 999L,
|
||||||
|
isOriginal = null,
|
||||||
|
isAdult = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// expect
|
||||||
|
assertThrows(SodaException::class.java) {
|
||||||
|
service.modifySeries(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user