Compare commits
132 Commits
3c32614d1c
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
| 13029ab8d2 | |||
| 6f0619e482 | |||
| 920a866ae0 | |||
| de60a70733 | |||
| 59949e5aee | |||
| 165640201f | |||
| ba1844a6c2 | |||
| 082f255773 | |||
| 04281817a5 | |||
| 236394e148 | |||
| 7ab25470b6 | |||
| 8fec60db11 | |||
| 5d925e98e0 | |||
| 2355aa7c75 | |||
| 5bdb6d20a5 | |||
| 143ba2fbb2 | |||
| 28fbdd7826 | |||
| 25169aaac3 | |||
| 608898eb0c | |||
| 1748b26318 | |||
| 3ff38bb73a | |||
| 4498af4509 | |||
| 8636a8cac0 | |||
| 304c001a27 | |||
| fdac55ebdf | |||
| 668d4f28cd | |||
| 7b0644cb66 | |||
| 503802bcce | |||
| 899f2865b3 | |||
| e0dcbd16fc | |||
| 62ec994069 | |||
| 8ec6d50dd8 | |||
| ddd46d585e | |||
| c5fa260a0d | |||
| 412c52e754 | |||
| 8f4544ad71 | |||
| 619ceeea24 | |||
| a2998002e5 | |||
| da9b89a6cf | |||
| 5ee5107364 | |||
| ae2c699748 | |||
| 93ccb666c4 | |||
| edaea84a5b | |||
| 76806e2e90 | |||
| 39c51825da | |||
| 9a58b7b95f | |||
| 26eae4b06e | |||
| 60989391f6 | |||
| 88d90eec2f | |||
| b6eb13df06 | |||
| a6b815ad05 | |||
| d89122802a | |||
| 690432d6ee | |||
| bc358d18de | |||
| add88aca35 | |||
| b6971f6a8d | |||
| f83dd47c7c | |||
| 146f733f5d | |||
| 806fcfe7db | |||
| 04e7c90407 | |||
| f278497526 | |||
| 597bd8f8ae | |||
| e4c1cf5a9a | |||
| 9f6bdf6ed8 | |||
| 4f89b0189e | |||
| 27be9a4fc2 | |||
| 9464cc5ed4 | |||
| 39760e16ff | |||
| bf149c45ad | |||
| 4f52ec0663 | |||
| 3ed306ae8c | |||
| ee35244296 | |||
| fe76ecdfa9 | |||
| 16b6c13309 | |||
| 80c44373c7 | |||
| a538bb766d | |||
| 26c09de7c9 | |||
| 82bd93c1ae | |||
| e24e8372a8 | |||
| eab7dc4521 | |||
| 5ca666c7fa | |||
| 8fb3bd578f | |||
| 01fad8d93c | |||
| a05ada5df0 | |||
| 34480385d3 | |||
| fd68ed87a3 | |||
| 779fc5c5a5 | |||
| 08ebb311fb | |||
| 12cdd25be7 | |||
| 59700493eb | |||
| 88c3a84972 | |||
| db0d3a6ef3 | |||
| 3d29d27441 | |||
| b5f66603bd | |||
| 976eeaa443 | |||
| 25d1d813f1 | |||
| 778f0c3ba2 | |||
| 38c50a4f8a | |||
| c497f321bb | |||
| 84c0768c8b | |||
| efb8d8115f | |||
| 41183b4648 | |||
| 36e20bf0d1 | |||
| 0308e9ad83 | |||
| 06c0374f16 | |||
| c5bc610e2f | |||
| a86a24ca34 | |||
| cb2e3ea581 | |||
| 42eaf1d5e3 | |||
| 02ef706fc2 | |||
| 085b217abb | |||
| 0866e0972a | |||
| 4b13265737 | |||
| 79cd2b8123 | |||
| 8cc9641bbf | |||
| 32935aed88 | |||
| c72adbfc4b | |||
| bc378cc619 | |||
| 6327a5d2bf | |||
| 2ab2a04748 | |||
| fb0a9e98a1 | |||
| e45fe1bf10 | |||
| 3d852a8356 | |||
| b244944f41 | |||
| 3c7ba669e2 | |||
| 81e7e7129c | |||
| d7ad110b9e | |||
| 0c17ea2dcd | |||
| 78ff13a654 | |||
| 863c285049 | |||
| a3d74c0b57 | |||
| 9016a72046 |
@@ -1,7 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
data class AdminCanChargeRequest(
|
||||
val memberId: Long,
|
||||
val memberIds: List<Long>,
|
||||
val method: String,
|
||||
val can: Int
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
import kr.co.vividnext.sodalive.can.CanResponse
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
@@ -13,6 +15,11 @@ import org.springframework.web.bind.annotation.RestController
|
||||
@RequestMapping("/admin/can")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminCanController(private val service: AdminCanService) {
|
||||
@GetMapping
|
||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
||||
return ApiResponse.ok(service.getCans())
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
||||
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.can.Can
|
||||
import kr.co.vividnext.sodalive.can.CanResponse
|
||||
import kr.co.vividnext.sodalive.can.CanStatus
|
||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||
import kr.co.vividnext.sodalive.can.QCanResponse
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
interface AdminCanRepository : JpaRepository<Can, Long>
|
||||
interface AdminCanRepository : JpaRepository<Can, Long>, AdminCanQueryRepository
|
||||
|
||||
interface AdminCanQueryRepository {
|
||||
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
||||
}
|
||||
|
||||
@Repository
|
||||
class AdminCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminCanQueryRepository {
|
||||
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QCanResponse(
|
||||
can1.id,
|
||||
can1.title,
|
||||
can1.can,
|
||||
can1.rewardCan,
|
||||
can1.price.intValue(),
|
||||
can1.currency,
|
||||
can1.price.stringValue()
|
||||
)
|
||||
)
|
||||
.from(can1)
|
||||
.where(can1.status.eq(status))
|
||||
.orderBy(can1.currency.asc(), can1.price.asc())
|
||||
.fetch()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.admin.can
|
||||
import kr.co.vividnext.sodalive.can.Can
|
||||
import kr.co.vividnext.sodalive.can.CanStatus
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class AdminCanRequest(
|
||||
val can: Int,
|
||||
val rewardCan: Int,
|
||||
val price: Int
|
||||
val price: BigDecimal,
|
||||
val currency: String
|
||||
) {
|
||||
fun toEntity(): Can {
|
||||
var title = "${can.moneyFormat()} 캔"
|
||||
@@ -20,6 +22,7 @@ data class AdminCanRequest(
|
||||
can = can,
|
||||
rewardCan = rewardCan,
|
||||
price = price,
|
||||
currency = currency,
|
||||
status = CanStatus.SALE
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
||||
import kr.co.vividnext.sodalive.can.CanResponse
|
||||
import kr.co.vividnext.sodalive.can.CanStatus
|
||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||
@@ -20,6 +21,10 @@ class AdminCanService(
|
||||
private val chargeRepository: ChargeRepository,
|
||||
private val memberRepository: AdminMemberRepository
|
||||
) {
|
||||
fun getCans(): List<CanResponse> {
|
||||
return repository.findAllByStatus(status = CanStatus.SALE)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun saveCan(request: AdminCanRequest) {
|
||||
repository.save(request.toEntity())
|
||||
@@ -35,22 +40,27 @@ class AdminCanService(
|
||||
|
||||
@Transactional
|
||||
fun charge(request: AdminCanChargeRequest) {
|
||||
val member = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 회원번호 입니다.")
|
||||
|
||||
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
|
||||
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
|
||||
|
||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||
charge.title = "${request.can.moneyFormat()} 캔"
|
||||
charge.member = member
|
||||
val ids = request.memberIds.distinct()
|
||||
if (ids.isEmpty()) throw SodaException("회원번호를 입력하세요.")
|
||||
|
||||
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
payment.method = request.method
|
||||
charge.payment = payment
|
||||
val members = memberRepository.findAllById(ids).toList()
|
||||
if (members.size != ids.size) throw SodaException("잘못된 회원번호 입니다.")
|
||||
|
||||
chargeRepository.save(charge)
|
||||
members.forEach { member ->
|
||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||
charge.title = "${request.can.moneyFormat()} 캔"
|
||||
charge.member = member
|
||||
|
||||
member.pgRewardCan += charge.rewardCan
|
||||
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
payment.method = request.method
|
||||
charge.payment = payment
|
||||
|
||||
chargeRepository.save(charge)
|
||||
|
||||
member.pgRewardCan += charge.rewardCan
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
|
||||
@GetMapping("/detail")
|
||||
fun getChargeStatusDetail(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam paymentGateway: PaymentGateway
|
||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
|
||||
@RequestParam paymentGateway: PaymentGateway,
|
||||
@RequestParam(value = "currency", required = false) currency: String? = null
|
||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.BooleanBuilder
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||
@@ -14,7 +15,7 @@ import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
|
||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||
val formattedDate = Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
Expressions.dateTimeTemplate(
|
||||
@@ -26,15 +27,16 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
),
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetChargeStatusQueryDto(
|
||||
QGetChargeStatusResponse(
|
||||
formattedDate,
|
||||
payment.price.sum(),
|
||||
can1.price.sum(),
|
||||
payment.id.count(),
|
||||
payment.paymentGateway
|
||||
payment.paymentGateway.stringValue(),
|
||||
currency.coalesce("KRW")
|
||||
)
|
||||
)
|
||||
.from(payment)
|
||||
@@ -46,15 +48,46 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
)
|
||||
.groupBy(formattedDate, payment.paymentGateway)
|
||||
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
|
||||
.orderBy(formattedDate.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getChargeStatusSummary(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||
val currency = Expressions.stringTemplate(
|
||||
"substring({0}, length({0}) - 2, 3)",
|
||||
payment.locale
|
||||
).coalesce("KRW")
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetChargeStatusResponse(
|
||||
Expressions.stringTemplate("'합계'"), // date
|
||||
payment.price.sum(),
|
||||
payment.id.count(),
|
||||
Expressions.stringTemplate("''"),
|
||||
currency
|
||||
)
|
||||
)
|
||||
.from(payment)
|
||||
.innerJoin(payment.charge, charge)
|
||||
.leftJoin(charge.can, can1)
|
||||
.where(
|
||||
charge.createdAt.goe(startDate)
|
||||
.and(charge.createdAt.loe(endDate))
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
)
|
||||
.groupBy(currency)
|
||||
.orderBy(currency.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getChargeStatusDetail(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
paymentGateway: PaymentGateway
|
||||
paymentGateway: PaymentGateway,
|
||||
currency: String? = null
|
||||
): List<GetChargeStatusDetailQueryDto> {
|
||||
val formattedDate = Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
@@ -67,6 +100,20 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
),
|
||||
"%Y-%m-%d %H:%i:%s"
|
||||
)
|
||||
val currencyExpr = Expressions.stringTemplate(
|
||||
"substring({0}, length({0}) - 2, 3)",
|
||||
payment.locale
|
||||
).coalesce("KRW")
|
||||
val whereBuilder = BooleanBuilder()
|
||||
whereBuilder.and(charge.createdAt.goe(startDate))
|
||||
.and(charge.createdAt.loe(endDate))
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
.and(payment.paymentGateway.eq(paymentGateway))
|
||||
|
||||
if (currency != null) {
|
||||
whereBuilder.and(currencyExpr.eq(currency))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
@@ -75,8 +122,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
member.nickname,
|
||||
payment.method.coalesce(""),
|
||||
payment.price,
|
||||
can1.price,
|
||||
payment.locale.coalesce(""),
|
||||
currencyExpr,
|
||||
formattedDate
|
||||
)
|
||||
)
|
||||
@@ -84,13 +130,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
.innerJoin(charge.member, member)
|
||||
.innerJoin(charge.payment, payment)
|
||||
.leftJoin(charge.can, can1)
|
||||
.where(
|
||||
charge.createdAt.goe(startDate)
|
||||
.and(charge.createdAt.loe(endDate))
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
.and(payment.paymentGateway.eq(paymentGateway))
|
||||
)
|
||||
.where(whereBuilder)
|
||||
.orderBy(formattedDate.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -20,48 +20,17 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
var totalChargeAmount = 0
|
||||
var totalChargeCount = 0L
|
||||
|
||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
|
||||
.asSequence()
|
||||
.map {
|
||||
val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) {
|
||||
it.pgChargeAmount
|
||||
} else {
|
||||
it.appleChargeAmount.toInt()
|
||||
}
|
||||
|
||||
val chargeCount = it.chargeCount
|
||||
|
||||
totalChargeAmount += chargeAmount
|
||||
totalChargeCount += chargeCount
|
||||
|
||||
GetChargeStatusResponse(
|
||||
date = it.date,
|
||||
chargeAmount = chargeAmount,
|
||||
chargeCount = chargeCount,
|
||||
pg = it.paymentGateWay.name
|
||||
)
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
chargeStatusList.add(
|
||||
0,
|
||||
GetChargeStatusResponse(
|
||||
date = "합계",
|
||||
chargeAmount = totalChargeAmount,
|
||||
chargeCount = totalChargeCount,
|
||||
pg = ""
|
||||
)
|
||||
)
|
||||
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
|
||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
|
||||
chargeStatusList.addAll(0, summaryRows)
|
||||
|
||||
return chargeStatusList.toList()
|
||||
}
|
||||
|
||||
fun getChargeStatusDetail(
|
||||
startDateStr: String,
|
||||
paymentGateway: PaymentGateway
|
||||
paymentGateway: PaymentGateway,
|
||||
currency: String? = null
|
||||
): List<GetChargeStatusDetailResponse> {
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||
@@ -74,18 +43,16 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
|
||||
.asSequence()
|
||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
|
||||
.map {
|
||||
GetChargeStatusDetailResponse(
|
||||
memberId = it.memberId,
|
||||
nickname = it.nickname,
|
||||
method = it.method,
|
||||
amount = it.appleChargeAmount.toInt(),
|
||||
amount = it.amount,
|
||||
locale = it.locale,
|
||||
datetime = it.datetime
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val method: String,
|
||||
val appleChargeAmount: Double,
|
||||
val pgChargeAmount: Int,
|
||||
val amount: BigDecimal,
|
||||
val locale: String,
|
||||
val datetime: String
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusDetailResponse(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val method: String,
|
||||
val amount: Int,
|
||||
val amount: BigDecimal,
|
||||
val locale: String,
|
||||
val datetime: String
|
||||
)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
|
||||
data class GetChargeStatusQueryDto @QueryProjection constructor(
|
||||
val date: String,
|
||||
val appleChargeAmount: Double,
|
||||
val pgChargeAmount: Int,
|
||||
val chargeCount: Long,
|
||||
val paymentGateWay: PaymentGateway
|
||||
)
|
||||
@@ -1,8 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
data class GetChargeStatusResponse(
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusResponse @QueryProjection constructor(
|
||||
val date: String,
|
||||
val chargeAmount: Int,
|
||||
val chargeAmount: BigDecimal,
|
||||
val chargeCount: Long,
|
||||
val pg: String
|
||||
val pg: String,
|
||||
val currency: String
|
||||
)
|
||||
|
||||
@@ -13,8 +13,13 @@ import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
@@ -40,6 +45,7 @@ class AdminChatCharacterController(
|
||||
private val adminService: AdminChatCharacterService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val originalWorkService: AdminOriginalWorkService,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${weraser.api-key}")
|
||||
private val apiKey: String,
|
||||
@@ -165,6 +171,18 @@ class AdminChatCharacterController(
|
||||
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
||||
}
|
||||
|
||||
// 5. 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
// 언어 감지에 사용할 내용은 chatCharacter.description 만 사용한다.
|
||||
if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = chatCharacter.id!!,
|
||||
query = chatCharacter.description,
|
||||
targetType = LanguageDetectTargetType.CHARACTER
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@@ -315,6 +333,13 @@ class AdminChatCharacterController(
|
||||
request = request
|
||||
)
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = request.id,
|
||||
targetType = LanguageTranslationTargetType.CHARACTER
|
||||
)
|
||||
)
|
||||
|
||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||
if (request.originalWorkId != null) {
|
||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||
|
||||
@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.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.RestController
|
||||
@@ -19,4 +21,9 @@ class AdminContentSeriesController(private val service: AdminContentSeriesServic
|
||||
fun searchSeriesList(
|
||||
@RequestParam(value = "search_word") searchWord: String
|
||||
) = 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
|
||||
|
||||
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.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AdminContentSeriesService(private val repository: AdminContentSeriesRepository) {
|
||||
class AdminContentSeriesService(
|
||||
private val repository: AdminContentSeriesRepository,
|
||||
private val genreRepository: AdminContentSeriesGenreRepository
|
||||
) {
|
||||
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
|
||||
val totalCount = repository.getSeriesTotalCount()
|
||||
val items = repository.getSeriesList(
|
||||
@@ -12,10 +19,53 @@ class AdminContentSeriesService(private val repository: AdminContentSeriesReposi
|
||||
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)
|
||||
}
|
||||
|
||||
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
|
||||
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
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
|
||||
data class GetAdminSeriesListResponse(
|
||||
val totalCount: Int,
|
||||
@@ -17,7 +18,10 @@ data class GetAdminSeriesListItem @QueryProjection constructor(
|
||||
val numberOfWorks: Long,
|
||||
val state: String,
|
||||
val isAdult: Boolean
|
||||
)
|
||||
) {
|
||||
var publishedDaysOfWeek: List<SeriesPublishedDaysOfWeek> = emptyList()
|
||||
var isOriginal: Boolean = false
|
||||
}
|
||||
|
||||
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
|
||||
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 {
|
||||
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
|
||||
fun findActiveSeriesGenreById(id: Long): SeriesGenre?
|
||||
}
|
||||
|
||||
class AdminContentSeriesGenreQueryRepositoryImpl(
|
||||
@@ -21,4 +22,14 @@ class AdminContentSeriesGenreQueryRepositoryImpl(
|
||||
.orderBy(seriesGenre.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun findActiveSeriesGenreById(id: Long): SeriesGenre? {
|
||||
return queryFactory
|
||||
.selectFrom(seriesGenre)
|
||||
.where(
|
||||
seriesGenre.id.eq(id)
|
||||
.and(seriesGenre.isActive.isTrue)
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
@@ -18,6 +21,8 @@ class AdminContentThemeService(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val repository: AdminContentThemeRepository,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String
|
||||
) {
|
||||
@@ -37,7 +42,14 @@ class AdminContentThemeService(
|
||||
}
|
||||
|
||||
fun createTheme(theme: String, imagePath: String) {
|
||||
repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||
val savedTheme = repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = savedTheme.id!!,
|
||||
targetType = LanguageTranslationTargetType.CONTENT_THEME
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun themeExistCheck(request: CreateContentThemeRequest) {
|
||||
|
||||
@@ -36,6 +36,12 @@ class AdminMemberController(private val service: AdminMemberService) {
|
||||
pageable: 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")
|
||||
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ interface AdminMemberQueryRepository {
|
||||
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
|
||||
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
|
||||
fun findByIdAndActive(memberId: Long): Member?
|
||||
fun searchMemberByNickname(searchWord: String, limit: Long = 20): List<AdminSimpleMemberResponse>
|
||||
}
|
||||
|
||||
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
|
||||
@@ -121,4 +122,22 @@ class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
|
||||
.orderBy(member.id.desc())
|
||||
.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()
|
||||
}
|
||||
|
||||
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
|
||||
fun resetPassword(request: ResetPasswordRequest) {
|
||||
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.DateTimePath
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.core.types.dsl.NumberExpression
|
||||
import com.querydsl.core.types.dsl.StringTemplate
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||
@@ -67,7 +66,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
val firstPaymentTotalAmount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(Expressions.constant(0.0))
|
||||
.otherwise(0.toBigDecimal())
|
||||
.sum()
|
||||
|
||||
val repeatPaymentCount = CaseBuilder()
|
||||
@@ -79,7 +78,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
val repeatPaymentTotalAmount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(Expressions.constant(0.0))
|
||||
.otherwise(0.toBigDecimal())
|
||||
.sum()
|
||||
|
||||
val allPaymentCount = CaseBuilder()
|
||||
@@ -97,7 +96,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||
)
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(Expressions.constant(0.0))
|
||||
.otherwise(0.toBigDecimal())
|
||||
.sum()
|
||||
|
||||
return queryFactory
|
||||
@@ -111,11 +110,11 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
loginCount,
|
||||
signUpCount,
|
||||
firstPaymentCount,
|
||||
roundedValueDecimalPlaces2(firstPaymentTotalAmount),
|
||||
firstPaymentTotalAmount,
|
||||
repeatPaymentCount,
|
||||
roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
|
||||
repeatPaymentTotalAmount,
|
||||
allPaymentCount,
|
||||
roundedValueDecimalPlaces2(allPaymentTotalAmount)
|
||||
allPaymentTotalAmount
|
||||
)
|
||||
)
|
||||
.from(adTrackingHistory)
|
||||
@@ -148,13 +147,4 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
}
|
||||
|
||||
private fun roundedValueDecimalPlaces2(valueExpression: NumberExpression<Double>): NumberExpression<Double> {
|
||||
return Expressions.numberTemplate(
|
||||
Double::class.java,
|
||||
"ROUND({0}, {1})",
|
||||
valueExpression,
|
||||
2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetAdminAdStatisticsResponse(
|
||||
val totalCount: Int,
|
||||
@@ -16,9 +17,9 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
|
||||
val loginCount: Int,
|
||||
val signUpCount: Int,
|
||||
val firstPaymentCount: Int,
|
||||
val firstPaymentTotalAmount: Double,
|
||||
val firstPaymentTotalAmount: BigDecimal,
|
||||
val repeatPaymentCount: Int,
|
||||
val repeatPaymentTotalAmount: Double,
|
||||
val repeatPaymentTotalAmount: BigDecimal,
|
||||
val allPaymentCount: Int,
|
||||
val allPaymentTotalAmount: Double
|
||||
val allPaymentTotalAmount: BigDecimal
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.GetAuditionListItem
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
||||
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
|
||||
@@ -21,8 +22,11 @@ data class GetHomeResponse(
|
||||
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
|
||||
val auditionList: List<GetAuditionListItem>,
|
||||
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
|
||||
val popularCharacters: List<Character>,
|
||||
val contentRanking: List<GetAudioContentRankingItem>,
|
||||
val recommendChannelList: List<RecommendChannelResponse>,
|
||||
val freeContentList: List<AudioContentMainItem>,
|
||||
val pointAvailableContentList: List<AudioContentMainItem>,
|
||||
val recommendContentList: List<AudioContentMainItem>,
|
||||
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.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
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,22 +1,29 @@
|
||||
package kr.co.vividnext.sodalive.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.AuditionService
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.event.GetEventResponse
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberService
|
||||
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
|
||||
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
|
||||
import kr.co.vividnext.sodalive.rank.RankingRepository
|
||||
import kr.co.vividnext.sodalive.rank.RankingService
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
@@ -39,13 +46,24 @@ class HomeService(
|
||||
private val contentThemeService: AudioContentThemeService,
|
||||
private val recommendChannelService: RecommendChannelQueryService,
|
||||
|
||||
private val characterService: ChatCharacterService,
|
||||
private val rankingService: RankingService,
|
||||
private val rankingRepository: RankingRepository,
|
||||
private val explorerQueryRepository: ExplorerQueryRepository,
|
||||
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
|
||||
private val langContext: LangContext,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
companion object {
|
||||
private const val RECOMMEND_TARGET_SIZE = 30
|
||||
private const val RECOMMEND_MAX_ATTEMPTS = 3
|
||||
}
|
||||
|
||||
fun fetchData(
|
||||
timezone: String,
|
||||
isAdultContentVisible: Boolean,
|
||||
@@ -102,6 +120,8 @@ class HomeService(
|
||||
}
|
||||
}
|
||||
|
||||
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
|
||||
|
||||
val eventBannerList = GetEventResponse(
|
||||
totalCount = 0,
|
||||
eventList = emptyList()
|
||||
@@ -115,7 +135,8 @@ class HomeService(
|
||||
|
||||
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
contentType = contentType,
|
||||
orderByRandom = true
|
||||
)
|
||||
|
||||
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
||||
@@ -127,6 +148,9 @@ class HomeService(
|
||||
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
||||
)
|
||||
|
||||
// 인기 캐릭터 조회
|
||||
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
|
||||
|
||||
val currentDateTime = LocalDateTime.now()
|
||||
val startDate = currentDateTime
|
||||
.withHour(15)
|
||||
@@ -143,10 +167,26 @@ class HomeService(
|
||||
contentType = contentType,
|
||||
startDate = startDate.minusDays(1),
|
||||
endDate = endDate,
|
||||
sortType = "매출"
|
||||
sort = ContentRankingSortType.REVENUE
|
||||
)
|
||||
|
||||
// TODO 오디오 북
|
||||
val contentRankingContentIds = contentRanking.map { it.contentId }
|
||||
val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) {
|
||||
val translations = contentTranslationRepository
|
||||
.findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code)
|
||||
.associateBy { it.contentId }
|
||||
|
||||
contentRanking.map { item ->
|
||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contentRanking
|
||||
}
|
||||
|
||||
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
||||
memberId = memberId,
|
||||
@@ -154,6 +194,40 @@ class HomeService(
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
/**
|
||||
* recommendChannelList의 콘텐츠 번역 데이터 조회
|
||||
*
|
||||
* languageCode != null
|
||||
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
|
||||
*
|
||||
* 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다
|
||||
*/
|
||||
val channelContentIds = recommendChannelList
|
||||
.flatMap { it.contentList }
|
||||
.map { it.contentId }
|
||||
.distinct()
|
||||
|
||||
val translatedRecommendChannelList = if (channelContentIds.isNotEmpty()) {
|
||||
val translations = contentTranslationRepository
|
||||
.findByContentIdInAndLocale(contentIds = channelContentIds, locale = langContext.lang.code)
|
||||
.associateBy { it.contentId }
|
||||
|
||||
recommendChannelList.map { channel ->
|
||||
val translatedContentList = channel.contentList.map { item ->
|
||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
}
|
||||
|
||||
channel.copy(contentList = translatedContentList)
|
||||
}
|
||||
} else {
|
||||
recommendChannelList
|
||||
}
|
||||
|
||||
val freeContentList = contentService.getLatestContentByTheme(
|
||||
theme = contentThemeService.getActiveThemeOfContent(
|
||||
isAdult = isAdult,
|
||||
@@ -162,7 +236,8 @@ class HomeService(
|
||||
),
|
||||
contentType = contentType,
|
||||
isFree = true,
|
||||
isAdult = isAdult
|
||||
isAdult = isAdult,
|
||||
orderByRandom = true
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
@@ -171,6 +246,26 @@ class HomeService(
|
||||
}
|
||||
}
|
||||
|
||||
val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList)
|
||||
|
||||
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
|
||||
val pointAvailableContentList = contentService.getLatestContentByTheme(
|
||||
theme = emptyList(),
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = true,
|
||||
isPointAvailableOnly = true
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
|
||||
|
||||
val curationList = curationService.getContentCurationList(
|
||||
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
||||
isAdult = isAdult,
|
||||
@@ -182,15 +277,22 @@ class HomeService(
|
||||
liveList = liveList,
|
||||
creatorRanking = creatorRanking,
|
||||
latestContentThemeList = latestContentThemeList,
|
||||
latestContentList = latestContentList,
|
||||
latestContentList = translatedLatestContentList,
|
||||
bannerList = bannerList,
|
||||
eventBannerList = eventBannerList,
|
||||
originalAudioDramaList = originalAudioDramaList,
|
||||
auditionList = auditionList,
|
||||
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
||||
contentRanking = contentRanking,
|
||||
recommendChannelList = recommendChannelList,
|
||||
freeContentList = freeContentList,
|
||||
popularCharacters = translatedPopularCharacters,
|
||||
contentRanking = translatedContentRanking,
|
||||
recommendChannelList = translatedRecommendChannelList,
|
||||
freeContentList = translatedFreeContentList,
|
||||
pointAvailableContentList = translatedPointAvailableContentList,
|
||||
recommendContentList = getRecommendContentList(
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
contentType = contentType,
|
||||
member = member
|
||||
),
|
||||
curationList = curationList
|
||||
)
|
||||
}
|
||||
@@ -214,7 +316,7 @@ class HomeService(
|
||||
listOf(theme)
|
||||
}
|
||||
|
||||
return contentService.getLatestContentByTheme(
|
||||
val contentList = contentService.getLatestContentByTheme(
|
||||
theme = themeList,
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
@@ -226,6 +328,8 @@ class HomeService(
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
return getTranslatedContentList(contentList = contentList)
|
||||
}
|
||||
|
||||
fun getDayOfWeekSeriesList(
|
||||
@@ -245,6 +349,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 {
|
||||
val systemTime = LocalDateTime.now()
|
||||
val zoneId = ZoneId.of(timezone)
|
||||
@@ -262,4 +400,116 @@ class HomeService(
|
||||
|
||||
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 getTranslatedContentList(contentList = result)
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||
*
|
||||
* 처리 절차:
|
||||
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
||||
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
|
||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||
*
|
||||
* 성능:
|
||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||
*
|
||||
* @param contentList 번역 대상 AudioContentMainItem 목록
|
||||
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
|
||||
*/
|
||||
private fun getTranslatedContentList(contentList: List<AudioContentMainItem>): List<AudioContentMainItem> {
|
||||
val contentIds = contentList.map { it.contentId }
|
||||
|
||||
return if (contentIds.isNotEmpty()) {
|
||||
val translations = contentTranslationRepository
|
||||
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||
.associateBy { it.contentId }
|
||||
|
||||
contentList.map { item ->
|
||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contentList
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||
*
|
||||
* 처리 절차:
|
||||
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
|
||||
* 번역 데이터를 한 번에 조회한다.
|
||||
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
|
||||
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
|
||||
*
|
||||
* @param aiCharacterList 번역 대상 캐릭터 목록
|
||||
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
|
||||
*/
|
||||
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
|
||||
val characterIds = aiCharacterList.map { it.characterId }
|
||||
|
||||
return if (characterIds.isNotEmpty()) {
|
||||
val translations = aiCharacterTranslationRepository
|
||||
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||
.associateBy { it.characterId }
|
||||
|
||||
aiCharacterList.map { character ->
|
||||
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
||||
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
|
||||
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
|
||||
character
|
||||
} else {
|
||||
character.copy(name = translatedName, description = translatedDesc)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
aiCharacterList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.can
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import java.math.BigDecimal
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
@@ -10,7 +12,10 @@ data class Can(
|
||||
var title: String,
|
||||
var can: Int,
|
||||
var rewardCan: Int,
|
||||
var price: Int,
|
||||
@Column(precision = 10, scale = 4, nullable = false)
|
||||
var price: BigDecimal,
|
||||
@Column(length = 3, nullable = false, columnDefinition = "CHAR(3)")
|
||||
var currency: String,
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
var status: CanStatus
|
||||
) : BaseEntity()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.can
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.GeoCountry
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.data.domain.Pageable
|
||||
@@ -9,13 +10,15 @@ import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/can")
|
||||
class CanController(private val service: CanService) {
|
||||
@GetMapping
|
||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
||||
return ApiResponse.ok(service.getCans())
|
||||
fun getCans(request: HttpServletRequest): ApiResponse<List<CanResponse>> {
|
||||
val geoCountry = request.getAttribute("geoCountry") as? GeoCountry ?: GeoCountry.OTHER
|
||||
return ApiResponse.ok(service.getCans(geoCountry))
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.springframework.stereotype.Repository
|
||||
interface CanRepository : JpaRepository<Can, Long>, CanQueryRepository
|
||||
|
||||
interface CanQueryRepository {
|
||||
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
||||
fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse>
|
||||
fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan>
|
||||
fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge>
|
||||
fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan?
|
||||
@@ -32,7 +32,7 @@ interface CanQueryRepository {
|
||||
|
||||
@Repository
|
||||
class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository {
|
||||
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
||||
override fun findAllByStatusAndCurrency(status: CanStatus, currency: String): List<CanResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QCanResponse(
|
||||
@@ -40,11 +40,16 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue
|
||||
can1.title,
|
||||
can1.can,
|
||||
can1.rewardCan,
|
||||
can1.price
|
||||
can1.price.intValue(),
|
||||
can1.currency,
|
||||
can1.price.stringValue()
|
||||
)
|
||||
)
|
||||
.from(can1)
|
||||
.where(can1.status.eq(status))
|
||||
.where(
|
||||
can1.status.eq(status),
|
||||
can1.currency.eq(currency)
|
||||
)
|
||||
.orderBy(can1.can.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -7,5 +7,7 @@ data class CanResponse @QueryProjection constructor(
|
||||
val title: String,
|
||||
val can: Int,
|
||||
val rewardCan: Int,
|
||||
val price: Int
|
||||
val price: Int,
|
||||
val currency: String,
|
||||
val priceStr: String
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.can
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.common.GeoCountry
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -11,8 +12,12 @@ import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
class CanService(private val repository: CanRepository) {
|
||||
fun getCans(): List<CanResponse> {
|
||||
return repository.findAllByStatus(status = CanStatus.SALE)
|
||||
fun getCans(geoCountry: GeoCountry): List<CanResponse> {
|
||||
val currency = when (geoCountry) {
|
||||
GeoCountry.KR -> "KRW"
|
||||
else -> "USD"
|
||||
}
|
||||
return repository.findAllByStatusAndCurrency(status = CanStatus.SALE, currency = currency)
|
||||
}
|
||||
|
||||
fun getCanStatus(member: Member, container: String): GetCanStatusResponse {
|
||||
@@ -35,6 +40,7 @@ class CanService(private val repository: CanRepository) {
|
||||
"aos" -> {
|
||||
it.useCanCalculates.any { useCanCalculate ->
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
|
||||
}
|
||||
}
|
||||
@@ -42,12 +48,14 @@ class CanService(private val repository: CanRepository) {
|
||||
"ios" -> {
|
||||
it.useCanCalculates.any { useCanCalculate ->
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP
|
||||
}
|
||||
}
|
||||
|
||||
else -> it.useCanCalculates.any { useCanCalculate ->
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PG
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.can.charge
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class ChargeCompleteResponse(
|
||||
val price: Double,
|
||||
val price: BigDecimal,
|
||||
val currencyCode: String,
|
||||
val isFirstCharged: Boolean
|
||||
)
|
||||
|
||||
@@ -59,10 +59,12 @@ class ChargeController(
|
||||
@RequestBody request: PayverseWebhookRequest,
|
||||
servletRequest: HttpServletRequest
|
||||
): PayverseWebhookResponse {
|
||||
val remoteIp = servletRequest.remoteAddr ?: ""
|
||||
|
||||
print("Payverse Webhook Request: $remoteIp")
|
||||
print("Payverse Webhook Request: $payverseInboundIp")
|
||||
val header = servletRequest.getHeader("X-Forwarded-For")
|
||||
val remoteIp = if (header.isNullOrEmpty()) {
|
||||
servletRequest.remoteAddr
|
||||
} else {
|
||||
header.split(",")[0].trim() // 첫 번째 값이 클라이언트 IP
|
||||
}
|
||||
|
||||
if (remoteIp != payverseInboundIp) {
|
||||
throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
@@ -166,8 +168,7 @@ class ChargeController(
|
||||
memberId = member.id!!,
|
||||
chargeId = chargeId,
|
||||
productId = request.productId,
|
||||
purchaseToken = request.purchaseToken,
|
||||
paymentGateway = request.paymentGateway
|
||||
purchaseToken = request.purchaseToken
|
||||
)
|
||||
|
||||
trackingCharge(member, response)
|
||||
|
||||
@@ -21,14 +21,14 @@ data class VerifyResult(
|
||||
val method: String,
|
||||
val pg: String,
|
||||
val status: Int,
|
||||
val price: Int
|
||||
val price: BigDecimal
|
||||
)
|
||||
|
||||
data class AppleChargeRequest(
|
||||
val title: String,
|
||||
val chargeCan: Int,
|
||||
val paymentGateway: PaymentGateway,
|
||||
var price: Double? = null,
|
||||
var price: BigDecimal? = null,
|
||||
var locale: String? = null
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ data class AppleVerifyResponse(val status: Int)
|
||||
data class GoogleChargeRequest(
|
||||
val title: String,
|
||||
val chargeCan: Int,
|
||||
val price: Double,
|
||||
val price: BigDecimal,
|
||||
val currencyCode: String,
|
||||
val productId: String,
|
||||
val purchaseToken: String,
|
||||
@@ -70,8 +70,8 @@ data class PayverseVerifyResponse(
|
||||
val transactionMessage: String,
|
||||
val orderId: String,
|
||||
val customerId: String,
|
||||
val processingCurrency: String,
|
||||
val processingAmount: BigDecimal
|
||||
val requestCurrency: String,
|
||||
val requestAmount: BigDecimal
|
||||
)
|
||||
|
||||
data class PayverseWebhookRequest(
|
||||
@@ -81,7 +81,6 @@ data class PayverseWebhookRequest(
|
||||
val schemeGroup: String,
|
||||
val schemeCode: String,
|
||||
val orderId: String,
|
||||
val productName: String,
|
||||
val requestCurrency: String,
|
||||
val requestAmount: BigDecimal,
|
||||
val resultStatus: String,
|
||||
|
||||
@@ -113,15 +113,18 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha
|
||||
val paymentGatewayCondition = when (container) {
|
||||
"aos" -> {
|
||||
payment.paymentGateway.eq(PaymentGateway.PG)
|
||||
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||
.or(payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP))
|
||||
}
|
||||
|
||||
"ios" -> {
|
||||
payment.paymentGateway.eq(PaymentGateway.PG)
|
||||
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||
.or(payment.paymentGateway.eq(PaymentGateway.APPLE_IAP))
|
||||
}
|
||||
|
||||
else -> payment.paymentGateway.eq(PaymentGateway.PG)
|
||||
.or(payment.paymentGateway.eq(PaymentGateway.PAYVERSE))
|
||||
}
|
||||
|
||||
return paymentGatewayCondition.or(payment.paymentGateway.eq(PaymentGateway.POINT_CLICK_AD))
|
||||
|
||||
@@ -73,6 +73,14 @@ class ChargeService(
|
||||
private val payverseClientKey: String,
|
||||
@Value("\${payverse.secret-key}")
|
||||
private val payverseSecretKey: String,
|
||||
|
||||
@Value("\${payverse.usd-mid}")
|
||||
private val payverseUsdMid: String,
|
||||
@Value("\${payverse.usd-client-key}")
|
||||
private val payverseUsdClientKey: String,
|
||||
@Value("\${payverse.usd-secret-key}")
|
||||
private val payverseUsdSecretKey: String,
|
||||
|
||||
@Value("\${payverse.host}")
|
||||
private val payverseHost: String,
|
||||
|
||||
@@ -94,11 +102,20 @@ class ChargeService(
|
||||
return when (charge.payment?.status) {
|
||||
PaymentStatus.REQUEST -> {
|
||||
// 성공 조건 검증
|
||||
val mid = if (request.requestCurrency == "KRW") {
|
||||
payverseMid
|
||||
} else {
|
||||
payverseUsdMid
|
||||
}
|
||||
val expectedSign = DigestUtils.sha512Hex(
|
||||
String.format(
|
||||
"||%s||%s||%s||%s||%s||",
|
||||
payverseSecretKey,
|
||||
payverseMid,
|
||||
if (request.requestCurrency == "KRW") {
|
||||
payverseSecretKey
|
||||
} else {
|
||||
payverseUsdSecretKey
|
||||
},
|
||||
mid,
|
||||
request.orderId,
|
||||
request.requestAmount,
|
||||
request.approvalDay
|
||||
@@ -106,12 +123,12 @@ class ChargeService(
|
||||
)
|
||||
|
||||
val isAmountMatch = request.requestAmount.compareTo(
|
||||
BigDecimal.valueOf(charge.payment!!.price)
|
||||
charge.payment!!.price
|
||||
) == 0
|
||||
|
||||
val isSuccess = request.resultStatus == "SUCCESS" &&
|
||||
request.mid == payverseMid &&
|
||||
charge.title == request.productName &&
|
||||
request.mid == mid &&
|
||||
request.orderId.toLongOrNull() == charge.id &&
|
||||
isAmountMatch &&
|
||||
request.sign == expectedSign
|
||||
|
||||
@@ -219,29 +236,58 @@ class ChargeService(
|
||||
val can = canRepository.findByIdOrNull(request.canId)
|
||||
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
|
||||
|
||||
val requestCurrency = can.currency
|
||||
val isKrw = requestCurrency == "KRW"
|
||||
val mid = if (isKrw) {
|
||||
payverseMid
|
||||
} else {
|
||||
payverseUsdMid
|
||||
}
|
||||
val clientKey = if (isKrw) {
|
||||
payverseClientKey
|
||||
} else {
|
||||
payverseUsdClientKey
|
||||
}
|
||||
val secretKey = if (isKrw) {
|
||||
payverseSecretKey
|
||||
} else {
|
||||
payverseUsdSecretKey
|
||||
}
|
||||
|
||||
val charge = Charge(can.can, can.rewardCan)
|
||||
charge.title = can.title
|
||||
charge.member = member
|
||||
charge.can = can
|
||||
|
||||
val payment = Payment(paymentGateway = PaymentGateway.PAYVERSE)
|
||||
payment.price = can.price.toDouble()
|
||||
payment.price = can.price
|
||||
charge.payment = payment
|
||||
|
||||
val savedCharge = chargeRepository.save(charge)
|
||||
|
||||
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 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 requestCurrency = "KRW"
|
||||
|
||||
val payload = linkedMapOf(
|
||||
"mid" to payverseMid,
|
||||
"clientKey" to payverseClientKey,
|
||||
"mid" to mid,
|
||||
"clientKey" to clientKey,
|
||||
"orderId" to chargeId.toString(),
|
||||
"customerId" to customerId,
|
||||
"productName" to can.title,
|
||||
@@ -262,6 +308,18 @@ class ChargeService(
|
||||
val member = memberRepository.findByIdOrNull(memberId)
|
||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
val isKrw = charge.can?.currency == "KRW"
|
||||
val mid = if (isKrw) {
|
||||
payverseMid
|
||||
} else {
|
||||
payverseUsdMid
|
||||
}
|
||||
val clientKey = if (isKrw) {
|
||||
payverseClientKey
|
||||
} else {
|
||||
payverseUsdClientKey
|
||||
}
|
||||
|
||||
// 결제수단 확인
|
||||
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
@@ -274,8 +332,8 @@ class ChargeService(
|
||||
val url = "$payverseHost/payment/search/transaction/${verifyRequest.transactionId}"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("mid", payverseMid)
|
||||
.addHeader("clientKey", payverseClientKey)
|
||||
.addHeader("mid", mid)
|
||||
.addHeader("clientKey", clientKey)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
@@ -287,10 +345,12 @@ class ChargeService(
|
||||
val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
|
||||
|
||||
val customerId = "${serverEnv}_user_${member.id!!}"
|
||||
val isSuccess = verifyResponse.resultStatus == "SUCCESS" &&
|
||||
verifyResponse.transactionStatus == "SUCCESS" &&
|
||||
verifyResponse.orderId.toLongOrNull() == charge.id &&
|
||||
verifyResponse.processingAmount.compareTo(BigDecimal.valueOf(charge.can!!.price.toLong())) == 0
|
||||
verifyResponse.customerId == customerId &&
|
||||
verifyResponse.requestAmount.compareTo(charge.can!!.price) == 0
|
||||
|
||||
if (isSuccess) {
|
||||
// verify 함수의 232~248 라인과 동일 처리
|
||||
@@ -303,7 +363,7 @@ class ChargeService(
|
||||
charge.payment?.method = mappedMethod ?: verifyResponse.schemeCode
|
||||
charge.payment?.status = PaymentStatus.COMPLETE
|
||||
// 통화코드 설정
|
||||
charge.payment?.locale = verifyResponse.processingCurrency
|
||||
charge.payment?.locale = verifyResponse.requestCurrency
|
||||
|
||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||
|
||||
@@ -315,7 +375,7 @@ class ChargeService(
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
@@ -330,7 +390,7 @@ class ChargeService(
|
||||
PaymentStatus.COMPLETE -> {
|
||||
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
|
||||
return ChargeCompleteResponse(
|
||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
@@ -353,7 +413,7 @@ class ChargeService(
|
||||
charge.can = can
|
||||
|
||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||
payment.price = can.price.toDouble()
|
||||
payment.price = can.price
|
||||
charge.payment = payment
|
||||
|
||||
chargeRepository.save(charge)
|
||||
@@ -392,7 +452,7 @@ class ChargeService(
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
@@ -424,7 +484,7 @@ class ChargeService(
|
||||
VerifyResult::class.java
|
||||
)
|
||||
|
||||
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
|
||||
if (verifyResult.status == 1) {
|
||||
charge.payment?.receiptId = verifyResult.receiptId
|
||||
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
|
||||
"${verifyResult.pg}-${verifyResult.method}"
|
||||
@@ -442,7 +502,7 @@ class ChargeService(
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
@@ -467,7 +527,7 @@ class ChargeService(
|
||||
payment.price = if (request.price != null) {
|
||||
request.price!!
|
||||
} else {
|
||||
0.toDouble()
|
||||
0.toBigDecimal()
|
||||
}
|
||||
|
||||
payment.locale = request.locale
|
||||
@@ -502,7 +562,7 @@ class ChargeService(
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
@@ -519,7 +579,7 @@ class ChargeService(
|
||||
member: Member,
|
||||
title: String,
|
||||
chargeCan: Int,
|
||||
price: Double,
|
||||
price: BigDecimal,
|
||||
currencyCode: String,
|
||||
productId: String,
|
||||
purchaseToken: String,
|
||||
@@ -547,8 +607,7 @@ class ChargeService(
|
||||
memberId: Long,
|
||||
chargeId: Long,
|
||||
productId: String,
|
||||
purchaseToken: String,
|
||||
paymentGateway: PaymentGateway
|
||||
purchaseToken: String
|
||||
): ChargeCompleteResponse {
|
||||
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
||||
@@ -570,7 +629,7 @@ class ChargeService(
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = BigDecimal(charge.payment!!.price).setScale(2, RoundingMode.HALF_UP).toDouble(),
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.can.charge.temp
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class ChargeTempRequest(
|
||||
val can: Int,
|
||||
val price: Int,
|
||||
val price: BigDecimal,
|
||||
val paymentGateway: PaymentGateway
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ class ChargeTempService(
|
||||
charge.member = member
|
||||
|
||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||
payment.price = request.price.toDouble()
|
||||
payment.price = request.price
|
||||
charge.payment = payment
|
||||
|
||||
chargeRepository.save(charge)
|
||||
@@ -66,7 +66,7 @@ class ChargeTempService(
|
||||
VerifyResult::class.java
|
||||
)
|
||||
|
||||
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price.toInt()) {
|
||||
if (verifyResult.status == 1 && verifyResult.price == charge.payment!!.price) {
|
||||
charge.payment?.receiptId = verifyResult.receiptId
|
||||
charge.payment?.method = verifyResult.method
|
||||
charge.payment?.status = PaymentStatus.COMPLETE
|
||||
@@ -74,7 +74,7 @@ class ChargeTempService(
|
||||
} else {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
throw SodaException("결제정보에 오류가 있습니다.")
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -127,6 +127,7 @@ class CanPaymentService(
|
||||
useCanRepository.save(useCan)
|
||||
|
||||
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
||||
setUseCanCalculate(
|
||||
recipientId,
|
||||
useRewardCan,
|
||||
@@ -379,6 +380,7 @@ class CanPaymentService(
|
||||
useCanRepository.save(useCan)
|
||||
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||
@@ -428,6 +430,7 @@ class CanPaymentService(
|
||||
useCanRepository.save(useCan)
|
||||
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||
|
||||
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.can.payment
|
||||
|
||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import java.math.BigDecimal
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
@@ -25,7 +26,8 @@ data class Payment(
|
||||
var receiptId: String? = null
|
||||
var method: String? = null
|
||||
|
||||
var price: Double = 0.toDouble()
|
||||
@Column(precision = 10, scale = 4, nullable = false)
|
||||
var price: BigDecimal = 0.toBigDecimal()
|
||||
var locale: String? = null
|
||||
var orderId: String? = null
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ class ChatCharacter(
|
||||
// 캐릭터 한 줄 소개
|
||||
var description: String,
|
||||
|
||||
var languageCode: String? = null,
|
||||
|
||||
// AI 시스템 프롬프트
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var systemPrompt: String,
|
||||
|
||||
@@ -16,6 +16,7 @@ import javax.persistence.Table
|
||||
data class CharacterComment(
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var comment: String,
|
||||
var languageCode: String?,
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
|
||||
@@ -47,7 +47,7 @@ class CharacterCommentController(
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val id = service.addReply(characterId, commentId, member, request.comment)
|
||||
val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode)
|
||||
ApiResponse.ok(id)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
// Request DTOs
|
||||
data class CreateCharacterCommentRequest(
|
||||
val comment: String
|
||||
val comment: String,
|
||||
val languageCode: String? = null
|
||||
)
|
||||
|
||||
// Response DTOs
|
||||
@@ -20,7 +21,8 @@ data class CharacterCommentResponse(
|
||||
val memberNickname: String,
|
||||
val createdAt: Long,
|
||||
val replyCount: Int,
|
||||
val comment: String
|
||||
val comment: String,
|
||||
val languageCode: String?
|
||||
)
|
||||
|
||||
// 답글 Response 단건(목록 원소)
|
||||
@@ -35,7 +37,8 @@ data class CharacterReplyResponse(
|
||||
val memberProfileImage: String,
|
||||
val memberNickname: String,
|
||||
val createdAt: Long,
|
||||
val comment: String
|
||||
val comment: String,
|
||||
val languageCode: String?
|
||||
)
|
||||
|
||||
// 댓글의 답글 조회 Response 컨테이너
|
||||
|
||||
@@ -2,7 +2,10 @@ package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
@@ -12,7 +15,8 @@ import java.time.ZoneId
|
||||
class CharacterCommentService(
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val commentRepository: CharacterCommentRepository,
|
||||
private val reportRepository: CharacterCommentReportRepository
|
||||
private val reportRepository: CharacterCommentReportRepository,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
|
||||
private fun profileUrl(imageHost: String, profileImage: String?): String {
|
||||
@@ -40,7 +44,8 @@ class CharacterCommentService(
|
||||
memberNickname = member.nickname,
|
||||
createdAt = toEpochMilli(entity.createdAt),
|
||||
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
|
||||
comment = entity.comment
|
||||
comment = entity.comment,
|
||||
languageCode = entity.languageCode
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,25 +57,44 @@ class CharacterCommentService(
|
||||
memberProfileImage = profileUrl(imageHost, member.profileImage),
|
||||
memberNickname = member.nickname,
|
||||
createdAt = toEpochMilli(entity.createdAt),
|
||||
comment = entity.comment
|
||||
comment = entity.comment,
|
||||
languageCode = entity.languageCode
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addComment(characterId: Long, member: Member, text: String): Long {
|
||||
fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long {
|
||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val entity = CharacterComment(comment = text)
|
||||
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
||||
entity.chatCharacter = character
|
||||
entity.member = member
|
||||
commentRepository.save(entity)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = entity.id!!,
|
||||
query = text,
|
||||
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return entity.id!!
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): Long {
|
||||
fun addReply(
|
||||
characterId: Long,
|
||||
parentCommentId: Long,
|
||||
member: Member,
|
||||
text: String,
|
||||
languageCode: String? = null
|
||||
): Long {
|
||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
||||
@@ -78,11 +102,23 @@ class CharacterCommentService(
|
||||
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
|
||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val entity = CharacterComment(comment = text)
|
||||
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
||||
entity.chatCharacter = character
|
||||
entity.member = member
|
||||
entity.parent = parent
|
||||
commentRepository.save(entity)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = entity.id!!,
|
||||
query = text,
|
||||
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return entity.id!!
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.controller
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
|
||||
@@ -10,11 +11,21 @@ import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
|
||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.PageRequest
|
||||
@@ -32,7 +43,12 @@ class ChatCharacterController(
|
||||
private val bannerService: ChatCharacterBannerService,
|
||||
private val chatRoomService: ChatRoomService,
|
||||
private val characterCommentService: CharacterCommentService,
|
||||
private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService,
|
||||
private val curationQueryService: CharacterCurationQueryService,
|
||||
|
||||
private val translationService: PapagoTranslationService,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
|
||||
private val langContext: LangContext,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
@@ -65,6 +81,24 @@ class ChatCharacterController(
|
||||
}
|
||||
}
|
||||
|
||||
val characterIds = recentCharacters.map { it.characterId }
|
||||
val translatedRecentCharacters = if (characterIds.isNotEmpty()) {
|
||||
val translations = aiCharacterTranslationRepository
|
||||
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||
.associateBy { it.characterId }
|
||||
|
||||
recentCharacters.map { character ->
|
||||
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
||||
if (translatedName.isNullOrBlank()) {
|
||||
character
|
||||
} else {
|
||||
character.copy(name = translatedName)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
recentCharacters
|
||||
}
|
||||
|
||||
// 인기 캐릭터 조회
|
||||
val popularCharacters = service.getPopularCharacters()
|
||||
|
||||
@@ -74,6 +108,13 @@ class ChatCharacterController(
|
||||
size = 50
|
||||
).content
|
||||
|
||||
// 추천 캐릭터 조회
|
||||
// 최근 대화한 캐릭터를 제외한 랜덤 30개 조회
|
||||
// Controller에서는 호출만
|
||||
// 세부로직은 추후에 변경될 수 있으므로 Service에 별도로 생성
|
||||
val excludeIds = recentCharacters.map { it.characterId }
|
||||
val recommendCharacters = service.getRecommendCharacters(excludeIds, 30)
|
||||
|
||||
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
||||
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
||||
.map { agg ->
|
||||
@@ -85,7 +126,8 @@ class ChatCharacterController(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
||||
new = false
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -95,9 +137,10 @@ class ChatCharacterController(
|
||||
ApiResponse.ok(
|
||||
CharacterMainResponse(
|
||||
banners = banners,
|
||||
recentCharacters = recentCharacters,
|
||||
popularCharacters = popularCharacters,
|
||||
newCharacters = newCharacters,
|
||||
recentCharacters = translatedRecentCharacters,
|
||||
popularCharacters = getTranslatedAiCharacterList(popularCharacters),
|
||||
newCharacters = getTranslatedAiCharacterList(newCharacters),
|
||||
recommendCharacters = getTranslatedAiCharacterList(recommendCharacters),
|
||||
curationSections = curationSections
|
||||
)
|
||||
)
|
||||
@@ -139,6 +182,118 @@ class ChatCharacterController(
|
||||
)
|
||||
}
|
||||
|
||||
var translated: TranslatedAiCharacterDetail? = null
|
||||
if (langContext.lang.code != character.languageCode) {
|
||||
val existing = aiCharacterTranslationRepository
|
||||
.findByCharacterIdAndLocale(character.id!!, langContext.lang.code)
|
||||
|
||||
if (existing != null) {
|
||||
val payload = existing.renderedPayload
|
||||
translated = TranslatedAiCharacterDetail(
|
||||
name = payload.name,
|
||||
description = payload.description,
|
||||
gender = payload.gender,
|
||||
personality = TranslatedAiCharacterPersonality(
|
||||
trait = payload.personalityTrait,
|
||||
description = payload.personalityDescription
|
||||
).takeIf {
|
||||
(it.trait?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
|
||||
},
|
||||
background = TranslatedAiCharacterBackground(
|
||||
topic = payload.backgroundTopic,
|
||||
description = payload.backgroundDescription
|
||||
).takeIf {
|
||||
(it.topic?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
|
||||
},
|
||||
tags = payload.tags
|
||||
)
|
||||
} else {
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(character.name)
|
||||
texts.add(character.description)
|
||||
texts.add(character.gender ?: "")
|
||||
|
||||
val hasPersonality = personality != null
|
||||
if (hasPersonality) {
|
||||
texts.add(personality!!.trait)
|
||||
texts.add(personality.description)
|
||||
}
|
||||
|
||||
val hasBackground = background != null
|
||||
if (hasBackground) {
|
||||
texts.add(background!!.topic)
|
||||
texts.add(background.description)
|
||||
}
|
||||
|
||||
texts.add(tags)
|
||||
|
||||
val sourceLanguage = character.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = langContext.lang.code
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
|
||||
val translatedName = translatedTexts[index++]
|
||||
val translatedDescription = translatedTexts[index++]
|
||||
val translatedGender = translatedTexts[index++]
|
||||
|
||||
var translatedPersonality: TranslatedAiCharacterPersonality? = null
|
||||
if (hasPersonality) {
|
||||
translatedPersonality = TranslatedAiCharacterPersonality(
|
||||
trait = translatedTexts[index++],
|
||||
description = translatedTexts[index++]
|
||||
)
|
||||
}
|
||||
|
||||
var translatedBackground: TranslatedAiCharacterBackground? = null
|
||||
if (hasBackground) {
|
||||
translatedBackground = TranslatedAiCharacterBackground(
|
||||
topic = translatedTexts[index++],
|
||||
description = translatedTexts[index++]
|
||||
)
|
||||
}
|
||||
|
||||
val translatedTags = translatedTexts[index]
|
||||
|
||||
val payload = AiCharacterTranslationRenderedPayload(
|
||||
name = translatedName,
|
||||
description = translatedDescription,
|
||||
gender = translatedGender,
|
||||
personalityTrait = translatedPersonality?.trait ?: "",
|
||||
personalityDescription = translatedPersonality?.description ?: "",
|
||||
backgroundTopic = translatedBackground?.topic ?: "",
|
||||
backgroundDescription = translatedBackground?.description ?: "",
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val entity = AiCharacterTranslation(
|
||||
characterId = character.id!!,
|
||||
locale = langContext.lang.code,
|
||||
renderedPayload = payload
|
||||
)
|
||||
|
||||
aiCharacterTranslationRepository.save(entity)
|
||||
|
||||
translated = TranslatedAiCharacterDetail(
|
||||
name = translatedName,
|
||||
description = translatedDescription,
|
||||
gender = translatedGender,
|
||||
personality = translatedPersonality,
|
||||
background = translatedBackground,
|
||||
tags = translatedTags
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
|
||||
val others = service.getOtherCharactersBySharedTags(characterId, 10)
|
||||
.map { other ->
|
||||
@@ -153,6 +308,35 @@ class ChatCharacterController(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 캐릭터 이름, 태그 번역 데이터 조회
|
||||
*
|
||||
* languageCode != null
|
||||
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
|
||||
*
|
||||
* 한 번에 조회하고 characterId 매핑하여 others 캐릭터 이름과 tags 번역 데이터로 변경한다
|
||||
*/
|
||||
val characterIds = others.map { it.characterId }
|
||||
val translatedOthers = if (characterIds.isNotEmpty()) {
|
||||
val translations = aiCharacterTranslationRepository
|
||||
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||
.associateBy { it.characterId }
|
||||
|
||||
others.map { other ->
|
||||
val payload = translations[other.characterId]?.renderedPayload
|
||||
val translatedName = payload?.name
|
||||
val translatedTags = payload?.tags
|
||||
|
||||
if (translatedName.isNullOrBlank() || translatedTags.isNullOrBlank()) {
|
||||
other
|
||||
} else {
|
||||
other.copy(name = translatedName, tags = translatedTags)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
others
|
||||
}
|
||||
|
||||
// 최신 댓글 1개 조회
|
||||
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
|
||||
|
||||
@@ -162,6 +346,7 @@ class ChatCharacterController(
|
||||
characterId = character.id!!,
|
||||
name = character.name,
|
||||
description = character.description,
|
||||
languageCode = character.languageCode,
|
||||
mbti = character.mbti,
|
||||
gender = character.gender,
|
||||
age = character.age,
|
||||
@@ -172,9 +357,10 @@ class ChatCharacterController(
|
||||
originalTitle = character.originalTitle,
|
||||
originalLink = character.originalLink,
|
||||
characterType = character.characterType,
|
||||
others = others,
|
||||
others = translatedOthers,
|
||||
latestComment = latestComment,
|
||||
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
|
||||
totalComments = characterCommentService.getTotalCommentCount(character.id!!),
|
||||
translated = translated
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -185,12 +371,80 @@ class ChatCharacterController(
|
||||
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
|
||||
*/
|
||||
@GetMapping("/recent")
|
||||
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
|
||||
fun getRecentCharacters(
|
||||
@RequestParam("page", required = false) page: Int?
|
||||
): ApiResponse<RecentCharactersResponse> = run {
|
||||
val characterPage = service.getRecentCharactersPage(
|
||||
page = page ?: 0,
|
||||
size = 20
|
||||
)
|
||||
|
||||
val translatedCharacterPage = RecentCharactersResponse(
|
||||
totalCount = characterPage.totalCount,
|
||||
content = getTranslatedAiCharacterList(characterPage.content)
|
||||
)
|
||||
|
||||
ApiResponse.ok(translatedCharacterPage)
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 캐릭터 새로고침 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.getRecentCharactersPage(
|
||||
page = page ?: 0,
|
||||
size = 20
|
||||
getTranslatedAiCharacterList(
|
||||
service.getRecommendCharacters(
|
||||
recent,
|
||||
20
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||
*
|
||||
* 처리 절차:
|
||||
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
|
||||
* 번역 데이터를 한 번에 조회한다.
|
||||
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
|
||||
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
|
||||
*
|
||||
* @param aiCharacterList 번역 대상 캐릭터 목록
|
||||
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
|
||||
*/
|
||||
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
|
||||
val characterIds = aiCharacterList.map { it.characterId }
|
||||
|
||||
return if (characterIds.isNotEmpty()) {
|
||||
val translations = aiCharacterTranslationRepository
|
||||
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||
.associateBy { it.characterId }
|
||||
|
||||
aiCharacterList.map { character ->
|
||||
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
||||
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
|
||||
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
|
||||
character
|
||||
} else {
|
||||
character.copy(name = translatedName, description = translatedDesc)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
aiCharacterList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package kr.co.vividnext.sodalive.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
|
||||
|
||||
data class CharacterDetailResponse(
|
||||
val characterId: Long,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val languageCode: String?,
|
||||
val mbti: String?,
|
||||
val gender: String?,
|
||||
val age: Int?,
|
||||
@@ -19,7 +21,8 @@ data class CharacterDetailResponse(
|
||||
val characterType: CharacterType,
|
||||
val others: List<OtherCharacter>,
|
||||
val latestComment: CharacterCommentResponse?,
|
||||
val totalComments: Int
|
||||
val totalComments: Int,
|
||||
val translated: TranslatedAiCharacterDetail?
|
||||
)
|
||||
|
||||
data class OtherCharacter(
|
||||
|
||||
@@ -7,6 +7,7 @@ data class CharacterMainResponse(
|
||||
val recentCharacters: List<RecentCharacter>,
|
||||
val popularCharacters: List<Character>,
|
||||
val newCharacters: List<Character>,
|
||||
val recommendCharacters: List<Character>,
|
||||
val curationSections: List<CurationSection>
|
||||
)
|
||||
|
||||
@@ -20,7 +21,8 @@ data class Character(
|
||||
@JsonProperty("characterId") val characterId: Long,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("description") val description: String,
|
||||
@JsonProperty("imageUrl") val imageUrl: String
|
||||
@JsonProperty("imageUrl") val imageUrl: String,
|
||||
@JsonProperty("isNew") val new: Boolean
|
||||
)
|
||||
|
||||
data class RecentCharacter(
|
||||
|
||||
@@ -8,7 +8,9 @@ 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.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
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"
|
||||
)
|
||||
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 {
|
||||
|
||||
@@ -74,5 +74,29 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||
pageable: Pageable
|
||||
): 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>
|
||||
}
|
||||
|
||||
@@ -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.dto.Character
|
||||
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.ChatCharacterHobbyRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
@@ -34,10 +35,42 @@ class ChatCharacterService(
|
||||
private val hobbyRepository: ChatCharacterHobbyRepository,
|
||||
private val goalRepository: ChatCharacterGoalRepository,
|
||||
private val popularCharacterQuery: PopularCharacterQuery,
|
||||
private val imageRepository: CharacterImageRepository,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
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 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회
|
||||
* Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용
|
||||
@@ -51,12 +84,25 @@ class ChatCharacterService(
|
||||
val window = RankingWindowCalculator.now("popular-character")
|
||||
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
|
||||
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 {
|
||||
Character(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
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()
|
||||
)
|
||||
}
|
||||
val fallback = chatCharacterRepository.findByIsActiveTrue(
|
||||
val chars = chatCharacterRepository.findByIsActiveTrue(
|
||||
PageRequest.of(0, 20, Sort.by("createdAt").descending())
|
||||
)
|
||||
val content = fallback.content.map {
|
||||
).content
|
||||
|
||||
val recentSet = if (chars.isNotEmpty()) {
|
||||
imageRepository
|
||||
.findCharacterIdsWithRecentImages(
|
||||
chars.map { it.id!! },
|
||||
LocalDateTime.now().minusDays(3)
|
||||
)
|
||||
.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
val content = chars.map {
|
||||
Character(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
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(
|
||||
@@ -108,16 +167,29 @@ class ChatCharacterService(
|
||||
)
|
||||
}
|
||||
|
||||
val pageResult = chatCharacterRepository.findRecentSince(
|
||||
val chars = chatCharacterRepository.findRecentSince(
|
||||
since,
|
||||
PageRequest.of(safePage, safeSize)
|
||||
)
|
||||
val content = pageResult.content.map {
|
||||
).content
|
||||
|
||||
val recentSet = if (chars.isNotEmpty()) {
|
||||
imageRepository
|
||||
.findCharacterIdsWithRecentImages(
|
||||
chars.map { it.id!! },
|
||||
LocalDateTime.now().minusDays(3)
|
||||
)
|
||||
.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
val content = chars.map {
|
||||
Character(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
||||
new = recentSet.contains(it.id)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.translate
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.AttributeConverter
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Convert
|
||||
import javax.persistence.Converter
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(columnNames = ["characterId", "locale"])
|
||||
]
|
||||
)
|
||||
class AiCharacterTranslation(
|
||||
val characterId: Long,
|
||||
val locale: String,
|
||||
|
||||
@Column(columnDefinition = "json")
|
||||
@Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class)
|
||||
var renderedPayload: AiCharacterTranslationRenderedPayload
|
||||
) : BaseEntity()
|
||||
|
||||
data class AiCharacterTranslationRenderedPayload(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val gender: String,
|
||||
val personalityTrait: String,
|
||||
val personalityDescription: String,
|
||||
val backgroundTopic: String,
|
||||
val backgroundDescription: String,
|
||||
val tags: String
|
||||
)
|
||||
|
||||
@Converter(autoApply = false)
|
||||
class AiCharacterTranslationRenderedPayloadConverter :
|
||||
AttributeConverter<AiCharacterTranslationRenderedPayload, String> {
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: AiCharacterTranslationRenderedPayload?): String {
|
||||
if (attribute == null) return "{}"
|
||||
return objectMapper.writeValueAsString(attribute)
|
||||
}
|
||||
|
||||
override fun convertToEntityAttribute(dbData: String?): AiCharacterTranslationRenderedPayload {
|
||||
if (dbData.isNullOrBlank()) {
|
||||
return AiCharacterTranslationRenderedPayload(
|
||||
name = "",
|
||||
description = "",
|
||||
gender = "",
|
||||
personalityTrait = "",
|
||||
personalityDescription = "",
|
||||
backgroundTopic = "",
|
||||
backgroundDescription = "",
|
||||
tags = ""
|
||||
)
|
||||
}
|
||||
return objectMapper.readValue(dbData)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
}
|
||||
}
|
||||
|
||||
data class TranslatedAiCharacterDetail(
|
||||
val name: String?,
|
||||
val description: String?,
|
||||
val gender: String?,
|
||||
val personality: TranslatedAiCharacterPersonality?,
|
||||
val background: TranslatedAiCharacterBackground?,
|
||||
val tags: String?
|
||||
)
|
||||
|
||||
data class TranslatedAiCharacterPersonality(
|
||||
val trait: String?,
|
||||
val description: String?
|
||||
)
|
||||
|
||||
data class TranslatedAiCharacterBackground(
|
||||
val topic: String?,
|
||||
val description: String?
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.translate
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface AiCharacterTranslationRepository : JpaRepository<AiCharacterTranslation, Long> {
|
||||
fun findByCharacterIdAndLocale(characterId: Long, locale: String): AiCharacterTranslation?
|
||||
|
||||
fun findByCharacterIdInAndLocale(characterIds: List<Long>, locale: String): List<AiCharacterTranslation>
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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.image.CharacterImageRepository
|
||||
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.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.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
* 앱용 원작(오리지널 작품) 공개 API
|
||||
@@ -25,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController
|
||||
@RequestMapping("/api/chat/original")
|
||||
class OriginalWorkController(
|
||||
private val queryService: OriginalWorkQueryService,
|
||||
private val characterImageRepository: CharacterImageRepository,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
@@ -65,17 +70,34 @@ class OriginalWorkController(
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val ow = queryService.getOriginalWork(id)
|
||||
val pageRes = queryService.getActiveCharactersPage(id, page = 0, size = 20)
|
||||
val characters = pageRes.content.map {
|
||||
val path = it.imagePath ?: "profile/default-profile.png"
|
||||
Character(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/$path"
|
||||
)
|
||||
val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content
|
||||
|
||||
val recentSet = if (chars.isNotEmpty()) {
|
||||
characterImageRepository
|
||||
.findCharacterIdsWithRecentImages(
|
||||
chars.map { it.id!! },
|
||||
LocalDateTime.now().minusDays(3)
|
||||
)
|
||||
.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
val response = OriginalWorkDetailResponse.from(ow, imageHost, characters)
|
||||
ApiResponse.ok(response)
|
||||
|
||||
ApiResponse.ok(
|
||||
OriginalWorkDetailResponse.from(
|
||||
ow,
|
||||
imageHost,
|
||||
chars.map<ChatCharacter, Character> {
|
||||
val path = it.imagePath ?: "profile/default-profile.png"
|
||||
Character(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/$path",
|
||||
new = recentSet.contains(it.id)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class OriginalWorkQueryService(
|
||||
val safePage = if (page < 0) 0 else page
|
||||
val safeSize = when {
|
||||
size <= 0 -> 20
|
||||
size > 50 -> 50
|
||||
size > 20 -> 20
|
||||
else -> size
|
||||
}
|
||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||
|
||||
@@ -54,7 +54,7 @@ class ChatRoomQuotaController(
|
||||
): ApiResponse<PurchaseRoomQuotaResponse> = run {
|
||||
if (member == 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)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
@@ -79,7 +79,7 @@ class ChatRoomQuotaController(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId,
|
||||
addPaid = 40,
|
||||
addPaid = 12,
|
||||
container = req.container
|
||||
)
|
||||
|
||||
|
||||
@@ -126,13 +126,13 @@ class ChatRoomQuotaService(
|
||||
memberId: Long,
|
||||
chatRoomId: Long,
|
||||
characterId: Long,
|
||||
addPaid: Int = 40,
|
||||
addPaid: Int = 12,
|
||||
container: String
|
||||
): RoomQuotaStatus {
|
||||
// 요구사항: 30캔 결제 및 UseCan에 방/캐릭터 기록
|
||||
// 요구사항: 10캔 결제 및 UseCan에 방/캐릭터 기록
|
||||
canPaymentService.spendCan(
|
||||
memberId = memberId,
|
||||
needCan = 30,
|
||||
needCan = 10,
|
||||
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
|
||||
chatRoomId = chatRoomId,
|
||||
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.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.AccessDeniedException
|
||||
import org.springframework.security.authentication.BadCredentialsException
|
||||
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)
|
||||
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
|
||||
logger.error("API error", e)
|
||||
@@ -72,6 +64,7 @@ class SodaExceptionHandler {
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
fun handleException(e: Exception) = run {
|
||||
if (e is ResponseStatusException) throw e
|
||||
logger.error("API error", e)
|
||||
ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ class SecurityConfig(
|
||||
.antMatchers("/api/home").permitAll()
|
||||
.antMatchers("/api/home/latest-content").permitAll()
|
||||
.antMatchers("/api/home/day-of-week-series").permitAll()
|
||||
.antMatchers("/api/home/content-ranking").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/live").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/faq").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/faq/category").permitAll()
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package kr.co.vividnext.sodalive.configs
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.LangInterceptor
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
|
||||
@Configuration
|
||||
class WebConfig : WebMvcConfigurer {
|
||||
class WebConfig(
|
||||
private val langInterceptor: LangInterceptor
|
||||
) : WebMvcConfigurer {
|
||||
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||
registry.addInterceptor(langInterceptor).addPathPatterns("/**")
|
||||
}
|
||||
|
||||
override fun addCorsMappings(registry: CorsRegistry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(
|
||||
|
||||
@@ -23,7 +23,7 @@ enum class PurchaseOption {
|
||||
}
|
||||
|
||||
enum class SortType {
|
||||
NEWEST, PRICE_HIGH, PRICE_LOW
|
||||
NEWEST, PRICE_HIGH, PRICE_LOW, POPULARITY
|
||||
}
|
||||
|
||||
@Entity
|
||||
@@ -32,6 +32,7 @@ data class AudioContent(
|
||||
var title: String,
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var detail: String,
|
||||
var languageCode: String?,
|
||||
var playCount: Long = 0,
|
||||
var price: Int = 0,
|
||||
var releaseDate: LocalDateTime? = null,
|
||||
|
||||
@@ -237,6 +237,33 @@ class AudioContentController(private val service: AudioContentService) {
|
||||
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")
|
||||
fun replayLive(
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
|
||||
@@ -109,7 +109,6 @@ interface AudioContentQueryRepository {
|
||||
): Int
|
||||
|
||||
fun findByThemeFor2Weeks(
|
||||
isFree: Boolean = false,
|
||||
cloudfrontHost: String,
|
||||
memberId: Long,
|
||||
theme: List<String> = emptyList(),
|
||||
@@ -120,7 +119,6 @@ interface AudioContentQueryRepository {
|
||||
): List<GetAudioContentMainItem>
|
||||
|
||||
fun totalCountNewContentFor2Weeks(
|
||||
isFree: Boolean = false,
|
||||
theme: List<String> = emptyList(),
|
||||
memberId: Long,
|
||||
isAdult: Boolean,
|
||||
@@ -182,8 +180,11 @@ interface AudioContentQueryRepository {
|
||||
contentType: ContentType,
|
||||
offset: Long,
|
||||
limit: Long,
|
||||
sortType: SortType,
|
||||
isFree: Boolean,
|
||||
isAdult: Boolean
|
||||
isAdult: Boolean,
|
||||
orderByRandom: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false
|
||||
): List<AudioContentMainItem>
|
||||
|
||||
fun findContentByCurationId(
|
||||
@@ -193,6 +194,11 @@ interface AudioContentQueryRepository {
|
||||
offset: Long = 0,
|
||||
limit: Long = 20
|
||||
): List<GetAudioContentMainItem>
|
||||
|
||||
fun findLatestContentByCreatorId(
|
||||
creatorId: Long,
|
||||
isAdult: Boolean = false
|
||||
): AudioContent?
|
||||
}
|
||||
|
||||
@Repository
|
||||
@@ -236,6 +242,7 @@ class AudioContentQueryRepositoryImpl(
|
||||
SortType.NEWEST -> audioContent.releaseDate.desc()
|
||||
SortType.PRICE_HIGH -> audioContent.price.desc()
|
||||
SortType.PRICE_LOW -> audioContent.price.asc()
|
||||
SortType.POPULARITY -> audioContent.playCount.desc()
|
||||
}
|
||||
|
||||
var where = audioContent.member.id.eq(creatorId)
|
||||
@@ -457,6 +464,12 @@ class AudioContentQueryRepositoryImpl(
|
||||
audioContent.releaseDate.asc(),
|
||||
audioContent.id.asc()
|
||||
)
|
||||
|
||||
SortType.POPULARITY -> listOf(
|
||||
audioContent.playCount.desc(),
|
||||
audioContent.releaseDate.asc(),
|
||||
audioContent.id.asc()
|
||||
)
|
||||
}
|
||||
|
||||
var where = audioContent.isActive.isTrue
|
||||
@@ -688,7 +701,6 @@ class AudioContentQueryRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun totalCountNewContentFor2Weeks(
|
||||
isFree: Boolean,
|
||||
theme: List<String>,
|
||||
memberId: Long,
|
||||
isAdult: Boolean,
|
||||
@@ -725,10 +737,6 @@ class AudioContentQueryRepositoryImpl(
|
||||
where = where.and(audioContentTheme.theme.`in`(theme))
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
where = where.and(audioContent.price.loe(0))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(audioContent.id)
|
||||
.from(audioContent)
|
||||
@@ -740,7 +748,6 @@ class AudioContentQueryRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun findByThemeFor2Weeks(
|
||||
isFree: Boolean,
|
||||
cloudfrontHost: String,
|
||||
memberId: Long,
|
||||
theme: List<String>,
|
||||
@@ -780,10 +787,6 @@ class AudioContentQueryRepositoryImpl(
|
||||
where = where.and(audioContentTheme.theme.`in`(theme))
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
where = where.and(audioContent.price.loe(0))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAudioContentMainItem(
|
||||
@@ -1302,8 +1305,11 @@ class AudioContentQueryRepositoryImpl(
|
||||
contentType: ContentType,
|
||||
offset: Long,
|
||||
limit: Long,
|
||||
sortType: SortType,
|
||||
isFree: Boolean,
|
||||
isAdult: Boolean
|
||||
isAdult: Boolean,
|
||||
orderByRandom: Boolean,
|
||||
isPointAvailableOnly: Boolean
|
||||
): List<AudioContentMainItem> {
|
||||
var where = audioContent.isActive.isTrue
|
||||
.and(audioContent.duration.isNotNull)
|
||||
@@ -1338,6 +1344,31 @@ class AudioContentQueryRepositoryImpl(
|
||||
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
|
||||
.select(
|
||||
QAudioContentMainItem(
|
||||
@@ -1355,7 +1386,7 @@ class AudioContentQueryRepositoryImpl(
|
||||
.where(where)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.orderBy(audioContent.id.desc())
|
||||
.orderBy(orderBy)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -1416,4 +1447,26 @@ class AudioContentQueryRepositoryImpl(
|
||||
.limit(limit)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,19 @@ import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.pin.PinContent
|
||||
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
@@ -56,11 +65,16 @@ class AudioContentService(
|
||||
private val audioContentLikeRepository: AudioContentLikeRepository,
|
||||
private val pinContentRepository: PinContentRepository,
|
||||
|
||||
private val translationService: PapagoTranslationService,
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val audioContentCloudFront: AudioContentCloudFront,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
private val langContext: LangContext,
|
||||
|
||||
@Value("\${cloud.aws.s3.content-bucket}")
|
||||
private val audioContentBucket: String,
|
||||
|
||||
@@ -160,6 +174,13 @@ class AudioContentService(
|
||||
|
||||
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
|
||||
}
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = request.contentId,
|
||||
targetType = LanguageTranslationTargetType.CONTENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -238,6 +259,7 @@ class AudioContentService(
|
||||
val audioContent = AudioContent(
|
||||
title = request.title.trim(),
|
||||
detail = request.detail.trim(),
|
||||
languageCode = request.languageCode,
|
||||
price = if (request.price > 0) {
|
||||
request.price
|
||||
} else {
|
||||
@@ -331,6 +353,31 @@ class AudioContentService(
|
||||
|
||||
audioContent.content = contentPath
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (audioContent.languageCode.isNullOrBlank()) {
|
||||
val papagoQuery = listOf(
|
||||
request.title.trim(),
|
||||
request.detail.trim(),
|
||||
request.tags.trim()
|
||||
)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = audioContent.id!!,
|
||||
query = papagoQuery
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = audioContent.id!!,
|
||||
targetType = LanguageTranslationTargetType.CONTENT
|
||||
)
|
||||
)
|
||||
|
||||
return CreateAudioContentResponse(contentId = audioContent.id!!)
|
||||
}
|
||||
|
||||
@@ -386,7 +433,7 @@ class AudioContentService(
|
||||
|
||||
// Check if the time difference is greater than 30 seconds (30000 milliseconds)
|
||||
return date2.time - date1.time
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// Handle invalid time formats or parsing errors
|
||||
return 0
|
||||
}
|
||||
@@ -477,6 +524,7 @@ class AudioContentService(
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun getDetail(
|
||||
id: Long,
|
||||
member: Member,
|
||||
@@ -699,10 +747,89 @@ class AudioContentService(
|
||||
listOf()
|
||||
}
|
||||
|
||||
var translated: TranslatedContent? = null
|
||||
|
||||
/**
|
||||
* audioContent.languageCode != languageCode
|
||||
*
|
||||
* 번역 콘텐츠를 조회한다. - contentId, locale
|
||||
* 번역 콘텐츠가 있으면
|
||||
* TranslatedContent로 가공한다
|
||||
*
|
||||
* 번역 콘텐츠가 없으면
|
||||
* 파파고 API를 통해 번역한 후 저장한다.
|
||||
*
|
||||
* 번역 대상: title, detail, tags
|
||||
*
|
||||
* 파파고로 번역한 데이터를 TranslatedContent로 가공한다
|
||||
*/
|
||||
if (
|
||||
audioContent.languageCode != null &&
|
||||
audioContent.languageCode!!.isNotBlank() &&
|
||||
audioContent.languageCode != langContext.lang.code
|
||||
) {
|
||||
val existing = contentTranslationRepository
|
||||
.findByContentIdAndLocale(audioContent.id!!, langContext.lang.code)
|
||||
|
||||
if (existing != null) {
|
||||
val payload = existing.renderedPayload
|
||||
translated = TranslatedContent(
|
||||
title = payload.title,
|
||||
detail = payload.detail,
|
||||
tags = payload.tags
|
||||
)
|
||||
} else {
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(audioContent.title)
|
||||
texts.add(audioContent.detail)
|
||||
texts.add(tag)
|
||||
|
||||
val sourceLanguage = audioContent.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = langContext.lang.code
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
|
||||
val translatedTitle = translatedTexts[index++]
|
||||
val translatedDetail = translatedTexts[index++]
|
||||
val translatedTags = translatedTexts[index]
|
||||
|
||||
val payload = ContentTranslationPayload(
|
||||
title = translatedTitle,
|
||||
detail = translatedDetail,
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
contentTranslationRepository.save(
|
||||
ContentTranslation(
|
||||
contentId = audioContent.id!!,
|
||||
locale = langContext.lang.code,
|
||||
renderedPayload = payload
|
||||
)
|
||||
)
|
||||
|
||||
translated = TranslatedContent(
|
||||
title = translatedTitle,
|
||||
detail = translatedDetail,
|
||||
tags = translatedTags
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GetAudioContentDetailResponse(
|
||||
contentId = audioContent.id!!,
|
||||
title = audioContent.title,
|
||||
detail = contentDetail,
|
||||
languageCode = audioContent.languageCode,
|
||||
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
|
||||
contentUrl = audioContentUrl,
|
||||
themeStr = audioContent.theme!!.theme,
|
||||
@@ -745,7 +872,51 @@ class AudioContentService(
|
||||
previousContent = previousContent,
|
||||
nextContent = nextContent,
|
||||
buyerList = buyerList,
|
||||
isAvailableUsePoint = audioContent.isPointAvailable
|
||||
isAvailableUsePoint = audioContent.isPointAvailable,
|
||||
translated = translated
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -809,9 +980,27 @@ class AudioContentService(
|
||||
it
|
||||
}
|
||||
|
||||
val contentIds = items.map { it.contentId }
|
||||
val translatedContentList = if (contentIds.isNotEmpty()) {
|
||||
val translations = contentTranslationRepository
|
||||
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||
.associateBy { it.contentId }
|
||||
|
||||
items.map { item ->
|
||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items
|
||||
}
|
||||
|
||||
return GetAudioContentListResponse(
|
||||
totalCount = totalCount,
|
||||
items = items
|
||||
items = translatedContentList
|
||||
)
|
||||
}
|
||||
|
||||
@@ -945,16 +1134,40 @@ class AudioContentService(
|
||||
contentType: ContentType,
|
||||
offset: Long = 0,
|
||||
limit: Long = 20,
|
||||
sortType: SortType = SortType.NEWEST,
|
||||
isFree: Boolean = false,
|
||||
isAdult: Boolean = false
|
||||
isAdult: Boolean = false,
|
||||
orderByRandom: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false
|
||||
): List<AudioContentMainItem> {
|
||||
return repository.getLatestContentByTheme(
|
||||
val contentList = repository.getLatestContentByTheme(
|
||||
theme = theme,
|
||||
contentType = contentType,
|
||||
offset = offset,
|
||||
limit = limit,
|
||||
sortType = sortType,
|
||||
isFree = isFree,
|
||||
isAdult = isAdult
|
||||
isAdult = isAdult,
|
||||
orderByRandom = orderByRandom,
|
||||
isPointAvailableOnly = isPointAvailableOnly
|
||||
)
|
||||
|
||||
val contentIds = contentList.map { it.contentId }
|
||||
return if (contentIds.isNotEmpty()) {
|
||||
val translations = contentTranslationRepository
|
||||
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||
.associateBy { it.contentId }
|
||||
|
||||
contentList.map { item ->
|
||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contentList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ data class CreateAudioContentRequest(
|
||||
val isCommentAvailable: Boolean = false,
|
||||
val isFullDetailVisible: Boolean = true,
|
||||
val previewStartTime: String? = null,
|
||||
val previewEndTime: String? = null
|
||||
val previewEndTime: String? = null,
|
||||
val languageCode: String? = null
|
||||
)
|
||||
|
||||
@@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.content
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
|
||||
|
||||
data class GetAudioContentDetailResponse(
|
||||
val contentId: Long,
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val languageCode: String?,
|
||||
val coverImageUrl: String,
|
||||
val contentUrl: String,
|
||||
val themeStr: String,
|
||||
@@ -39,7 +41,8 @@ data class GetAudioContentDetailResponse(
|
||||
val previousContent: OtherContentResponse?,
|
||||
val nextContent: OtherContentResponse?,
|
||||
val buyerList: List<ContentBuyer>,
|
||||
val isAvailableUsePoint: Boolean
|
||||
val isAvailableUsePoint: Boolean,
|
||||
val translated: TranslatedContent?
|
||||
)
|
||||
|
||||
data class OtherContentResponse @QueryProjection constructor(
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.scheduling.annotation.Async
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.event.TransactionPhase
|
||||
import org.springframework.transaction.event.TransactionalEventListener
|
||||
import org.springframework.util.LinkedMultiValueMap
|
||||
import org.springframework.web.client.RestTemplate
|
||||
|
||||
/**
|
||||
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
||||
*/
|
||||
enum class LanguageDetectTargetType {
|
||||
CONTENT,
|
||||
COMMENT,
|
||||
CHARACTER,
|
||||
CHARACTER_COMMENT,
|
||||
CREATOR_CHEERS
|
||||
}
|
||||
|
||||
class LanguageDetectEvent(
|
||||
val id: Long,
|
||||
val query: String,
|
||||
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT
|
||||
)
|
||||
|
||||
data class PapagoLanguageDetectResponse(
|
||||
val langCode: String?
|
||||
)
|
||||
|
||||
@Component
|
||||
class LanguageDetectListener(
|
||||
private val audioContentRepository: AudioContentRepository,
|
||||
private val audioContentCommentRepository: AudioContentCommentRepository,
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val characterCommentRepository: CharacterCommentRepository,
|
||||
private val creatorCheersRepository: CreatorCheersRepository,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${cloud.naver.papago-client-id}")
|
||||
private val papagoClientId: String,
|
||||
|
||||
@Value("\${cloud.naver.papago-client-secret}")
|
||||
private val papagoClientSecret: String
|
||||
) {
|
||||
|
||||
private val log = LoggerFactory.getLogger(LanguageDetectListener::class.java)
|
||||
|
||||
private val restTemplate: RestTemplate = RestTemplate()
|
||||
|
||||
private val papagoDetectUrl = "https://papago.apigw.ntruss.com/langs/v1/dect"
|
||||
|
||||
@Async
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun detectLanguage(event: LanguageDetectEvent) {
|
||||
if (event.query.isBlank()) {
|
||||
log.debug("[PapagoLanguageDetect] query is blank. Skip language detection. event={}", event)
|
||||
return
|
||||
}
|
||||
|
||||
when (event.targetType) {
|
||||
LanguageDetectTargetType.CONTENT -> handleContentLanguageDetect(event)
|
||||
LanguageDetectTargetType.COMMENT -> handleCommentLanguageDetect(event)
|
||||
LanguageDetectTargetType.CHARACTER -> handleCharacterLanguageDetect(event)
|
||||
LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event)
|
||||
LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCharacterLanguageDetect(event: LanguageDetectEvent) {
|
||||
val characterId = event.id
|
||||
|
||||
val character = chatCharacterRepository.findById(characterId).orElse(null)
|
||||
if (character == null) {
|
||||
log.warn("[PapagoLanguageDetect] ChatCharacter not found. characterId={}", characterId)
|
||||
return
|
||||
}
|
||||
|
||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||
if (!character.languageCode.isNullOrBlank()) {
|
||||
log.debug(
|
||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. characterId={}, languageCode={}",
|
||||
characterId,
|
||||
character.languageCode
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, characterId) ?: return
|
||||
|
||||
character.languageCode = langCode
|
||||
chatCharacterRepository.save(character)
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = characterId,
|
||||
targetType = LanguageTranslationTargetType.CHARACTER
|
||||
)
|
||||
)
|
||||
|
||||
log.info(
|
||||
"[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}",
|
||||
characterId,
|
||||
langCode
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleContentLanguageDetect(event: LanguageDetectEvent) {
|
||||
val contentId = event.id
|
||||
|
||||
val audioContent = audioContentRepository.findById(contentId).orElse(null)
|
||||
if (audioContent == null) {
|
||||
log.warn("[PapagoLanguageDetect] AudioContent not found. contentId={}", contentId)
|
||||
return
|
||||
}
|
||||
|
||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||
if (!audioContent.languageCode.isNullOrBlank()) {
|
||||
log.debug(
|
||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}",
|
||||
contentId,
|
||||
audioContent.languageCode
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, contentId) ?: return
|
||||
|
||||
audioContent.languageCode = langCode
|
||||
|
||||
// REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다.
|
||||
audioContentRepository.save(audioContent)
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = contentId,
|
||||
targetType = LanguageTranslationTargetType.CONTENT
|
||||
)
|
||||
)
|
||||
|
||||
log.info(
|
||||
"[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}",
|
||||
contentId,
|
||||
langCode
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleCommentLanguageDetect(event: LanguageDetectEvent) {
|
||||
val commentId = event.id
|
||||
|
||||
val comment = audioContentCommentRepository.findById(commentId).orElse(null)
|
||||
if (comment == null) {
|
||||
log.warn("[PapagoLanguageDetect] AudioContentComment not found. commentId={}", commentId)
|
||||
return
|
||||
}
|
||||
|
||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||
if (!comment.languageCode.isNullOrBlank()) {
|
||||
log.debug(
|
||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. commentId={}, languageCode={}",
|
||||
commentId,
|
||||
comment.languageCode
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
|
||||
|
||||
comment.languageCode = langCode
|
||||
audioContentCommentRepository.save(comment)
|
||||
|
||||
log.info(
|
||||
"[PapagoLanguageDetect] languageCode updated from Papago. commentId={}, langCode={}",
|
||||
commentId,
|
||||
langCode
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleCharacterCommentLanguageDetect(event: LanguageDetectEvent) {
|
||||
val commentId = event.id
|
||||
|
||||
val comment = characterCommentRepository.findById(commentId).orElse(null)
|
||||
if (comment == null) {
|
||||
log.warn("[PapagoLanguageDetect] CharacterComment not found. commentId={}", commentId)
|
||||
return
|
||||
}
|
||||
|
||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||
if (!comment.languageCode.isNullOrBlank()) {
|
||||
log.debug(
|
||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. " +
|
||||
"characterCommentId={}, languageCode={}",
|
||||
commentId,
|
||||
comment.languageCode
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
|
||||
|
||||
comment.languageCode = langCode
|
||||
characterCommentRepository.save(comment)
|
||||
|
||||
log.info(
|
||||
"[PapagoLanguageDetect] languageCode updated from Papago. characterCommentId={}, langCode={}",
|
||||
commentId,
|
||||
langCode
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleCreatorCheersLanguageDetect(event: LanguageDetectEvent) {
|
||||
val cheersId = event.id
|
||||
|
||||
val cheers = creatorCheersRepository.findById(cheersId).orElse(null)
|
||||
if (cheers == null) {
|
||||
log.warn("[PapagoLanguageDetect] CreatorCheers not found. cheersId={}", cheersId)
|
||||
return
|
||||
}
|
||||
|
||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||
if (!cheers.languageCode.isNullOrBlank()) {
|
||||
log.debug(
|
||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. cheersId={}, languageCode={}",
|
||||
cheersId,
|
||||
cheers.languageCode
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, cheersId) ?: return
|
||||
|
||||
cheers.languageCode = langCode
|
||||
creatorCheersRepository.save(cheers)
|
||||
|
||||
log.info(
|
||||
"[PapagoLanguageDetect] languageCode updated from Papago. cheersId={}, langCode={}",
|
||||
cheersId,
|
||||
langCode
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
|
||||
return try {
|
||||
val headers = HttpHeaders().apply {
|
||||
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
||||
set("X-NCP-APIGW-API-KEY-ID", papagoClientId)
|
||||
set("X-NCP-APIGW-API-KEY", papagoClientSecret)
|
||||
}
|
||||
|
||||
val body = LinkedMultiValueMap<String, String>().apply {
|
||||
// 파파고 스펙에 따라 query 파라미터에 텍스트를 공백으로 구분하여 전달
|
||||
add("query", query)
|
||||
}
|
||||
|
||||
val requestEntity = HttpEntity(body, headers)
|
||||
|
||||
val response = restTemplate.postForEntity(
|
||||
papagoDetectUrl,
|
||||
requestEntity,
|
||||
PapagoLanguageDetectResponse::class.java
|
||||
)
|
||||
|
||||
if (!response.statusCode.is2xxSuccessful) {
|
||||
log.warn(
|
||||
"[PapagoLanguageDetect] Non-success status from Papago. status={}, targetId={}",
|
||||
response.statusCode,
|
||||
targetIdForLog
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
val langCode = response.body?.langCode?.takeIf { it.isNotBlank() }
|
||||
if (langCode == null) {
|
||||
log.warn(
|
||||
"[PapagoLanguageDetect] langCode is null or blank in Papago response. targetId={}",
|
||||
targetIdForLog
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
langCode
|
||||
} catch (ex: Exception) {
|
||||
// 언어 감지는 부가 기능이므로, 실패 시 예외를 전파하지 않고 로그만 남긴다.
|
||||
log.error(
|
||||
"[PapagoLanguageDetect] Failed to detect language via Papago. targetId={}",
|
||||
targetIdForLog,
|
||||
ex
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import javax.persistence.Table
|
||||
data class AudioContentComment(
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var comment: String,
|
||||
var languageCode: String?,
|
||||
@Column(nullable = true)
|
||||
var donationCan: Int? = null,
|
||||
val isSecret: Boolean = false,
|
||||
|
||||
@@ -32,7 +32,8 @@ class AudioContentCommentController(
|
||||
audioContentId = request.contentId,
|
||||
parentId = request.parentId,
|
||||
isSecret = request.isSecret,
|
||||
member = member
|
||||
member = member,
|
||||
languageCode = request.languageCode
|
||||
)
|
||||
|
||||
try {
|
||||
|
||||
@@ -85,6 +85,7 @@ class AudioContentCommentQueryRepositoryImpl(
|
||||
audioContentComment.member.nickname,
|
||||
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
|
||||
audioContentComment.comment,
|
||||
audioContentComment.languageCode,
|
||||
audioContentComment.isSecret,
|
||||
audioContentComment.donationCan.coalesce(0),
|
||||
formattedDate,
|
||||
@@ -166,6 +167,7 @@ class AudioContentCommentQueryRepositoryImpl(
|
||||
audioContentComment.member.nickname,
|
||||
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
|
||||
audioContentComment.comment,
|
||||
audioContentComment.languageCode,
|
||||
audioContentComment.isSecret,
|
||||
audioContentComment.donationCan.coalesce(0),
|
||||
formattedDate,
|
||||
|
||||
@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.content.comment
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.content.order.OrderRepository
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
@@ -32,7 +34,8 @@ class AudioContentCommentService(
|
||||
comment: String,
|
||||
audioContentId: Long,
|
||||
parentId: Long? = null,
|
||||
isSecret: Boolean = false
|
||||
isSecret: Boolean = false,
|
||||
languageCode: String?
|
||||
): Long {
|
||||
val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId)
|
||||
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
||||
@@ -50,7 +53,7 @@ class AudioContentCommentService(
|
||||
throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.")
|
||||
}
|
||||
|
||||
val audioContentComment = AudioContentComment(comment = comment, isSecret = isSecret)
|
||||
val audioContentComment = AudioContentComment(comment = comment, languageCode = languageCode, isSecret = isSecret)
|
||||
audioContentComment.audioContent = audioContent
|
||||
audioContentComment.member = member
|
||||
|
||||
@@ -85,6 +88,17 @@ class AudioContentCommentService(
|
||||
)
|
||||
)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = savedContentComment.id!!,
|
||||
query = comment,
|
||||
targetType = LanguageDetectTargetType.COMMENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return savedContentComment.id!!
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ data class GetAudioContentCommentListItem @QueryProjection constructor(
|
||||
val nickname: String,
|
||||
val profileUrl: String,
|
||||
val comment: String,
|
||||
val languageCode: String?,
|
||||
val isSecret: Boolean,
|
||||
val donationCan: Int,
|
||||
val date: String,
|
||||
|
||||
@@ -4,5 +4,6 @@ data class RegisterCommentRequest(
|
||||
val comment: String,
|
||||
val contentId: Long,
|
||||
val parentId: Long?,
|
||||
val isSecret: Boolean = false
|
||||
val isSecret: Boolean = false,
|
||||
val languageCode: String? = null
|
||||
)
|
||||
|
||||
@@ -4,5 +4,6 @@ data class AudioContentDonationRequest(
|
||||
val contentId: Long,
|
||||
val donationCan: Int,
|
||||
val comment: String,
|
||||
val container: String
|
||||
val container: String,
|
||||
val languageCode: String? = null
|
||||
)
|
||||
|
||||
@@ -4,9 +4,12 @@ import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@@ -14,7 +17,8 @@ import org.springframework.transaction.annotation.Transactional
|
||||
class AudioContentDonationService(
|
||||
private val canPaymentService: CanPaymentService,
|
||||
private val queryRepository: AudioContentRepository,
|
||||
private val commentRepository: AudioContentCommentRepository
|
||||
private val commentRepository: AudioContentCommentRepository,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
@Transactional
|
||||
fun donation(request: AudioContentDonationRequest, member: Member) {
|
||||
@@ -34,10 +38,23 @@ class AudioContentDonationService(
|
||||
|
||||
val audioContentComment = AudioContentComment(
|
||||
comment = request.comment,
|
||||
languageCode = request.languageCode,
|
||||
donationCan = request.donationCan
|
||||
)
|
||||
audioContentComment.audioContent = audioContent
|
||||
audioContentComment.member = member
|
||||
commentRepository.save(audioContentComment)
|
||||
|
||||
val savedComment = commentRepository.save(audioContentComment)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (request.languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = savedComment.id!!,
|
||||
query = request.comment,
|
||||
targetType = LanguageDetectTargetType.COMMENT
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,6 @@ class AudioContentMainController(
|
||||
|
||||
@GetMapping("/new/all")
|
||||
fun getNewContentAllByTheme(
|
||||
@RequestParam("isFree", required = false) isFree: Boolean? = null,
|
||||
@RequestParam("theme") theme: String,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@@ -110,7 +109,6 @@ class AudioContentMainController(
|
||||
|
||||
ApiResponse.ok(
|
||||
service.getNewContentFor2WeeksByTheme(
|
||||
isFree = isFree ?: false,
|
||||
theme = theme,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
|
||||
@@ -6,7 +6,9 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
|
||||
import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.event.EventItem
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
@@ -21,6 +23,10 @@ class AudioContentMainService(
|
||||
private val blockMemberRepository: BlockMemberRepository,
|
||||
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
||||
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
|
||||
private val langContext: LangContext,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
@@ -28,16 +34,6 @@ class AudioContentMainService(
|
||||
@Cacheable(cacheNames = ["default"], key = "'themeList:' + ':' + #isAdult")
|
||||
fun getThemeList(isAdult: Boolean, contentType: ContentType): List<String> {
|
||||
return audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
|
||||
.filter {
|
||||
it != "모닝콜" &&
|
||||
it != "알람" &&
|
||||
it != "슬립콜" &&
|
||||
it != "다시듣기" &&
|
||||
it != "ASMR" &&
|
||||
it != "릴레이" &&
|
||||
it != "챌린지" &&
|
||||
it != "자기소개"
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -64,7 +60,6 @@ class AudioContentMainService(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getNewContentFor2WeeksByTheme(
|
||||
isFree: Boolean,
|
||||
theme: String,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
@@ -75,31 +70,19 @@ class AudioContentMainService(
|
||||
val themeList = if (theme.isBlank()) {
|
||||
audioContentThemeRepository.getActiveThemeOfContent(
|
||||
isAdult = isAdult,
|
||||
isFree = isFree,
|
||||
contentType = contentType
|
||||
).filter {
|
||||
it != "모닝콜" &&
|
||||
it != "알람" &&
|
||||
it != "슬립콜" &&
|
||||
it != "다시듣기" &&
|
||||
it != "ASMR" &&
|
||||
it != "릴레이" &&
|
||||
it != "챌린지" &&
|
||||
it != "자기소개"
|
||||
}
|
||||
)
|
||||
} else {
|
||||
listOf(theme)
|
||||
}
|
||||
|
||||
val totalCount = repository.totalCountNewContentFor2Weeks(
|
||||
isFree,
|
||||
themeList,
|
||||
memberId = member.id!!,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
)
|
||||
val items = repository.findByThemeFor2Weeks(
|
||||
isFree,
|
||||
val contentList = repository.findByThemeFor2Weeks(
|
||||
cloudfrontHost = imageHost,
|
||||
memberId = member.id!!,
|
||||
theme = themeList,
|
||||
@@ -110,7 +93,25 @@ class AudioContentMainService(
|
||||
)
|
||||
.filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) }
|
||||
|
||||
return GetNewContentAllResponse(totalCount, items)
|
||||
val contentIds = contentList.map { it.contentId }
|
||||
val translatedContentList = if (contentIds.isNotEmpty()) {
|
||||
val translations = contentTranslationRepository
|
||||
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||
.associateBy { it.contentId }
|
||||
|
||||
contentList.map { item ->
|
||||
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contentList
|
||||
}
|
||||
|
||||
return GetNewContentAllResponse(totalCount, translatedContentList)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -58,6 +58,7 @@ class AudioContentCurationQueryRepository(private val queryFactory: JPAQueryFact
|
||||
SortType.NEWEST -> audioContent.createdAt.desc()
|
||||
SortType.PRICE_HIGH -> audioContent.price.desc()
|
||||
SortType.PRICE_LOW -> audioContent.price.asc()
|
||||
SortType.POPULARITY -> audioContent.playCount.desc()
|
||||
}
|
||||
|
||||
var where = audioContent.isActive.isTrue
|
||||
|
||||
@@ -187,6 +187,7 @@ class OrderQueryRepositoryImpl(
|
||||
return queryFactory.select(order.id)
|
||||
.from(order)
|
||||
.where(where)
|
||||
.distinct()
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ import org.springframework.web.bind.annotation.RestController
|
||||
class ContentSeriesController(private val service: ContentSeriesService) {
|
||||
@GetMapping
|
||||
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("contentType", required = false) contentType: ContentType? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@@ -29,6 +31,8 @@ class ContentSeriesController(private val service: ContentSeriesService) {
|
||||
ApiResponse.ok(
|
||||
service.getSeriesList(
|
||||
creatorId = creatorId,
|
||||
isOriginal = isOriginal ?: false,
|
||||
isCompleted = isCompleted ?: false,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
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.Series
|
||||
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.member.MemberRole
|
||||
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 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(
|
||||
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,
|
||||
contentType: ContentType,
|
||||
offset: Long,
|
||||
@@ -40,6 +66,7 @@ interface ContentSeriesQueryRepository {
|
||||
fun getOriginalAudioDramaList(
|
||||
isAdult: Boolean,
|
||||
contentType: ContentType,
|
||||
orderByRandom: Boolean = false,
|
||||
offset: Long = 0,
|
||||
limit: Long = 20
|
||||
): List<Series>
|
||||
@@ -59,9 +86,26 @@ interface ContentSeriesQueryRepository {
|
||||
class ContentSeriesQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : ContentSeriesQueryRepository {
|
||||
override fun getSeriesTotalCount(creatorId: Long, isAuth: Boolean, contentType: ContentType): Int {
|
||||
var where = series.member.id.eq(creatorId)
|
||||
.and(series.isActive.isTrue)
|
||||
override fun getSeriesTotalCount(
|
||||
creatorId: Long?,
|
||||
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) {
|
||||
where = where.and(series.isAdult.isFalse)
|
||||
@@ -92,14 +136,74 @@ class ContentSeriesQueryRepositoryImpl(
|
||||
|
||||
override fun getSeriesList(
|
||||
imageHost: String,
|
||||
creatorId: Long,
|
||||
creatorId: Long?,
|
||||
isAuth: Boolean,
|
||||
contentType: ContentType,
|
||||
isOriginal: Boolean,
|
||||
isCompleted: Boolean,
|
||||
orderByRandom: Boolean,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<Series> {
|
||||
var where = series.member.id.eq(creatorId)
|
||||
.and(series.isActive.isTrue)
|
||||
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) {
|
||||
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) {
|
||||
where = where.and(series.isAdult.isFalse)
|
||||
@@ -120,10 +224,57 @@ class ContentSeriesQueryRepositoryImpl(
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.selectFrom(series)
|
||||
.select(series.id)
|
||||
.from(seriesContent)
|
||||
.innerJoin(seriesContent.series, series)
|
||||
.innerJoin(seriesContent.content, audioContent)
|
||||
.innerJoin(series.member, member)
|
||||
.innerJoin(series.genre, seriesGenre)
|
||||
.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)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
@@ -216,6 +367,7 @@ class ContentSeriesQueryRepositoryImpl(
|
||||
override fun getOriginalAudioDramaList(
|
||||
isAdult: Boolean,
|
||||
contentType: ContentType,
|
||||
orderByRandom: Boolean,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<Series> {
|
||||
@@ -244,7 +396,13 @@ class ContentSeriesQueryRepositoryImpl(
|
||||
.selectFrom(series)
|
||||
.innerJoin(series.member, member)
|
||||
.where(where)
|
||||
.orderBy(series.id.desc())
|
||||
.orderBy(
|
||||
if (orderByRandom) {
|
||||
Expressions.numberTemplate(Double::class.java, "function('rand')").asc()
|
||||
} else {
|
||||
series.id.desc()
|
||||
}
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
|
||||
@@ -37,10 +37,11 @@ class ContentSeriesService(
|
||||
fun getOriginalAudioDramaList(
|
||||
isAdult: Boolean,
|
||||
contentType: ContentType,
|
||||
orderByRandom: Boolean = false,
|
||||
offset: Long = 0,
|
||||
limit: Long = 20
|
||||
): 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)
|
||||
}
|
||||
|
||||
@@ -49,25 +50,63 @@ class ContentSeriesService(
|
||||
}
|
||||
|
||||
fun getSeriesList(
|
||||
creatorId: Long,
|
||||
creatorId: Long?,
|
||||
isOriginal: Boolean = false,
|
||||
isCompleted: Boolean = false,
|
||||
orderByRandom: Boolean = false,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member,
|
||||
offset: Long = 0,
|
||||
limit: Long = 10
|
||||
limit: Long = 20
|
||||
): GetSeriesListResponse {
|
||||
val isAuth = member.auth != null && isAdultContentVisible
|
||||
|
||||
val totalCount = repository.getSeriesTotalCount(
|
||||
creatorId = creatorId,
|
||||
isAuth = isAuth,
|
||||
contentType = contentType
|
||||
contentType = contentType,
|
||||
isOriginal = isOriginal,
|
||||
isCompleted = isCompleted
|
||||
)
|
||||
|
||||
val rawItems = repository.getSeriesList(
|
||||
imageHost = coverImageHost,
|
||||
creatorId = creatorId,
|
||||
isAuth = isAuth,
|
||||
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,
|
||||
limit = limit
|
||||
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
|
||||
@@ -201,7 +240,7 @@ class ContentSeriesService(
|
||||
val seriesList = repository.getRecommendSeriesList(
|
||||
isAuth = isAuth,
|
||||
contentType = contentType,
|
||||
limit = 10
|
||||
limit = 20
|
||||
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
|
||||
|
||||
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?
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import javax.persistence.Table
|
||||
|
||||
@Entity
|
||||
@Table(name = "content_theme")
|
||||
data class AudioContentTheme(
|
||||
class AudioContentTheme(
|
||||
@Column(nullable = false)
|
||||
var theme: String,
|
||||
@Column(nullable = false)
|
||||
|
||||
@@ -27,6 +27,26 @@ class AudioContentThemeController(private val service: AudioContentThemeService)
|
||||
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")
|
||||
fun getContentByTheme(
|
||||
@PathVariable id: Long,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.content.theme
|
||||
|
||||
import com.querydsl.core.types.dsl.CaseBuilder
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
@@ -14,6 +15,10 @@ class AudioContentThemeQueryRepository(
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
data class ThemeIdAndName(
|
||||
val id: Long,
|
||||
val theme: String
|
||||
)
|
||||
fun getActiveThemes(): List<GetAudioContentThemeResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
@@ -32,6 +37,7 @@ class AudioContentThemeQueryRepository(
|
||||
fun getActiveThemeOfContent(
|
||||
isAdult: Boolean = false,
|
||||
isFree: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false,
|
||||
contentType: ContentType
|
||||
): List<String> {
|
||||
var where = audioContent.isActive.isTrue
|
||||
@@ -59,15 +65,94 @@ class AudioContentThemeQueryRepository(
|
||||
where = where.and(audioContent.price.loe(0))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
if (isPointAvailableOnly) {
|
||||
where = where.and(audioContent.isPointAvailable.isTrue)
|
||||
}
|
||||
|
||||
val query = queryFactory
|
||||
.select(audioContentTheme.theme)
|
||||
.from(audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.innerJoin(audioContent.theme, audioContentTheme)
|
||||
.where(where)
|
||||
.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 getActiveThemeWithIdsOfContent(
|
||||
isAdult: Boolean = false,
|
||||
isFree: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false,
|
||||
contentType: ContentType
|
||||
): List<ThemeIdAndName> {
|
||||
var where = audioContent.isActive.isTrue
|
||||
.and(audioContentTheme.isActive.isTrue)
|
||||
|
||||
if (!isAdult) {
|
||||
where = where.and(audioContent.isAdult.isFalse)
|
||||
} else {
|
||||
if (contentType != ContentType.ALL) {
|
||||
where = where.and(
|
||||
audioContent.member.isNull.or(
|
||||
audioContent.member.auth.gender.eq(
|
||||
if (contentType == ContentType.MALE) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
where = where.and(audioContent.price.loe(0))
|
||||
}
|
||||
|
||||
if (isPointAvailableOnly) {
|
||||
where = where.and(audioContent.isPointAvailable.isTrue)
|
||||
}
|
||||
|
||||
val query = queryFactory
|
||||
.select(audioContentTheme.id, audioContentTheme.theme)
|
||||
.from(audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.innerJoin(audioContent.theme, audioContentTheme)
|
||||
.where(where)
|
||||
.groupBy(audioContentTheme.id)
|
||||
|
||||
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().map { tuple ->
|
||||
ThemeIdAndName(
|
||||
id = tuple.get(audioContentTheme.id)!!,
|
||||
theme = tuple.get(audioContentTheme.theme)!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun findThemeByIdAndActive(id: Long): AudioContentTheme? {
|
||||
|
||||
@@ -5,6 +5,12 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.SortType
|
||||
import kr.co.vividnext.sodalive.content.theme.content.GetContentByThemeResponse
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
@@ -12,24 +18,94 @@ import org.springframework.transaction.annotation.Transactional
|
||||
@Service
|
||||
class AudioContentThemeService(
|
||||
private val queryRepository: AudioContentThemeQueryRepository,
|
||||
private val contentRepository: AudioContentRepository
|
||||
private val contentRepository: AudioContentRepository,
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
|
||||
private val papagoTranslationService: PapagoTranslationService,
|
||||
private val langContext: LangContext
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getThemes(): List<GetAudioContentThemeResponse> {
|
||||
return queryRepository.getActiveThemes()
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Transactional
|
||||
fun getActiveThemeOfContent(
|
||||
isAdult: Boolean = false,
|
||||
isFree: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false,
|
||||
contentType: ContentType
|
||||
): List<String> {
|
||||
return queryRepository.getActiveThemeOfContent(
|
||||
val themesWithIds = queryRepository.getActiveThemeWithIdsOfContent(
|
||||
isAdult = isAdult,
|
||||
isFree = isFree,
|
||||
isPointAvailableOnly = isPointAvailableOnly,
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
/**
|
||||
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
|
||||
* 번역이 없으면 번역 API 호출 후 저장하고 반환
|
||||
*/
|
||||
val currentLang = langContext.lang
|
||||
if (currentLang == Lang.EN || currentLang == Lang.JA) {
|
||||
val targetLocale = currentLang.code
|
||||
// 1) 기존 번역을 한 번에 조회
|
||||
val ids = themesWithIds.map { it.id }
|
||||
val existingTranslations = if (ids.isNotEmpty()) {
|
||||
contentThemeTranslationRepository.findByContentThemeIdInAndLocale(ids, targetLocale)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val existingMap = existingTranslations.associateBy { it.contentThemeId }
|
||||
|
||||
// 2) 미번역 항목만 수집하여 한 번에 번역 요청
|
||||
val untranslatedPairs = themesWithIds.filter { existingMap[it.id] == null }
|
||||
|
||||
if (untranslatedPairs.isNotEmpty()) {
|
||||
val texts = untranslatedPairs.map { it.theme }
|
||||
|
||||
val response = papagoTranslationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = targetLocale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
val entitiesToSave = mutableListOf<ContentThemeTranslation>()
|
||||
|
||||
// translatedTexts 크기가 다르면 안전하게 원문으로 대체
|
||||
untranslatedPairs.forEachIndexed { index, pair ->
|
||||
val translated = translatedTexts.getOrNull(index) ?: pair.theme
|
||||
entitiesToSave.add(
|
||||
ContentThemeTranslation(
|
||||
contentThemeId = pair.id,
|
||||
locale = targetLocale,
|
||||
theme = translated
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (entitiesToSave.isNotEmpty()) {
|
||||
contentThemeTranslationRepository.saveAll(entitiesToSave)
|
||||
}
|
||||
|
||||
// 저장 후 맵을 갱신
|
||||
entitiesToSave.forEach { entity ->
|
||||
(existingMap as MutableMap)[entity.contentThemeId] = entity
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 원래 순서대로 결과 조립 (번역 없으면 원문 fallback)
|
||||
return themesWithIds.map { pair ->
|
||||
existingMap[pair.id]?.theme ?: pair.theme
|
||||
}
|
||||
}
|
||||
|
||||
return themesWithIds.map { it.theme }
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package kr.co.vividnext.sodalive.content.theme.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Entity
|
||||
|
||||
@Entity
|
||||
class ContentThemeTranslation(
|
||||
val contentThemeId: Long,
|
||||
val locale: String,
|
||||
var theme: String
|
||||
) : BaseEntity()
|
||||
@@ -0,0 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.content.theme.translation
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface ContentThemeTranslationRepository : JpaRepository<ContentThemeTranslation, Long> {
|
||||
fun findByContentThemeIdAndLocale(contentThemeId: Long, locale: String): ContentThemeTranslation?
|
||||
|
||||
fun findByContentThemeIdInAndLocale(contentThemeIds: Collection<Long>, locale: String): List<ContentThemeTranslation>
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package kr.co.vividnext.sodalive.content.translation
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.AttributeConverter
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Convert
|
||||
import javax.persistence.Converter
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(columnNames = ["contentId", "locale"])
|
||||
]
|
||||
)
|
||||
class ContentTranslation(
|
||||
val contentId: Long,
|
||||
val locale: String,
|
||||
|
||||
@Column(columnDefinition = "json")
|
||||
@Convert(converter = ContentTranslationPayloadConverter::class)
|
||||
var renderedPayload: ContentTranslationPayload
|
||||
) : BaseEntity()
|
||||
|
||||
data class ContentTranslationPayload(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val tags: String
|
||||
)
|
||||
|
||||
@Converter(autoApply = false)
|
||||
class ContentTranslationPayloadConverter : AttributeConverter<ContentTranslationPayload, String> {
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: ContentTranslationPayload?): String {
|
||||
if (attribute == null) return "{}"
|
||||
return objectMapper.writeValueAsString(attribute)
|
||||
}
|
||||
|
||||
override fun convertToEntityAttribute(dbData: String?): ContentTranslationPayload {
|
||||
if (dbData.isNullOrBlank()) {
|
||||
return ContentTranslationPayload(
|
||||
title = "",
|
||||
detail = "",
|
||||
tags = ""
|
||||
)
|
||||
}
|
||||
return objectMapper.readValue(dbData)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
}
|
||||
}
|
||||
|
||||
data class TranslatedContent(
|
||||
val title: String?,
|
||||
val detail: String?,
|
||||
val tags: String?
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user