Compare commits
347 Commits
test
...
6da86e12bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 6da86e12bd | |||
| 9049022a74 | |||
| 7b6f3a7a5f | |||
| 53e9678efa | |||
| e4f547fa92 | |||
| b69756ef81 | |||
| 1a3a9149a2 | |||
| ce120a6d5d | |||
| 08b5fd23ab | |||
| eb18e2d009 | |||
| a27852ed44 | |||
| c7925c1706 | |||
| be59bd7e89 | |||
| 51ce143fc2 | |||
| 89eb11f808 | |||
| 30d89987a4 | |||
| 7959d3e5ed | |||
| 1e29573ef7 | |||
| cc2f533dc6 | |||
| 32b0c19f9d | |||
| 9af2d768e8 | |||
| 5677824cde | |||
| e8f1bc09f9 | |||
| d1a936d55b | |||
| dc97eaa835 | |||
| dcbe57806c | |||
| b14438cc15 | |||
| b27d3bd5c6 | |||
| 03ebc9cfe9 | |||
| 24841b9850 | |||
| d35a3d1a8c | |||
| 60c4e0b528 | |||
| 84f33d1bc2 | |||
| c4e1709b99 | |||
| e7a5fd5819 | |||
| 4bde03643c | |||
| 1bc52b56af | |||
| 9c33fd93f7 | |||
| 3c087bc275 | |||
| 8ad13c289e | |||
| 7577f48a09 | |||
| 0251906964 | |||
| 2723a5f134 | |||
| c3c60605fd | |||
| 238f704b22 | |||
| 5639d8ac8e | |||
| 9aac591591 | |||
| ffa8e5aebb | |||
| cbbfe014cc | |||
| 83028f7817 | |||
| 70d1795557 | |||
| 8c6c681424 | |||
| 50bc9f4ff3 | |||
| f00ea03fad | |||
| f22e7b9ad1 | |||
| c7ec95f4bb | |||
| 229e7a8ccc | |||
| 3c616474ff | |||
| 56eb6b3ce3 | |||
| 545836d43c | |||
| 219f83dec0 | |||
| a76a841238 | |||
| c26680de84 | |||
| 8fffad9d3a | |||
| f4f0f203a2 | |||
| b7196f5a0c | |||
| 5d33a18890 | |||
| 96186a1a50 | |||
| bc8bc479d1 | |||
| 47595b1291 | |||
| 01a88964df | |||
| 3a2b77379f | |||
| dc4e5f75cd | |||
| d0178d551c | |||
| 827333108d | |||
| 587b90bd27 | |||
| 4dc20c5e90 | |||
| ac25782f2b | |||
| 20437d56e7 | |||
| f0b412828a | |||
| 367faac5c3 | |||
| 84deaaa970 | |||
| a2b39466c2 | |||
| 03586c4005 | |||
| 6ea69e1510 | |||
| 553c6dc539 | |||
| 6cc22f5b6d | |||
| 9103d67cc1 | |||
| 25083fb0e4 | |||
| d2dc045255 | |||
| b8621dfbb0 | |||
| 93633940dd | |||
| b6f5325351 | |||
| 7c32c08f1f | |||
| 1d268da08d | |||
| 797666ae0d | |||
| dcf470997e | |||
| 0974d1dbf8 | |||
| 12a35db6cd | |||
| 9abbb05ad8 | |||
| 1ecaf69b0b | |||
| e334d1e5d9 | |||
| b735e861d0 | |||
| 4eb433d372 | |||
| 2416ae61f3 | |||
| 01fb336985 | |||
| b6af88a732 | |||
| 58a2a17d6d | |||
| 79f5a0f520 | |||
| 7f6c0f7f04 | |||
| f658df4dca | |||
| 9d43b8e23a | |||
| 4270aef79b | |||
| 1c0dc82d44 | |||
| c1e325aadf | |||
| cec87da69d | |||
| f68f24cb2c | |||
| ed094347fc | |||
| b8afdffbe1 | |||
| f6ba79f31c | |||
| 5f3b1663d2 | |||
| 66e786b4bb | |||
| f671114574 | |||
| ce37060d94 | |||
| 7d19a4d184 | |||
| 22f28a2f8a | |||
| ceef9ca979 | |||
| efe8f4f939 | |||
| ba692a1195 | |||
| d732bad042 | |||
| 4c935c3bee | |||
| c160dd791f | |||
| 23cd1b4601 | |||
| 031fc8ba1b | |||
| c6853289ad | |||
| 2497bb69bc | |||
| a58a67e0a2 | |||
| 4315fe12a5 | |||
| 42f10a8899 | |||
| 1e4b47f989 | |||
| ff255dbfae | |||
| dbe9b72feb | |||
| 95a714b391 | |||
| 28f58c7f56 | |||
| 8bd46d8f21 | |||
| e1bb8e54ed | |||
| 1de705b063 | |||
| f6926ad356 | |||
| 2cdbbb1b37 | |||
| 4dce8c8f03 | |||
| 97a5bace6f | |||
| d4d51ec48f | |||
| fb91398462 | |||
| 105dadd798 | |||
| 2abf2837d3 | |||
| 422aa67af6 | |||
| 7fffab6985 | |||
| 5a4be3d2c1 | |||
| f39a7681db | |||
| c60a7580ba | |||
| 97edb56edc | |||
| 6ebca8d22b | |||
| 95371ad934 | |||
| 2c176825fd | |||
| fae7de48d3 | |||
| b8230646a2 | |||
| 43279541dd | |||
| b4791977c1 | |||
| ef917ecc25 | |||
| a93faad951 | |||
| fd001d24d3 | |||
| 7aa5884797 | |||
| 5b237a1547 | |||
| 2e37990d87 | |||
| dd07d724a8 | |||
| 03ce8618e7 | |||
| db1a7a7fd6 | |||
| 36a82d7f53 | |||
| 3a34401113 | |||
| 9927268330 | |||
| c45c97e29d | |||
| c64a315226 | |||
| a4cafca6ab | |||
| 46284a0660 | |||
| 05df86e15a | |||
| 8b433027e2 | |||
| 5bd4ff7610 | |||
| d693c397ea | |||
| 1d8d1ec9a5 | |||
| 5e491f11ee | |||
| 7cedea06ac | |||
| 2e5f750e50 | |||
| 20289cad10 | |||
| e0d64c31c7 | |||
| 8c1b95dc97 | |||
| fb5641343e | |||
| 87765941eb | |||
| 1809862c16 | |||
| 300f784f7d | |||
| 67a045eae6 | |||
| 2a79903a28 | |||
| d3222ce083 | |||
| 406a421742 | |||
| 10bf728faf | |||
| 607617747c | |||
| f0a69eb1a2 | |||
| 6b307a6e17 | |||
| 08d08a934a | |||
| c500c12668 | |||
| 62060adeba | |||
| b2fc75edb8 | |||
| a999dd2085 | |||
| 49f95ab100 | |||
| 1a84d5b30c | |||
| 3b65050632 | |||
| d0df31674c | |||
| 1fe88402e2 | |||
| 67097696e6 | |||
| 8e7e77067a | |||
| 9899390b61 | |||
| 80c476a908 | |||
| 59da1d6e49 | |||
| 5aef7dac33 | |||
| faf7aa06b6 | |||
| 38ef6e5583 | |||
| c0b15b5d94 | |||
| 2cfc067ea1 | |||
| a91db4f956 | |||
| 8a09780a02 | |||
| 45e8ec6505 | |||
| 4554b85914 | |||
| 8aa79c4a9c | |||
| c8d3210b57 | |||
| 2282a49563 | |||
| b82fdfb2c8 | |||
| 2d17eac199 | |||
| e482bc3aad | |||
| ec022b74d1 | |||
| dc42c09ce3 | |||
| 046a34d2a4 | |||
| 9ff6ec1888 | |||
| d2950106ec | |||
| 962f800d2e | |||
| 962107e507 | |||
| 039bd11963 | |||
| 5c250ea4ae | |||
| e3405bcec6 | |||
| 0fd1c2235f | |||
| b20c29b022 | |||
| 12d5dcd298 | |||
| 2c305dc6c6 | |||
| 62f76f7433 | |||
| 858ce524f9 | |||
| 3795fb4a40 | |||
| 0c01aeec50 | |||
| 892206744d | |||
| 9e2c1474db | |||
| 16328f73d9 | |||
| e0d4f53cf4 | |||
| e09a59c5b4 | |||
| 049e654535 | |||
| c927dc4ecd | |||
| fe4ecd0ad8 | |||
| 78d476fe80 | |||
| a11c8465d5 | |||
| 366304a9b7 | |||
| 4356663688 | |||
| 26b55e6fcf | |||
| 0d743f7204 | |||
| 6cbe113b3e | |||
| 6409b69d6c | |||
| c5164c76fc | |||
| baade8e138 | |||
| b848d6b4e0 | |||
| d8139d2ab0 | |||
| e96d8f7469 | |||
| 2acffd8afc | |||
| 3c8e72073c | |||
| 724d7a9d9b | |||
| 2da3b0db78 | |||
| 685ad7afaf | |||
| 264cf75964 | |||
| c773dbc7b5 | |||
| 37cbc64f52 | |||
| cb1dde17bb | |||
| c29988acf4 | |||
| eadbf56dae | |||
| 4b3b455135 | |||
| e6ac177396 | |||
| 3d0e29003f | |||
| 78b9b00f77 | |||
| 0ee7faa551 | |||
| e5fdced681 | |||
| afb99fef64 | |||
| 7dfaa36024 | |||
| 0496f665aa | |||
| 0d19e1be74 | |||
| 4aff0111aa | |||
| 63b3ba2bb2 | |||
| 7444b41f60 | |||
| 8e90dbc8b6 | |||
| 9f70722521 | |||
| 52fae596fa | |||
| ccb67957bc | |||
| fb82538d0d | |||
| 72ee39612e | |||
| 51fd5408dc | |||
| 3fae40fbef | |||
| 0745890af0 | |||
| 4abe1730a7 | |||
| 626f0e6989 | |||
| 9f42d9d173 | |||
| f90a93c4bc | |||
| 8000ad6c6a | |||
| 1f1f1bea1a | |||
| d95460c7cd | |||
| a3d93d4b08 | |||
| 07a92af982 | |||
| f4618877d4 | |||
| 2b914fd222 | |||
| 109e42a5a3 | |||
| fa515ad39c | |||
| f09673a795 | |||
| f71536c614 | |||
| 7bdddc7ae8 | |||
| aa8926a624 | |||
| be71e59be2 | |||
| 4d7753378f | |||
| 60257c4ef4 | |||
| 1e0b79bf62 | |||
| 6883434d0d | |||
| eda2193e64 | |||
| 99bf829c88 | |||
| 5feafe1b48 | |||
| c9292b7d04 | |||
| ae7e1a91c1 | |||
| 3e1887e0d1 | |||
| 474646db47 | |||
| 56f7b6c449 | |||
| 76b2b5f7e3 | |||
| e918d809eb | |||
| 7af059e543 | |||
| 897726e1ec | |||
| 8b98a2dd07 | |||
| cca75420f0 | |||
| 86c627ed1d | |||
| d55514e3a7 |
@@ -1,7 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
data class AdminCanChargeRequest(
|
||||
val memberIds: List<Long>,
|
||||
val memberId: Long,
|
||||
val method: String,
|
||||
val can: Int
|
||||
)
|
||||
|
||||
@@ -40,27 +40,22 @@ 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 ids = request.memberIds.distinct()
|
||||
if (ids.isEmpty()) throw SodaException("회원번호를 입력하세요.")
|
||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||
charge.title = "${request.can.moneyFormat()} 캔"
|
||||
charge.member = member
|
||||
|
||||
val members = memberRepository.findAllById(ids).toList()
|
||||
if (members.size != ids.size) throw SodaException("잘못된 회원번호 입니다.")
|
||||
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
payment.method = request.method
|
||||
charge.payment = payment
|
||||
|
||||
members.forEach { member ->
|
||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||
charge.title = "${request.can.moneyFormat()} 캔"
|
||||
charge.member = member
|
||||
chargeRepository.save(charge)
|
||||
|
||||
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
payment.method = request.method
|
||||
charge.payment = payment
|
||||
|
||||
chargeRepository.save(charge)
|
||||
|
||||
member.pgRewardCan += charge.rewardCan
|
||||
}
|
||||
member.pgRewardCan += charge.rewardCan
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
|
||||
val formattedDate = Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
Expressions.dateTimeTemplate(
|
||||
@@ -31,41 +31,11 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetChargeStatusResponse(
|
||||
QGetChargeStatusQueryDto(
|
||||
formattedDate,
|
||||
payment.price.sum(),
|
||||
payment.id.count(),
|
||||
payment.paymentGateway.stringValue(),
|
||||
currency.coalesce("KRW")
|
||||
)
|
||||
)
|
||||
.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(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("''"),
|
||||
payment.paymentGateway,
|
||||
currency
|
||||
)
|
||||
)
|
||||
@@ -78,8 +48,8 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
)
|
||||
.groupBy(currency)
|
||||
.orderBy(currency.asc())
|
||||
.groupBy(formattedDate, payment.paymentGateway, currency)
|
||||
.orderBy(formattedDate.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -100,10 +70,7 @@ 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 currencyExpr = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
|
||||
val whereBuilder = BooleanBuilder()
|
||||
whereBuilder.and(charge.createdAt.goe(startDate))
|
||||
.and(charge.createdAt.loe(endDate))
|
||||
@@ -122,7 +89,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
member.nickname,
|
||||
payment.method.coalesce(""),
|
||||
payment.price,
|
||||
currencyExpr,
|
||||
currencyExpr.coalesce(""),
|
||||
formattedDate
|
||||
)
|
||||
)
|
||||
|
||||
@@ -20,8 +20,39 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
|
||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
|
||||
val totalsByCurrency = mutableMapOf<String, Pair<java.math.BigDecimal, Long>>()
|
||||
|
||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
|
||||
.map {
|
||||
val chargeAmount = it.amount
|
||||
val chargeCount = it.chargeCount
|
||||
val currency = it.currency
|
||||
|
||||
val prev = totalsByCurrency[currency] ?: (0.toBigDecimal() to 0L)
|
||||
totalsByCurrency[currency] = (prev.first + chargeAmount) to (prev.second + chargeCount)
|
||||
|
||||
GetChargeStatusResponse(
|
||||
date = it.date,
|
||||
chargeAmount = chargeAmount,
|
||||
chargeCount = chargeCount,
|
||||
pg = it.paymentGateWay.name,
|
||||
currency = currency
|
||||
)
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
val summaryRows = totalsByCurrency.entries
|
||||
.sortedBy { it.key }
|
||||
.map { (currency, total) ->
|
||||
GetChargeStatusResponse(
|
||||
date = "합계",
|
||||
chargeAmount = total.first,
|
||||
chargeCount = total.second,
|
||||
pg = "",
|
||||
currency = currency
|
||||
)
|
||||
}
|
||||
|
||||
chargeStatusList.addAll(0, summaryRows)
|
||||
|
||||
return chargeStatusList.toList()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusQueryDto @QueryProjection constructor(
|
||||
val date: String,
|
||||
val amount: BigDecimal,
|
||||
val chargeCount: Long,
|
||||
val paymentGateWay: PaymentGateway,
|
||||
val currency: String
|
||||
)
|
||||
@@ -1,9 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusResponse @QueryProjection constructor(
|
||||
data class GetChargeStatusResponse(
|
||||
val date: String,
|
||||
val chargeAmount: BigDecimal,
|
||||
val chargeCount: Long,
|
||||
|
||||
@@ -13,13 +13,8 @@ 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
|
||||
@@ -45,7 +40,6 @@ 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,
|
||||
@@ -171,18 +165,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -333,13 +315,6 @@ class AdminChatCharacterController(
|
||||
request = request
|
||||
)
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = request.id,
|
||||
targetType = LanguageTranslationTargetType.CHARACTER
|
||||
)
|
||||
)
|
||||
|
||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||
if (request.originalWorkId != null) {
|
||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||
|
||||
@@ -10,11 +10,6 @@ import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import 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 org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
@@ -29,9 +24,7 @@ import org.springframework.transaction.annotation.Transactional
|
||||
class AdminOriginalWorkService(
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val originalWorkTagRepository: OriginalWorkTagRepository,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
private val originalWorkTagRepository: OriginalWorkTagRepository
|
||||
) {
|
||||
|
||||
/** 원작 등록 (중복 제목 방지 포함) */
|
||||
@@ -63,44 +56,7 @@ class AdminOriginalWorkService(
|
||||
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
|
||||
}
|
||||
}
|
||||
|
||||
val originalWork = originalWorkRepository.save(entity)
|
||||
|
||||
/**
|
||||
* 저장이 완료된 후
|
||||
* originalWork의
|
||||
*
|
||||
* languageCode == null이면 언어 감지 이벤트 호출
|
||||
* languageCode != null이면 번역 이벤트 호출
|
||||
*
|
||||
*/
|
||||
if (originalWork.languageCode == null) {
|
||||
val papagoQuery = listOf(
|
||||
originalWork.title,
|
||||
originalWork.contentType,
|
||||
originalWork.category,
|
||||
originalWork.description
|
||||
)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = originalWork.id!!,
|
||||
query = papagoQuery,
|
||||
targetType = LanguageDetectTargetType.ORIGINAL_WORK
|
||||
)
|
||||
)
|
||||
} else {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = originalWork.id!!,
|
||||
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return originalWork
|
||||
return originalWorkRepository.save(entity)
|
||||
}
|
||||
|
||||
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
||||
@@ -151,25 +107,6 @@ class AdminOriginalWorkService(
|
||||
if (imagePath != null) {
|
||||
ow.imagePath = imagePath
|
||||
}
|
||||
|
||||
/**
|
||||
* 번역 이벤트 호출
|
||||
*/
|
||||
if (
|
||||
request.title != null ||
|
||||
request.contentType != null ||
|
||||
request.category != null ||
|
||||
request.description != null ||
|
||||
request.tags != null
|
||||
) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = ow.id!!,
|
||||
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ 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
|
||||
@@ -21,9 +19,4 @@ 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,17 +1,10 @@
|
||||
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,
|
||||
private val genreRepository: AdminContentSeriesGenreRepository
|
||||
) {
|
||||
class AdminContentSeriesService(private val repository: AdminContentSeriesRepository) {
|
||||
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
|
||||
val totalCount = repository.getSeriesTotalCount()
|
||||
val items = repository.getSeriesList(
|
||||
@@ -19,53 +12,10 @@ class AdminContentSeriesService(
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
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,
|
||||
@@ -18,10 +17,7 @@ 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,
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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,7 +8,6 @@ interface AdminContentSeriesGenreRepository : JpaRepository<SeriesGenre, Long>,
|
||||
|
||||
interface AdminContentSeriesGenreQueryRepository {
|
||||
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
|
||||
fun findActiveSeriesGenreById(id: Long): SeriesGenre?
|
||||
}
|
||||
|
||||
class AdminContentSeriesGenreQueryRepositoryImpl(
|
||||
@@ -22,14 +21,4 @@ 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,11 +5,8 @@ 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
|
||||
@@ -21,8 +18,6 @@ class AdminContentThemeService(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val repository: AdminContentThemeRepository,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String
|
||||
) {
|
||||
@@ -42,14 +37,7 @@ class AdminContentThemeService(
|
||||
}
|
||||
|
||||
fun createTheme(theme: String, imagePath: String) {
|
||||
val savedTheme = repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = savedTheme.id!!,
|
||||
targetType = LanguageTranslationTargetType.CONTENT_THEME
|
||||
)
|
||||
)
|
||||
repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||
}
|
||||
|
||||
fun themeExistCheck(request: CreateContentThemeRequest) {
|
||||
|
||||
@@ -36,12 +36,6 @@ 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,7 +16,6 @@ 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 {
|
||||
@@ -122,22 +121,4 @@ 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,12 +145,6 @@ 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)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -22,11 +21,8 @@ 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,7 +4,6 @@ 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
|
||||
@@ -64,44 +63,4 @@ 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,30 +1,22 @@
|
||||
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.series.translation.SeriesTranslationRepository
|
||||
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
|
||||
@@ -47,25 +39,13 @@ 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 seriesTranslationRepository: SeriesTranslationRepository,
|
||||
|
||||
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,
|
||||
@@ -122,8 +102,6 @@ class HomeService(
|
||||
}
|
||||
}
|
||||
|
||||
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
|
||||
|
||||
val eventBannerList = GetEventResponse(
|
||||
totalCount = 0,
|
||||
eventList = emptyList()
|
||||
@@ -135,28 +113,19 @@ class HomeService(
|
||||
isAdult = isAdult
|
||||
)
|
||||
|
||||
// 오직 보이스온에서만
|
||||
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
orderByRandom = true
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList)
|
||||
|
||||
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
||||
|
||||
// 요일별 시리즈
|
||||
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
||||
)
|
||||
val translatedDayOfWeekSeriesList = getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
|
||||
|
||||
// 인기 캐릭터 조회
|
||||
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
|
||||
|
||||
val currentDateTime = LocalDateTime.now()
|
||||
val startDate = currentDateTime
|
||||
@@ -174,26 +143,10 @@ class HomeService(
|
||||
contentType = contentType,
|
||||
startDate = startDate.minusDays(1),
|
||||
endDate = endDate,
|
||||
sort = ContentRankingSortType.REVENUE
|
||||
sortType = "매출"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
// TODO 오디오 북
|
||||
|
||||
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
||||
memberId = memberId,
|
||||
@@ -201,40 +154,6 @@ 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,
|
||||
@@ -243,8 +162,7 @@ class HomeService(
|
||||
),
|
||||
contentType = contentType,
|
||||
isFree = true,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = true
|
||||
isAdult = isAdult
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
@@ -253,26 +171,6 @@ 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,
|
||||
@@ -284,22 +182,15 @@ class HomeService(
|
||||
liveList = liveList,
|
||||
creatorRanking = creatorRanking,
|
||||
latestContentThemeList = latestContentThemeList,
|
||||
latestContentList = translatedLatestContentList,
|
||||
latestContentList = latestContentList,
|
||||
bannerList = bannerList,
|
||||
eventBannerList = eventBannerList,
|
||||
originalAudioDramaList = translatedOriginalAudioDramaList,
|
||||
originalAudioDramaList = originalAudioDramaList,
|
||||
auditionList = auditionList,
|
||||
dayOfWeekSeriesList = translatedDayOfWeekSeriesList,
|
||||
popularCharacters = translatedPopularCharacters,
|
||||
contentRanking = translatedContentRanking,
|
||||
recommendChannelList = translatedRecommendChannelList,
|
||||
freeContentList = translatedFreeContentList,
|
||||
pointAvailableContentList = translatedPointAvailableContentList,
|
||||
recommendContentList = getRecommendContentList(
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
contentType = contentType,
|
||||
member = member
|
||||
),
|
||||
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
||||
contentRanking = contentRanking,
|
||||
recommendChannelList = recommendChannelList,
|
||||
freeContentList = freeContentList,
|
||||
curationList = curationList
|
||||
)
|
||||
}
|
||||
@@ -323,7 +214,7 @@ class HomeService(
|
||||
listOf(theme)
|
||||
}
|
||||
|
||||
val contentList = contentService.getLatestContentByTheme(
|
||||
return contentService.getLatestContentByTheme(
|
||||
theme = themeList,
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
@@ -335,8 +226,6 @@ class HomeService(
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
return getTranslatedContentList(contentList = contentList)
|
||||
}
|
||||
|
||||
fun getDayOfWeekSeriesList(
|
||||
@@ -348,48 +237,12 @@ class HomeService(
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
||||
return seriesService.getDayOfWeekSeriesList(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
dayOfWeek = dayOfWeek
|
||||
)
|
||||
|
||||
return getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -409,154 +262,4 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||
*
|
||||
* 처리 절차:
|
||||
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
||||
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
|
||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||
*
|
||||
* 성능:
|
||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||
*
|
||||
* @param seriesList 번역 대상 SeriesListItem 목록
|
||||
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
|
||||
*/
|
||||
private fun getTranslatedSeriesList(
|
||||
seriesList: List<GetSeriesListResponse.SeriesListItem>
|
||||
): List<GetSeriesListResponse.SeriesListItem> {
|
||||
val seriesIds = seriesList.map { it.seriesId }
|
||||
|
||||
return if (seriesIds.isNotEmpty()) {
|
||||
val translations = seriesTranslationRepository
|
||||
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
|
||||
.associateBy { it.seriesId }
|
||||
|
||||
seriesList.map { item ->
|
||||
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seriesList
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ class CanService(private val repository: CanRepository) {
|
||||
"aos" -> {
|
||||
it.useCanCalculates.any { useCanCalculate ->
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PG ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PAYVERSE ||
|
||||
useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP
|
||||
}
|
||||
}
|
||||
@@ -48,14 +47,12 @@ 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.PAYVERSE
|
||||
useCanCalculate.paymentGateway == PaymentGateway.PG
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,18 +113,15 @@ 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))
|
||||
|
||||
@@ -127,7 +127,6 @@ class CanPaymentService(
|
||||
useCanRepository.save(useCan)
|
||||
|
||||
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG)
|
||||
setUseCanCalculate(recipientId, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PAYVERSE)
|
||||
setUseCanCalculate(
|
||||
recipientId,
|
||||
useRewardCan,
|
||||
@@ -380,7 +379,6 @@ 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)
|
||||
@@ -430,7 +428,6 @@ 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)
|
||||
|
||||
@@ -22,8 +22,6 @@ class ChatCharacter(
|
||||
// 캐릭터 한 줄 소개
|
||||
var description: String,
|
||||
|
||||
var languageCode: String? = null,
|
||||
|
||||
// AI 시스템 프롬프트
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var systemPrompt: String,
|
||||
|
||||
@@ -16,7 +16,6 @@ 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, request.languageCode)
|
||||
val id = service.addReply(characterId, commentId, member, request.comment)
|
||||
ApiResponse.ok(id)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ package kr.co.vividnext.sodalive.chat.character.comment
|
||||
|
||||
// Request DTOs
|
||||
data class CreateCharacterCommentRequest(
|
||||
val comment: String,
|
||||
val languageCode: String? = null
|
||||
val comment: String
|
||||
)
|
||||
|
||||
// Response DTOs
|
||||
@@ -21,8 +20,7 @@ data class CharacterCommentResponse(
|
||||
val memberNickname: String,
|
||||
val createdAt: Long,
|
||||
val replyCount: Int,
|
||||
val comment: String,
|
||||
val languageCode: String?
|
||||
val comment: String
|
||||
)
|
||||
|
||||
// 답글 Response 단건(목록 원소)
|
||||
@@ -37,8 +35,7 @@ data class CharacterReplyResponse(
|
||||
val memberProfileImage: String,
|
||||
val memberNickname: String,
|
||||
val createdAt: Long,
|
||||
val comment: String,
|
||||
val languageCode: String?
|
||||
val comment: String
|
||||
)
|
||||
|
||||
// 댓글의 답글 조회 Response 컨테이너
|
||||
|
||||
@@ -2,10 +2,7 @@ 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
|
||||
@@ -15,8 +12,7 @@ import java.time.ZoneId
|
||||
class CharacterCommentService(
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val commentRepository: CharacterCommentRepository,
|
||||
private val reportRepository: CharacterCommentReportRepository,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
private val reportRepository: CharacterCommentReportRepository
|
||||
) {
|
||||
|
||||
private fun profileUrl(imageHost: String, profileImage: String?): String {
|
||||
@@ -44,8 +40,7 @@ class CharacterCommentService(
|
||||
memberNickname = member.nickname,
|
||||
createdAt = toEpochMilli(entity.createdAt),
|
||||
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
|
||||
comment = entity.comment,
|
||||
languageCode = entity.languageCode
|
||||
comment = entity.comment
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,44 +52,25 @@ class CharacterCommentService(
|
||||
memberProfileImage = profileUrl(imageHost, member.profileImage),
|
||||
memberNickname = member.nickname,
|
||||
createdAt = toEpochMilli(entity.createdAt),
|
||||
comment = entity.comment,
|
||||
languageCode = entity.languageCode
|
||||
comment = entity.comment
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long {
|
||||
fun addComment(characterId: Long, member: Member, text: String): Long {
|
||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
||||
val entity = CharacterComment(comment = text)
|
||||
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,
|
||||
languageCode: String? = null
|
||||
): Long {
|
||||
fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): Long {
|
||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
||||
@@ -102,23 +78,11 @@ class CharacterCommentService(
|
||||
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
|
||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
||||
val entity = CharacterComment(comment = text)
|
||||
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,7 +1,6 @@
|
||||
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
|
||||
@@ -11,21 +10,11 @@ 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
|
||||
@@ -43,12 +32,7 @@ class ChatCharacterController(
|
||||
private val bannerService: ChatCharacterBannerService,
|
||||
private val chatRoomService: ChatRoomService,
|
||||
private val characterCommentService: CharacterCommentService,
|
||||
private val curationQueryService: CharacterCurationQueryService,
|
||||
|
||||
private val translationService: PapagoTranslationService,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
|
||||
private val langContext: LangContext,
|
||||
private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
@@ -81,24 +65,6 @@ 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()
|
||||
|
||||
@@ -108,13 +74,6 @@ 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 ->
|
||||
@@ -126,8 +85,7 @@ class ChatCharacterController(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
||||
new = false
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -137,10 +95,9 @@ class ChatCharacterController(
|
||||
ApiResponse.ok(
|
||||
CharacterMainResponse(
|
||||
banners = banners,
|
||||
recentCharacters = translatedRecentCharacters,
|
||||
popularCharacters = getTranslatedAiCharacterList(popularCharacters),
|
||||
newCharacters = getTranslatedAiCharacterList(newCharacters),
|
||||
recommendCharacters = getTranslatedAiCharacterList(recommendCharacters),
|
||||
recentCharacters = recentCharacters,
|
||||
popularCharacters = popularCharacters,
|
||||
newCharacters = newCharacters,
|
||||
curationSections = curationSections
|
||||
)
|
||||
)
|
||||
@@ -182,118 +139,6 @@ 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 ->
|
||||
@@ -308,35 +153,6 @@ 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!!)
|
||||
|
||||
@@ -346,7 +162,6 @@ class ChatCharacterController(
|
||||
characterId = character.id!!,
|
||||
name = character.name,
|
||||
description = character.description,
|
||||
languageCode = character.languageCode,
|
||||
mbti = character.mbti,
|
||||
gender = character.gender,
|
||||
age = character.age,
|
||||
@@ -357,10 +172,9 @@ class ChatCharacterController(
|
||||
originalTitle = character.originalTitle,
|
||||
originalLink = character.originalLink,
|
||||
characterType = character.characterType,
|
||||
others = translatedOthers,
|
||||
others = others,
|
||||
latestComment = latestComment,
|
||||
totalComments = characterCommentService.getTotalCommentCount(character.id!!),
|
||||
translated = translated
|
||||
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -371,80 +185,12 @@ class ChatCharacterController(
|
||||
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
|
||||
*/
|
||||
@GetMapping("/recent")
|
||||
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 }
|
||||
}
|
||||
|
||||
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
|
||||
ApiResponse.ok(
|
||||
getTranslatedAiCharacterList(
|
||||
service.getRecommendCharacters(
|
||||
recent,
|
||||
20
|
||||
)
|
||||
service.getRecentCharactersPage(
|
||||
page = page ?: 0,
|
||||
size = 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,13 +2,11 @@ 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?,
|
||||
@@ -21,8 +19,7 @@ data class CharacterDetailResponse(
|
||||
val characterType: CharacterType,
|
||||
val others: List<OtherCharacter>,
|
||||
val latestComment: CharacterCommentResponse?,
|
||||
val totalComments: Int,
|
||||
val translated: TranslatedAiCharacterDetail?
|
||||
val totalComments: Int
|
||||
)
|
||||
|
||||
data class OtherCharacter(
|
||||
|
||||
@@ -7,7 +7,6 @@ data class CharacterMainResponse(
|
||||
val recentCharacters: List<RecentCharacter>,
|
||||
val popularCharacters: List<Character>,
|
||||
val newCharacters: List<Character>,
|
||||
val recommendCharacters: List<Character>,
|
||||
val curationSections: List<CurationSection>
|
||||
)
|
||||
|
||||
@@ -21,8 +20,7 @@ data class Character(
|
||||
@JsonProperty("characterId") val characterId: Long,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("description") val description: String,
|
||||
@JsonProperty("imageUrl") val imageUrl: String,
|
||||
@JsonProperty("isNew") val new: Boolean
|
||||
@JsonProperty("imageUrl") val imageUrl: String
|
||||
)
|
||||
|
||||
data class RecentCharacter(
|
||||
|
||||
@@ -8,9 +8,7 @@ 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 {
|
||||
@@ -28,21 +26,6 @@ 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,29 +74,5 @@ 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,7 +13,6 @@ 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
|
||||
@@ -35,42 +34,10 @@ 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) 사용
|
||||
@@ -84,25 +51,12 @@ 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"}",
|
||||
new = recentSet.contains(it.id)
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -137,28 +91,15 @@ class ChatCharacterService(
|
||||
content = emptyList()
|
||||
)
|
||||
}
|
||||
val chars = chatCharacterRepository.findByIsActiveTrue(
|
||||
val fallback = chatCharacterRepository.findByIsActiveTrue(
|
||||
PageRequest.of(0, 20, Sort.by("createdAt").descending())
|
||||
).content
|
||||
|
||||
val recentSet = if (chars.isNotEmpty()) {
|
||||
imageRepository
|
||||
.findCharacterIdsWithRecentImages(
|
||||
chars.map { it.id!! },
|
||||
LocalDateTime.now().minusDays(3)
|
||||
)
|
||||
.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
val content = chars.map {
|
||||
)
|
||||
val content = fallback.content.map {
|
||||
Character(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
||||
new = recentSet.contains(it.id)
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
return RecentCharactersResponse(
|
||||
@@ -167,29 +108,16 @@ class ChatCharacterService(
|
||||
)
|
||||
}
|
||||
|
||||
val chars = chatCharacterRepository.findRecentSince(
|
||||
val pageResult = chatCharacterRepository.findRecentSince(
|
||||
since,
|
||||
PageRequest.of(safePage, safeSize)
|
||||
).content
|
||||
|
||||
val recentSet = if (chars.isNotEmpty()) {
|
||||
imageRepository
|
||||
.findCharacterIdsWithRecentImages(
|
||||
chars.map { it.id!! },
|
||||
LocalDateTime.now().minusDays(3)
|
||||
)
|
||||
.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
val content = chars.map {
|
||||
)
|
||||
val content = pageResult.content.map {
|
||||
Character(
|
||||
characterId = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
|
||||
new = recentSet.contains(it.id)
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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?
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -33,10 +33,6 @@ class OriginalWork(
|
||||
@Column(columnDefinition = "TEXT")
|
||||
var description: String = "",
|
||||
|
||||
/** 언어 코드 */
|
||||
@Column(nullable = true)
|
||||
var languageCode: String? = null,
|
||||
|
||||
/** 원천 원작 */
|
||||
@Column(nullable = true)
|
||||
var originalWork: String? = null,
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
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.character.translate.AiCharacterTranslationRepository
|
||||
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
|
||||
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService
|
||||
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkTranslationService
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||
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.member.Member
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
@@ -21,7 +15,6 @@ 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
|
||||
@@ -32,14 +25,6 @@ import java.time.LocalDateTime
|
||||
@RequestMapping("/api/chat/original")
|
||||
class OriginalWorkController(
|
||||
private val queryService: OriginalWorkQueryService,
|
||||
private val characterImageRepository: CharacterImageRepository,
|
||||
|
||||
private val langContext: LangContext,
|
||||
|
||||
private val originalWorkTranslationService: OriginalWorkTranslationService,
|
||||
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
@@ -61,57 +46,7 @@ class OriginalWorkController(
|
||||
val includeAdult = member?.auth != null
|
||||
val pageRes = queryService.listForAppPage(includeAdult, page, size)
|
||||
val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) }
|
||||
|
||||
/**
|
||||
* 원작 목록의 제목과 콘텐츠 타입을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||
*
|
||||
* 처리 절차:
|
||||
* - 입력된 원작들의 originalWorkId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||
* originalWorkTranslationRepository 번역 데이터를 한 번에 조회한다.
|
||||
* - 각 항목에 대해 번역된 제목과 콘텐츠 타입이 존재하고 비어있지 않으면 title과 contentType을 번역 값으로 교체한다.
|
||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||
*
|
||||
* 성능:
|
||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||
*/
|
||||
val translatedContent = run {
|
||||
if (content.isEmpty()) {
|
||||
content
|
||||
} else {
|
||||
val ids = content.map { it.id }.toSet()
|
||||
val locale = langContext.lang.code
|
||||
val translations = originalWorkTranslationRepository
|
||||
.findByOriginalWorkIdInAndLocale(ids, locale)
|
||||
.associateBy { it.originalWorkId }
|
||||
|
||||
content.map { item ->
|
||||
val payload = translations[item.id]?.renderedPayload
|
||||
if (payload != null) {
|
||||
val newTitle = payload.title.trim()
|
||||
val newContentType = payload.contentType.trim()
|
||||
val hasTitle = newTitle.isNotEmpty()
|
||||
val hasContentType = newContentType.isNotEmpty()
|
||||
if (hasTitle || hasContentType) {
|
||||
item.copy(
|
||||
title = if (hasTitle) newTitle else item.title,
|
||||
contentType = if (hasContentType) newContentType else item.contentType
|
||||
)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApiResponse.ok(
|
||||
OriginalWorkListResponse(
|
||||
totalCount = pageRes.totalElements,
|
||||
content = translatedContent
|
||||
)
|
||||
)
|
||||
ApiResponse.ok(OriginalWorkListResponse(totalCount = pageRes.totalElements, content = content))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,70 +65,17 @@ class OriginalWorkController(
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
|
||||
val ow = queryService.getOriginalWork(id)
|
||||
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 translatedOriginal = originalWorkTranslationService.ensureTranslated(
|
||||
originalWork = ow,
|
||||
targetLocale = langContext.lang.code
|
||||
)
|
||||
|
||||
/**
|
||||
* 캐릭터 리스트의 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)를 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||
*
|
||||
* 처리 절차:
|
||||
* - 입력된 콘텐츠들의 characterId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||
* AiCharacterTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
||||
* - 각 항목에 대해 번역된 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)가 존재하고 비어있지 않으면 번역 값으로 교체한다.
|
||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||
*
|
||||
* 성능:
|
||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||
*/
|
||||
val translatedCharacters = run {
|
||||
if (chars.isEmpty()) {
|
||||
emptyList<Character>()
|
||||
} else {
|
||||
val ids = chars.mapNotNull { it.id }
|
||||
val translations = aiCharacterTranslationRepository
|
||||
.findByCharacterIdInAndLocale(ids, langContext.lang.code)
|
||||
.associateBy { it.characterId }
|
||||
|
||||
chars.map<ChatCharacter, Character> {
|
||||
val path = it.imagePath ?: "profile/default-profile.png"
|
||||
val tr = translations[it.id!!]?.renderedPayload
|
||||
val newName = tr?.name?.trim().orEmpty()
|
||||
val newDesc = tr?.description?.trim().orEmpty()
|
||||
val hasName = newName.isNotEmpty()
|
||||
val hasDesc = newDesc.isNotEmpty()
|
||||
Character(
|
||||
characterId = it.id!!,
|
||||
name = if (hasName) newName else it.name,
|
||||
description = if (hasDesc) newDesc else it.description,
|
||||
imageUrl = "$imageHost/$path",
|
||||
new = recentSet.contains(it.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApiResponse.ok(
|
||||
OriginalWorkDetailResponse.from(
|
||||
ow,
|
||||
imageHost,
|
||||
translatedCharacters,
|
||||
translated = translatedOriginal
|
||||
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 response = OriginalWorkDetailResponse.from(ow, imageHost, characters)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.chat.original.dto
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
|
||||
|
||||
/**
|
||||
* 앱용 원작 목록 아이템 응답 DTO
|
||||
@@ -55,15 +54,13 @@ data class OriginalWorkDetailResponse(
|
||||
@JsonProperty("studio") val studio: String?,
|
||||
@JsonProperty("originalLinks") val originalLinks: List<String>,
|
||||
@JsonProperty("tags") val tags: List<String>,
|
||||
@JsonProperty("characters") val characters: List<Character>,
|
||||
@JsonProperty("translated") val translated: TranslatedOriginalWork?
|
||||
@JsonProperty("characters") val characters: List<Character>
|
||||
) {
|
||||
companion object {
|
||||
fun from(
|
||||
entity: OriginalWork,
|
||||
imageHost: String = "",
|
||||
characters: List<Character>,
|
||||
translated: TranslatedOriginalWork?
|
||||
characters: List<Character>
|
||||
): OriginalWorkDetailResponse {
|
||||
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${entity.imagePath}"
|
||||
@@ -83,8 +80,7 @@ data class OriginalWorkDetailResponse(
|
||||
studio = entity.studio,
|
||||
originalLinks = entity.originalLinks.map { it.url },
|
||||
tags = entity.tagMappings.map { it.tag.tag },
|
||||
characters = characters,
|
||||
translated = translated
|
||||
characters = characters
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class OriginalWorkQueryService(
|
||||
val safePage = if (page < 0) 0 else page
|
||||
val safeSize = when {
|
||||
size <= 0 -> 20
|
||||
size > 20 -> 20
|
||||
size > 50 -> 50
|
||||
else -> size
|
||||
}
|
||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.original.service
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class OriginalWorkTranslationService(
|
||||
private val translationRepository: OriginalWorkTranslationRepository,
|
||||
private val papagoTranslationService: PapagoTranslationService
|
||||
) {
|
||||
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
/**
|
||||
* 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다.
|
||||
* - 기존 번역이 있으면 그대로 사용
|
||||
* - 없으면 파파고 번역 수행 후 저장
|
||||
* - 실패/불필요 시 null 반환
|
||||
*/
|
||||
@Transactional
|
||||
fun ensureTranslated(originalWork: OriginalWork, targetLocale: String): TranslatedOriginalWork? {
|
||||
val source = originalWork.languageCode?.lowercase()
|
||||
val target = targetLocale.lowercase()
|
||||
|
||||
if (source.isNullOrBlank() || source == target) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 기존 번역 조회
|
||||
val existed = translationRepository.findByOriginalWorkIdAndLocale(originalWork.id!!, target)
|
||||
val existedPayload = existed?.renderedPayload
|
||||
if (existedPayload != null) {
|
||||
val t = existedPayload.title.trim()
|
||||
val ct = existedPayload.contentType.trim()
|
||||
val cat = existedPayload.category.trim()
|
||||
val desc = existedPayload.description.trim()
|
||||
val tags = existedPayload.tags
|
||||
val hasAny = t.isNotEmpty() || ct.isNotEmpty() || cat.isNotEmpty() || desc.isNotEmpty() || tags.isNotEmpty()
|
||||
if (hasAny) {
|
||||
return TranslatedOriginalWork(
|
||||
title = t,
|
||||
contentType = ct,
|
||||
category = cat,
|
||||
description = desc,
|
||||
tags = tags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 파파고 번역 수행
|
||||
return try {
|
||||
val tags = originalWork.tagMappings.map { it.tag.tag }.filter { it.isNotBlank() }
|
||||
val texts = buildList {
|
||||
add(originalWork.title)
|
||||
add(originalWork.contentType)
|
||||
add(originalWork.category)
|
||||
add(originalWork.description)
|
||||
addAll(tags)
|
||||
}
|
||||
|
||||
val response = papagoTranslationService.translate(
|
||||
TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = source,
|
||||
targetLanguage = target
|
||||
)
|
||||
)
|
||||
|
||||
val out = response.translatedText
|
||||
if (out.isEmpty()) return null
|
||||
|
||||
// 앞 4개는 필드, 나머지는 태그
|
||||
val title = out.getOrNull(0)?.trim().orEmpty()
|
||||
val contentType = out.getOrNull(1)?.trim().orEmpty()
|
||||
val category = out.getOrNull(2)?.trim().orEmpty()
|
||||
val description = out.getOrNull(3)?.trim().orEmpty()
|
||||
val translatedTags = if (out.size > 4) {
|
||||
out.drop(4).map { it.trim() }.filter { it.isNotEmpty() }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val hasAny = title.isNotEmpty() || contentType.isNotEmpty() ||
|
||||
category.isNotEmpty() || description.isNotEmpty() || translatedTags.isNotEmpty()
|
||||
if (!hasAny) return null
|
||||
|
||||
val payload = OriginalWorkTranslationPayload(
|
||||
title = title,
|
||||
contentType = contentType,
|
||||
category = category,
|
||||
description = description,
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val entity = existed?.apply { this.renderedPayload = payload }
|
||||
?: OriginalWorkTranslation(
|
||||
originalWorkId = originalWork.id!!,
|
||||
locale = target,
|
||||
renderedPayload = payload
|
||||
)
|
||||
|
||||
translationRepository.save(entity)
|
||||
|
||||
TranslatedOriginalWork(
|
||||
title = title,
|
||||
contentType = contentType,
|
||||
category = category,
|
||||
description = description,
|
||||
tags = translatedTags
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log.warn("Failed to translate OriginalWork(id={}) from {} to {}: {}", originalWork.id, source, target, e.message)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.original.translation
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
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 = ["original_work_id", "locale"])
|
||||
]
|
||||
)
|
||||
class OriginalWorkTranslation(
|
||||
@Column(name = "original_work_id")
|
||||
val originalWorkId: Long,
|
||||
@Column(name = "locale")
|
||||
val locale: String,
|
||||
|
||||
@Column(columnDefinition = "json")
|
||||
@Convert(converter = OriginalWorkTranslationPayloadConverter::class)
|
||||
var renderedPayload: OriginalWorkTranslationPayload
|
||||
) : BaseEntity()
|
||||
|
||||
data class OriginalWorkTranslationPayload(
|
||||
val title: String,
|
||||
val contentType: String,
|
||||
val category: String,
|
||||
val description: String,
|
||||
val tags: List<String>
|
||||
)
|
||||
|
||||
data class TranslatedOriginalWork(
|
||||
val title: String,
|
||||
val contentType: String,
|
||||
val category: String,
|
||||
val description: String,
|
||||
val tags: List<String>
|
||||
)
|
||||
|
||||
@Converter(autoApply = false)
|
||||
class OriginalWorkTranslationPayloadConverter : AttributeConverter<OriginalWorkTranslationPayload, String> {
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: OriginalWorkTranslationPayload?): String {
|
||||
if (attribute == null) return "{}"
|
||||
return objectMapper.writeValueAsString(attribute)
|
||||
}
|
||||
|
||||
override fun convertToEntityAttribute(dbData: String?): OriginalWorkTranslationPayload {
|
||||
if (dbData.isNullOrBlank()) {
|
||||
return OriginalWorkTranslationPayload(
|
||||
title = "",
|
||||
contentType = "",
|
||||
category = "",
|
||||
description = "",
|
||||
tags = emptyList()
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val node = objectMapper.readTree(dbData)
|
||||
val title = node.get("title")?.asText() ?: ""
|
||||
val contentType = node.get("contentType")?.asText() ?: ""
|
||||
val category = node.get("category")?.asText() ?: ""
|
||||
val description = node.get("description")?.asText() ?: ""
|
||||
val tagsNode = node.get("tags")
|
||||
val tags: List<String> = when {
|
||||
tagsNode == null || tagsNode.isNull -> emptyList()
|
||||
tagsNode.isArray -> tagsNode.mapNotNull { it.asText(null) }.filter { it.isNotBlank() }
|
||||
tagsNode.isTextual -> tagsNode.asText()
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
OriginalWorkTranslationPayload(
|
||||
title = title,
|
||||
contentType = contentType,
|
||||
category = category,
|
||||
description = description,
|
||||
tags = tags
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
OriginalWorkTranslationPayload(
|
||||
title = "",
|
||||
contentType = "",
|
||||
category = "",
|
||||
description = "",
|
||||
tags = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.chat.original.translation
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface OriginalWorkTranslationRepository : JpaRepository<OriginalWorkTranslation, Long> {
|
||||
fun findByOriginalWorkIdAndLocale(originalWorkId: Long, locale: String): OriginalWorkTranslation?
|
||||
|
||||
fun findByOriginalWorkIdInAndLocale(originalWorkIds: Set<Long>, locale: String): List<OriginalWorkTranslation>
|
||||
}
|
||||
@@ -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("잘못된 접근입니다")
|
||||
if (req.container.isBlank()) throw SodaException("container를 확인해주세요.")
|
||||
|
||||
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
|
||||
?: throw SodaException("채팅방을 찾을 수 없습니다.")
|
||||
@@ -79,7 +79,7 @@ class ChatRoomQuotaController(
|
||||
memberId = member.id!!,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId,
|
||||
addPaid = 12,
|
||||
addPaid = 40,
|
||||
container = req.container
|
||||
)
|
||||
|
||||
|
||||
@@ -126,13 +126,13 @@ class ChatRoomQuotaService(
|
||||
memberId: Long,
|
||||
chatRoomId: Long,
|
||||
characterId: Long,
|
||||
addPaid: Int = 12,
|
||||
addPaid: Int = 40,
|
||||
container: String
|
||||
): RoomQuotaStatus {
|
||||
// 요구사항: 10캔 결제 및 UseCan에 방/캐릭터 기록
|
||||
// 요구사항: 30캔 결제 및 UseCan에 방/캐릭터 기록
|
||||
canPaymentService.spendCan(
|
||||
memberId = memberId,
|
||||
needCan = 10,
|
||||
needCan = 30,
|
||||
canUsage = CanUsage.CHAT_QUOTA_PURCHASE,
|
||||
chatRoomId = chatRoomId,
|
||||
characterId = characterId,
|
||||
|
||||
@@ -83,7 +83,6 @@ 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,19 +1,11 @@
|
||||
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(
|
||||
private val langInterceptor: LangInterceptor
|
||||
) : WebMvcConfigurer {
|
||||
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||
registry.addInterceptor(langInterceptor).addPathPatterns("/**")
|
||||
}
|
||||
|
||||
class WebConfig : WebMvcConfigurer {
|
||||
override fun addCorsMappings(registry: CorsRegistry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(
|
||||
|
||||
@@ -23,7 +23,7 @@ enum class PurchaseOption {
|
||||
}
|
||||
|
||||
enum class SortType {
|
||||
NEWEST, PRICE_HIGH, PRICE_LOW, POPULARITY
|
||||
NEWEST, PRICE_HIGH, PRICE_LOW
|
||||
}
|
||||
|
||||
@Entity
|
||||
@@ -32,7 +32,6 @@ 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,33 +237,6 @@ 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,6 +109,7 @@ interface AudioContentQueryRepository {
|
||||
): Int
|
||||
|
||||
fun findByThemeFor2Weeks(
|
||||
isFree: Boolean = false,
|
||||
cloudfrontHost: String,
|
||||
memberId: Long,
|
||||
theme: List<String> = emptyList(),
|
||||
@@ -119,6 +120,7 @@ interface AudioContentQueryRepository {
|
||||
): List<GetAudioContentMainItem>
|
||||
|
||||
fun totalCountNewContentFor2Weeks(
|
||||
isFree: Boolean = false,
|
||||
theme: List<String> = emptyList(),
|
||||
memberId: Long,
|
||||
isAdult: Boolean,
|
||||
@@ -180,11 +182,8 @@ interface AudioContentQueryRepository {
|
||||
contentType: ContentType,
|
||||
offset: Long,
|
||||
limit: Long,
|
||||
sortType: SortType,
|
||||
isFree: Boolean,
|
||||
isAdult: Boolean,
|
||||
orderByRandom: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false
|
||||
isAdult: Boolean
|
||||
): List<AudioContentMainItem>
|
||||
|
||||
fun findContentByCurationId(
|
||||
@@ -194,11 +193,6 @@ interface AudioContentQueryRepository {
|
||||
offset: Long = 0,
|
||||
limit: Long = 20
|
||||
): List<GetAudioContentMainItem>
|
||||
|
||||
fun findLatestContentByCreatorId(
|
||||
creatorId: Long,
|
||||
isAdult: Boolean = false
|
||||
): AudioContent?
|
||||
}
|
||||
|
||||
@Repository
|
||||
@@ -242,7 +236,6 @@ 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)
|
||||
@@ -464,12 +457,6 @@ 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
|
||||
@@ -701,6 +688,7 @@ class AudioContentQueryRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun totalCountNewContentFor2Weeks(
|
||||
isFree: Boolean,
|
||||
theme: List<String>,
|
||||
memberId: Long,
|
||||
isAdult: Boolean,
|
||||
@@ -737,6 +725,10 @@ 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)
|
||||
@@ -748,6 +740,7 @@ class AudioContentQueryRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun findByThemeFor2Weeks(
|
||||
isFree: Boolean,
|
||||
cloudfrontHost: String,
|
||||
memberId: Long,
|
||||
theme: List<String>,
|
||||
@@ -787,6 +780,10 @@ class AudioContentQueryRepositoryImpl(
|
||||
where = where.and(audioContentTheme.theme.`in`(theme))
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
where = where.and(audioContent.price.loe(0))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAudioContentMainItem(
|
||||
@@ -1305,11 +1302,8 @@ class AudioContentQueryRepositoryImpl(
|
||||
contentType: ContentType,
|
||||
offset: Long,
|
||||
limit: Long,
|
||||
sortType: SortType,
|
||||
isFree: Boolean,
|
||||
isAdult: Boolean,
|
||||
orderByRandom: Boolean,
|
||||
isPointAvailableOnly: Boolean
|
||||
isAdult: Boolean
|
||||
): List<AudioContentMainItem> {
|
||||
var where = audioContent.isActive.isTrue
|
||||
.and(audioContent.duration.isNotNull)
|
||||
@@ -1344,31 +1338,6 @@ 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(
|
||||
@@ -1386,7 +1355,7 @@ class AudioContentQueryRepositoryImpl(
|
||||
.where(where)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.orderBy(orderBy)
|
||||
.orderBy(audioContent.id.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -1447,26 +1416,4 @@ 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,20 +21,10 @@ 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.theme.translation.ContentThemeTranslationRepository
|
||||
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
|
||||
@@ -66,18 +56,11 @@ 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,
|
||||
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
|
||||
@Value("\${cloud.aws.s3.content-bucket}")
|
||||
private val audioContentBucket: String,
|
||||
|
||||
@@ -177,13 +160,6 @@ class AudioContentService(
|
||||
|
||||
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
|
||||
}
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = request.contentId,
|
||||
targetType = LanguageTranslationTargetType.CONTENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -262,7 +238,6 @@ class AudioContentService(
|
||||
val audioContent = AudioContent(
|
||||
title = request.title.trim(),
|
||||
detail = request.detail.trim(),
|
||||
languageCode = request.languageCode,
|
||||
price = if (request.price > 0) {
|
||||
request.price
|
||||
} else {
|
||||
@@ -356,31 +331,6 @@ 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
|
||||
)
|
||||
)
|
||||
} else {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = audioContent.id!!,
|
||||
targetType = LanguageTranslationTargetType.CONTENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return CreateAudioContentResponse(contentId = audioContent.id!!)
|
||||
}
|
||||
|
||||
@@ -436,7 +386,7 @@ class AudioContentService(
|
||||
|
||||
// Check if the time difference is greater than 30 seconds (30000 milliseconds)
|
||||
return date2.time - date1.time
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
// Handle invalid time formats or parsing errors
|
||||
return 0
|
||||
}
|
||||
@@ -527,7 +477,6 @@ class AudioContentService(
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun getDetail(
|
||||
id: Long,
|
||||
member: Member,
|
||||
@@ -750,108 +699,13 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* themeStr 번역 처리
|
||||
*/
|
||||
val themeStrTranslated = run {
|
||||
val theme = audioContent.theme
|
||||
if (theme?.id != null) {
|
||||
val locale = langContext.lang.code
|
||||
val translated = contentThemeTranslationRepository
|
||||
.findByContentThemeIdAndLocale(theme.id!!, locale)
|
||||
val text = translated?.theme
|
||||
if (!text.isNullOrBlank()) text else theme.theme
|
||||
} else {
|
||||
audioContent.theme!!.theme
|
||||
}
|
||||
}
|
||||
|
||||
return GetAudioContentDetailResponse(
|
||||
contentId = audioContent.id!!,
|
||||
title = audioContent.title,
|
||||
detail = contentDetail,
|
||||
languageCode = audioContent.languageCode,
|
||||
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
|
||||
contentUrl = audioContentUrl,
|
||||
themeStr = themeStrTranslated,
|
||||
themeStr = audioContent.theme!!.theme,
|
||||
tag = tag,
|
||||
price = audioContent.price,
|
||||
duration = audioContent.duration ?: "",
|
||||
@@ -891,51 +745,7 @@ class AudioContentService(
|
||||
previousContent = previousContent,
|
||||
nextContent = nextContent,
|
||||
buyerList = buyerList,
|
||||
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
|
||||
isAvailableUsePoint = audioContent.isPointAvailable
|
||||
)
|
||||
}
|
||||
|
||||
@@ -999,27 +809,9 @@ 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 = translatedContentList
|
||||
items = items
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1153,108 +945,16 @@ class AudioContentService(
|
||||
contentType: ContentType,
|
||||
offset: Long = 0,
|
||||
limit: Long = 20,
|
||||
sortType: SortType = SortType.NEWEST,
|
||||
isFree: Boolean = false,
|
||||
isAdult: Boolean = false,
|
||||
orderByRandom: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false
|
||||
isAdult: Boolean = false
|
||||
): List<AudioContentMainItem> {
|
||||
/**
|
||||
* - AS-IS theme은 한글만 처리하도록 되어 있음
|
||||
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
|
||||
*/
|
||||
val normalizedTheme = normalizeThemeForQuery(
|
||||
themes = theme,
|
||||
contentType = contentType,
|
||||
isFree = isFree,
|
||||
isAdult = isAdult,
|
||||
isPointAvailableOnly = isPointAvailableOnly
|
||||
)
|
||||
|
||||
val contentList = repository.getLatestContentByTheme(
|
||||
theme = normalizedTheme,
|
||||
return repository.getLatestContentByTheme(
|
||||
theme = theme,
|
||||
contentType = contentType,
|
||||
offset = offset,
|
||||
limit = limit,
|
||||
sortType = sortType,
|
||||
isFree = isFree,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = orderByRandom,
|
||||
isPointAvailableOnly = isPointAvailableOnly
|
||||
isAdult = isAdult
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* theme 파라미터로 번역된 테마명이 들어와도 한글 원문 테마로 조회되도록 정규화한다.
|
||||
* - 현재 언어(locale)에 해당하는 테마 번역 목록을 활성 테마 집합과 매칭하여 역매핑한다.
|
||||
* - 입력이 이미 한글인 경우 그대로 유지한다.
|
||||
* - 매칭 실패 시 원본 값을 유지한다.
|
||||
*/
|
||||
private fun normalizeThemeForQuery(
|
||||
themes: List<String>,
|
||||
contentType: ContentType,
|
||||
isFree: Boolean,
|
||||
isAdult: Boolean,
|
||||
isPointAvailableOnly: Boolean
|
||||
): List<String> {
|
||||
if (themes.isEmpty()) return themes
|
||||
|
||||
val themesWithIds = themeQueryRepository.getActiveThemeWithIdsOfContent(
|
||||
isAdult = isAdult,
|
||||
isFree = isFree,
|
||||
isPointAvailableOnly = isPointAvailableOnly,
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
if (themesWithIds.isEmpty()) return themes
|
||||
|
||||
val idByKorean = themesWithIds.associate { it.theme to it.id }
|
||||
val koreanById = themesWithIds.associate { it.id to it.theme }
|
||||
|
||||
val locale = langContext.lang.code
|
||||
// 번역 테마를 역매핑하기 위해 현재 locale의 번역 목록을 조회
|
||||
val translatedByTextToId = run {
|
||||
val ids = themesWithIds.map { it.id }
|
||||
if (ids.isEmpty()) {
|
||||
emptyMap()
|
||||
} else {
|
||||
contentThemeTranslationRepository
|
||||
.findByContentThemeIdInAndLocale(ids, locale)
|
||||
.associate { it.theme to it.contentThemeId }
|
||||
}
|
||||
}
|
||||
|
||||
return themes.asSequence()
|
||||
.map { input ->
|
||||
when {
|
||||
idByKorean.containsKey(input) -> input // 이미 한글 원문
|
||||
translatedByTextToId.containsKey(input) -> {
|
||||
val id = translatedByTextToId[input]!!
|
||||
koreanById[id] ?: input
|
||||
}
|
||||
|
||||
else -> input
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,5 @@ data class CreateAudioContentRequest(
|
||||
val isCommentAvailable: Boolean = false,
|
||||
val isFullDetailVisible: Boolean = true,
|
||||
val previewStartTime: String? = null,
|
||||
val previewEndTime: String? = null,
|
||||
val languageCode: String? = null
|
||||
val previewEndTime: String? = null
|
||||
)
|
||||
|
||||
@@ -3,13 +3,11 @@ 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,
|
||||
@@ -41,8 +39,7 @@ data class GetAudioContentDetailResponse(
|
||||
val previousContent: OtherContentResponse?,
|
||||
val nextContent: OtherContentResponse?,
|
||||
val buyerList: List<ContentBuyer>,
|
||||
val isAvailableUsePoint: Boolean,
|
||||
val translated: TranslatedContent?
|
||||
val isAvailableUsePoint: Boolean
|
||||
)
|
||||
|
||||
data class OtherContentResponse @QueryProjection constructor(
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
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.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
|
||||
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.data.repository.findByIdOrNull
|
||||
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,
|
||||
SERIES,
|
||||
ORIGINAL_WORK
|
||||
}
|
||||
|
||||
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 seriesRepository: ContentSeriesRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
|
||||
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)
|
||||
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
|
||||
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(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 handleSeriesLanguageDetect(event: LanguageDetectEvent) {
|
||||
val seriesId = event.id
|
||||
|
||||
val series = seriesRepository.findByIdOrNull(seriesId)
|
||||
if (series == null) {
|
||||
log.warn("[PapagoLanguageDetect] Series not found. seriesId={}", seriesId)
|
||||
return
|
||||
}
|
||||
|
||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||
if (!series.languageCode.isNullOrBlank()) {
|
||||
log.debug(
|
||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}",
|
||||
seriesId,
|
||||
series.languageCode
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return
|
||||
|
||||
series.languageCode = langCode
|
||||
seriesRepository.save(series)
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = seriesId,
|
||||
targetType = LanguageTranslationTargetType.SERIES
|
||||
)
|
||||
)
|
||||
|
||||
log.info(
|
||||
"[PapagoLanguageDetect] languageCode updated from Papago. seriesId={}, langCode={}",
|
||||
seriesId,
|
||||
langCode
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleOriginalWorkLanguageDetect(event: LanguageDetectEvent) {
|
||||
val originalWorkId = event.id
|
||||
|
||||
val originalWork = originalWorkRepository.findByIdOrNull(originalWorkId)
|
||||
if (originalWork == null) {
|
||||
log.warn("[PapagoLanguageDetect] OriginalWork not found. originalWorkId={}", originalWorkId)
|
||||
return
|
||||
}
|
||||
|
||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||
if (!originalWork.languageCode.isNullOrBlank()) {
|
||||
log.debug(
|
||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. originalWorkId={}, languageCode={}",
|
||||
originalWorkId,
|
||||
originalWork.languageCode
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, originalWorkId) ?: return
|
||||
|
||||
originalWork.languageCode = langCode
|
||||
originalWorkRepository.save(originalWork)
|
||||
|
||||
// 언어 감지가 완료된 후 언어 번역 이벤트 호출
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = originalWorkId,
|
||||
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
|
||||
)
|
||||
)
|
||||
|
||||
log.info(
|
||||
"[PapagoLanguageDetect] languageCode updated from Papago. originalWorkId={}, langCode={}",
|
||||
originalWorkId,
|
||||
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,7 +16,6 @@ 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,8 +32,7 @@ class AudioContentCommentController(
|
||||
audioContentId = request.contentId,
|
||||
parentId = request.parentId,
|
||||
isSecret = request.isSecret,
|
||||
member = member,
|
||||
languageCode = request.languageCode
|
||||
member = member
|
||||
)
|
||||
|
||||
try {
|
||||
|
||||
@@ -85,7 +85,6 @@ class AudioContentCommentQueryRepositoryImpl(
|
||||
audioContentComment.member.nickname,
|
||||
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
|
||||
audioContentComment.comment,
|
||||
audioContentComment.languageCode,
|
||||
audioContentComment.isSecret,
|
||||
audioContentComment.donationCan.coalesce(0),
|
||||
formattedDate,
|
||||
@@ -167,7 +166,6 @@ class AudioContentCommentQueryRepositoryImpl(
|
||||
audioContentComment.member.nickname,
|
||||
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
|
||||
audioContentComment.comment,
|
||||
audioContentComment.languageCode,
|
||||
audioContentComment.isSecret,
|
||||
audioContentComment.donationCan.coalesce(0),
|
||||
formattedDate,
|
||||
|
||||
@@ -2,8 +2,6 @@ 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
|
||||
@@ -34,8 +32,7 @@ class AudioContentCommentService(
|
||||
comment: String,
|
||||
audioContentId: Long,
|
||||
parentId: Long? = null,
|
||||
isSecret: Boolean = false,
|
||||
languageCode: String?
|
||||
isSecret: Boolean = false
|
||||
): Long {
|
||||
val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId)
|
||||
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
||||
@@ -53,7 +50,7 @@ class AudioContentCommentService(
|
||||
throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.")
|
||||
}
|
||||
|
||||
val audioContentComment = AudioContentComment(comment = comment, languageCode = languageCode, isSecret = isSecret)
|
||||
val audioContentComment = AudioContentComment(comment = comment, isSecret = isSecret)
|
||||
audioContentComment.audioContent = audioContent
|
||||
audioContentComment.member = member
|
||||
|
||||
@@ -88,17 +85,6 @@ class AudioContentCommentService(
|
||||
)
|
||||
)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = savedContentComment.id!!,
|
||||
query = comment,
|
||||
targetType = LanguageDetectTargetType.COMMENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return savedContentComment.id!!
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ 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,6 +4,5 @@ data class RegisterCommentRequest(
|
||||
val comment: String,
|
||||
val contentId: Long,
|
||||
val parentId: Long?,
|
||||
val isSecret: Boolean = false,
|
||||
val languageCode: String? = null
|
||||
val isSecret: Boolean = false
|
||||
)
|
||||
|
||||
@@ -4,6 +4,5 @@ data class AudioContentDonationRequest(
|
||||
val contentId: Long,
|
||||
val donationCan: Int,
|
||||
val comment: String,
|
||||
val container: String,
|
||||
val languageCode: String? = null
|
||||
val container: String
|
||||
)
|
||||
|
||||
@@ -4,12 +4,9 @@ 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
|
||||
|
||||
@@ -17,8 +14,7 @@ import org.springframework.transaction.annotation.Transactional
|
||||
class AudioContentDonationService(
|
||||
private val canPaymentService: CanPaymentService,
|
||||
private val queryRepository: AudioContentRepository,
|
||||
private val commentRepository: AudioContentCommentRepository,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
private val commentRepository: AudioContentCommentRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun donation(request: AudioContentDonationRequest, member: Member) {
|
||||
@@ -38,23 +34,10 @@ class AudioContentDonationService(
|
||||
|
||||
val audioContentComment = AudioContentComment(
|
||||
comment = request.comment,
|
||||
languageCode = request.languageCode,
|
||||
donationCan = request.donationCan
|
||||
)
|
||||
audioContentComment.audioContent = audioContent
|
||||
audioContentComment.member = member
|
||||
|
||||
val savedComment = commentRepository.save(audioContentComment)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (request.languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = savedComment.id!!,
|
||||
query = request.comment,
|
||||
targetType = LanguageDetectTargetType.COMMENT
|
||||
)
|
||||
)
|
||||
}
|
||||
commentRepository.save(audioContentComment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ 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,
|
||||
@@ -109,6 +110,7 @@ class AudioContentMainController(
|
||||
|
||||
ApiResponse.ok(
|
||||
service.getNewContentFor2WeeksByTheme(
|
||||
isFree = isFree ?: false,
|
||||
theme = theme,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
|
||||
@@ -6,11 +6,7 @@ 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.theme.AudioContentThemeService
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
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
|
||||
@@ -24,33 +20,24 @@ class AudioContentMainService(
|
||||
private val repository: AudioContentRepository,
|
||||
private val blockMemberRepository: BlockMemberRepository,
|
||||
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
||||
private val audioContentThemeService: AudioContentThemeService,
|
||||
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
|
||||
private val langContext: LangContext,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
@Cacheable(cacheNames = ["default"], key = "'themeList:' + ':' + #isAdult")
|
||||
fun getThemeList(isAdult: Boolean, contentType: ContentType): List<String> {
|
||||
/**
|
||||
* 콘텐츠 테마 조회
|
||||
*
|
||||
* - langContext에 따라 기본 한국어 데이터 혹은 번역된 콘텐츠 테마를 조회해야 함
|
||||
*
|
||||
* - 번역된 테마 데이터가 없다면 번역하여 반환
|
||||
* - 번역된 데이터가 있다면 번역된 데이터를 조회하여 반환
|
||||
*/
|
||||
// 표시용 테마 목록은 언어 컨텍스트에 따라 번역된 값을 반환해야 한다.
|
||||
// AudioContentThemeService가 번역/저장을 처리하므로 이를 사용한다.
|
||||
return audioContentThemeService.getActiveThemeOfContent(
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
)
|
||||
return audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
|
||||
.filter {
|
||||
it != "모닝콜" &&
|
||||
it != "알람" &&
|
||||
it != "슬립콜" &&
|
||||
it != "다시듣기" &&
|
||||
it != "ASMR" &&
|
||||
it != "릴레이" &&
|
||||
it != "챌린지" &&
|
||||
it != "자기소개"
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -77,39 +64,42 @@ class AudioContentMainService(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getNewContentFor2WeeksByTheme(
|
||||
isFree: Boolean,
|
||||
theme: String,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member,
|
||||
pageable: Pageable
|
||||
): GetNewContentAllResponse {
|
||||
/**
|
||||
* - AS-IS theme은 한글만 처리하도록 되어 있음
|
||||
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
|
||||
*/
|
||||
val isAdult = member.auth != null && isAdultContentVisible
|
||||
val themeListRaw = if (theme.isBlank()) {
|
||||
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 themeList = normalizeThemeForQuery(
|
||||
themes = themeListRaw,
|
||||
contentType = contentType,
|
||||
isAdult = isAdult
|
||||
)
|
||||
|
||||
val totalCount = repository.totalCountNewContentFor2Weeks(
|
||||
isFree,
|
||||
themeList,
|
||||
memberId = member.id!!,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
)
|
||||
val contentList = repository.findByThemeFor2Weeks(
|
||||
val items = repository.findByThemeFor2Weeks(
|
||||
isFree,
|
||||
cloudfrontHost = imageHost,
|
||||
memberId = member.id!!,
|
||||
theme = themeList,
|
||||
@@ -120,75 +110,7 @@ class AudioContentMainService(
|
||||
)
|
||||
.filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) }
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 번역된 테마명이 들어와도 한글 원문 테마로 조회되도록 정규화한다.
|
||||
*/
|
||||
private fun normalizeThemeForQuery(
|
||||
themes: List<String>,
|
||||
contentType: ContentType,
|
||||
isAdult: Boolean
|
||||
): List<String> {
|
||||
if (themes.isEmpty()) return themes
|
||||
|
||||
val themesWithIds = audioContentThemeRepository.getActiveThemeWithIdsOfContent(
|
||||
isAdult = isAdult,
|
||||
isFree = false,
|
||||
isPointAvailableOnly = false,
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
if (themesWithIds.isEmpty()) return themes
|
||||
|
||||
val idByKorean = themesWithIds.associate { it.theme to it.id }
|
||||
val koreanById = themesWithIds.associate { it.id to it.theme }
|
||||
|
||||
val locale = langContext.lang.code
|
||||
val translatedByTextToId = run {
|
||||
val ids = themesWithIds.map { it.id }
|
||||
if (ids.isEmpty()) {
|
||||
emptyMap()
|
||||
} else {
|
||||
contentThemeTranslationRepository
|
||||
.findByContentThemeIdInAndLocale(ids, locale)
|
||||
.associate { it.theme to it.contentThemeId }
|
||||
}
|
||||
}
|
||||
|
||||
return themes.asSequence()
|
||||
.map { input ->
|
||||
when {
|
||||
idByKorean.containsKey(input) -> input
|
||||
translatedByTextToId.containsKey(input) -> {
|
||||
val id = translatedByTextToId[input]!!
|
||||
koreanById[id] ?: input
|
||||
}
|
||||
|
||||
else -> input
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
.toList()
|
||||
return GetNewContentAllResponse(totalCount, items)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -58,7 +58,6 @@ 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,7 +187,6 @@ class OrderQueryRepositoryImpl(
|
||||
return queryFactory.select(order.id)
|
||||
.from(order)
|
||||
.where(where)
|
||||
.distinct()
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
@@ -18,9 +18,7 @@ import org.springframework.web.bind.annotation.RestController
|
||||
class ContentSeriesController(private val service: ContentSeriesService) {
|
||||
@GetMapping
|
||||
fun getSeriesList(
|
||||
@RequestParam(required = false) creatorId: Long?,
|
||||
@RequestParam(name = "isOriginal", required = false) isOriginal: Boolean? = null,
|
||||
@RequestParam(name = "isCompleted", required = false) isCompleted: Boolean? = null,
|
||||
@RequestParam creatorId: Long,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@@ -31,8 +29,6 @@ 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,7 +14,6 @@ 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
|
||||
@@ -24,35 +23,10 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
interface ContentSeriesRepository : JpaRepository<Series, Long>, ContentSeriesQueryRepository
|
||||
|
||||
interface ContentSeriesQueryRepository {
|
||||
fun getSeriesTotalCount(
|
||||
creatorId: Long?,
|
||||
isAuth: Boolean,
|
||||
contentType: ContentType,
|
||||
isOriginal: Boolean,
|
||||
isCompleted: Boolean
|
||||
): Int
|
||||
|
||||
fun getSeriesTotalCount(creatorId: Long, isAuth: Boolean, contentType: ContentType): Int
|
||||
fun getSeriesList(
|
||||
imageHost: String,
|
||||
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,
|
||||
creatorId: Long,
|
||||
isAuth: Boolean,
|
||||
contentType: ContentType,
|
||||
offset: Long,
|
||||
@@ -66,7 +40,6 @@ interface ContentSeriesQueryRepository {
|
||||
fun getOriginalAudioDramaList(
|
||||
isAdult: Boolean,
|
||||
contentType: ContentType,
|
||||
orderByRandom: Boolean = false,
|
||||
offset: Long = 0,
|
||||
limit: Long = 20
|
||||
): List<Series>
|
||||
@@ -86,26 +59,9 @@ interface ContentSeriesQueryRepository {
|
||||
class ContentSeriesQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : ContentSeriesQueryRepository {
|
||||
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))
|
||||
}
|
||||
override fun getSeriesTotalCount(creatorId: Long, isAuth: Boolean, contentType: ContentType): Int {
|
||||
var where = series.member.id.eq(creatorId)
|
||||
.and(series.isActive.isTrue)
|
||||
|
||||
if (!isAuth) {
|
||||
where = where.and(series.isAdult.isFalse)
|
||||
@@ -136,27 +92,14 @@ 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.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))
|
||||
}
|
||||
var where = series.member.id.eq(creatorId)
|
||||
.and(series.isActive.isTrue)
|
||||
|
||||
if (!isAuth) {
|
||||
where = where.and(series.isAdult.isFalse)
|
||||
@@ -176,105 +119,11 @@ class ContentSeriesQueryRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
} 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.id)
|
||||
.from(seriesContent)
|
||||
.innerJoin(seriesContent.series, series)
|
||||
.innerJoin(seriesContent.content, audioContent)
|
||||
.innerJoin(series.member, member)
|
||||
.innerJoin(series.genre, seriesGenre)
|
||||
.where(where)
|
||||
.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())
|
||||
.orderBy(series.orders.asc(), series.createdAt.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
@@ -367,7 +216,6 @@ class ContentSeriesQueryRepositoryImpl(
|
||||
override fun getOriginalAudioDramaList(
|
||||
isAdult: Boolean,
|
||||
contentType: ContentType,
|
||||
orderByRandom: Boolean,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<Series> {
|
||||
@@ -396,13 +244,7 @@ class ContentSeriesQueryRepositoryImpl(
|
||||
.selectFrom(series)
|
||||
.innerJoin(series.member, member)
|
||||
.where(where)
|
||||
.orderBy(
|
||||
if (orderByRandom) {
|
||||
Expressions.numberTemplate(Double::class.java, "function('rand')").asc()
|
||||
} else {
|
||||
series.id.desc()
|
||||
}
|
||||
)
|
||||
.orderBy(series.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
|
||||
@@ -6,26 +6,15 @@ import kr.co.vividnext.sodalive.content.order.OrderRepository
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.series.content.ContentSeriesContentRepository
|
||||
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
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.SeriesSortType
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||
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 kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -38,13 +27,6 @@ class ContentSeriesService(
|
||||
private val explorerQueryRepository: ExplorerQueryRepository,
|
||||
private val seriesContentRepository: ContentSeriesContentRepository,
|
||||
|
||||
private val langContext: LangContext,
|
||||
|
||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
private val translationService: PapagoTranslationService,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val coverImageHost: String
|
||||
) {
|
||||
@@ -55,151 +37,45 @@ 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, orderByRandom, offset, limit)
|
||||
return getTranslatedSeriesList(seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType))
|
||||
val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, offset, limit)
|
||||
return seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType)
|
||||
}
|
||||
|
||||
fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> {
|
||||
/**
|
||||
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
|
||||
* 번역이 없으면 번역 API 호출 후 저장하고 반환
|
||||
*/
|
||||
val genres = repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType)
|
||||
|
||||
val currentLang = langContext.lang
|
||||
if (currentLang == Lang.EN || currentLang == Lang.JA) {
|
||||
val targetLocale = currentLang.code
|
||||
val ids = genres.map { it.id }
|
||||
|
||||
// 기존 번역 일괄 조회
|
||||
val existing = if (ids.isNotEmpty()) {
|
||||
// 메서드가 없을 경우 개별 조회로 대체되지만, 성능을 위해 우선 시도
|
||||
try {
|
||||
seriesGenreTranslationRepository
|
||||
.findBySeriesGenreIdInAndLocale(ids, targetLocale)
|
||||
} catch (_: Exception) {
|
||||
// Spring Data 메서드 미존재 시 안전하게 개별 조회로 폴백
|
||||
ids.mapNotNull { id ->
|
||||
seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(id, targetLocale)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val existingMap = existing.associateBy { it.seriesGenreId }.toMutableMap()
|
||||
|
||||
// 미번역 항목 수집
|
||||
val untranslated = genres.filter { existingMap[it.id] == null }
|
||||
if (untranslated.isNotEmpty()) {
|
||||
val texts = untranslated.map { it.genre }
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = targetLocale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
val toSave = mutableListOf<kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation>()
|
||||
untranslated.forEachIndexed { index, item ->
|
||||
val translated = translatedTexts.getOrNull(index) ?: item.genre
|
||||
toSave.add(
|
||||
kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation(
|
||||
seriesGenreId = item.id,
|
||||
locale = targetLocale,
|
||||
genre = translated
|
||||
)
|
||||
)
|
||||
}
|
||||
if (toSave.isNotEmpty()) {
|
||||
seriesGenreTranslationRepository.saveAll(toSave)
|
||||
toSave.forEach { saved -> existingMap[saved.seriesGenreId] = saved }
|
||||
}
|
||||
}
|
||||
|
||||
// 원래 순서 보존하여 결과 조립
|
||||
return genres.map { g ->
|
||||
val translated = existingMap[g.id]?.genre ?: g.genre
|
||||
GetSeriesGenreListResponse(id = g.id, genre = translated)
|
||||
}
|
||||
}
|
||||
|
||||
return genres
|
||||
return repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType)
|
||||
}
|
||||
|
||||
fun getSeriesList(
|
||||
creatorId: Long?,
|
||||
isOriginal: Boolean = false,
|
||||
isCompleted: Boolean = false,
|
||||
orderByRandom: Boolean = false,
|
||||
creatorId: Long,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member,
|
||||
offset: Long = 0,
|
||||
limit: Long = 20
|
||||
limit: Long = 10
|
||||
): GetSeriesListResponse {
|
||||
val isAuth = member.auth != null && isAdultContentVisible
|
||||
|
||||
val totalCount = repository.getSeriesTotalCount(
|
||||
creatorId = creatorId,
|
||||
isAuth = isAuth,
|
||||
contentType = contentType,
|
||||
isOriginal = isOriginal,
|
||||
isCompleted = isCompleted
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
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, getTranslatedSeriesList(items))
|
||||
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!!) }
|
||||
|
||||
val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType)
|
||||
return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items))
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun getSeriesDetail(
|
||||
seriesId: Long,
|
||||
isAdultContentVisible: Boolean,
|
||||
@@ -241,115 +117,7 @@ class ContentSeriesService(
|
||||
limit = 5
|
||||
)
|
||||
|
||||
/**
|
||||
* series.languageCode != null && series.languageCode != languageCode
|
||||
*
|
||||
* 번역 시리즈를 조회한다. - series, locale
|
||||
* 번역 콘텐츠가 있으면
|
||||
* TranslatedSeries로 가공한다
|
||||
*
|
||||
* 번역 콘텐츠가 없으면
|
||||
* 파파고 API를 통해 번역한 후 저장한다.
|
||||
*
|
||||
* 번역 대상: title, introduction, keywordList
|
||||
*
|
||||
* 파파고로 번역한 데이터를 TranslatedSeries 가공한다
|
||||
*/
|
||||
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd")
|
||||
|
||||
// 요청된 언어(locale)에 대한 시리즈 번역을 조회하거나, 없으면 동기 번역 후 저장한다.
|
||||
var translated: TranslatedSeries? = null
|
||||
run {
|
||||
val locale = langContext.lang.code
|
||||
val languageCode = series.languageCode
|
||||
// 원본 언어가 존재하고, 요청 언어와 다를 때만 번역 처리
|
||||
if (!languageCode.isNullOrBlank() && languageCode != locale) {
|
||||
val existing = seriesTranslationRepository.findBySeriesIdAndLocale(seriesId = seriesId, locale = locale)
|
||||
if (existing != null) {
|
||||
val payload = existing.renderedPayload
|
||||
val kws = payload.keywords.ifEmpty { keywordList }
|
||||
translated = TranslatedSeries(
|
||||
title = payload.title,
|
||||
introduction = payload.introduction,
|
||||
keywords = kws
|
||||
)
|
||||
} else {
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(series.title)
|
||||
texts.add(series.introduction)
|
||||
// 키워드는 개별 항목으로 번역 요청하여 N회 호출을 방지한다.
|
||||
val keywordListForTranslate = keywordList
|
||||
texts.addAll(keywordListForTranslate)
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = languageCode,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
val translatedTitle = translatedTexts[index++]
|
||||
val translatedIntroduction = translatedTexts[index++]
|
||||
val translatedKeywords = if (keywordListForTranslate.isNotEmpty()) {
|
||||
translatedTexts.subList(index, translatedTexts.size)
|
||||
} else {
|
||||
// 번역할 키워드가 없으면 원본 키워드 반환 정책 적용
|
||||
keywordList
|
||||
}
|
||||
|
||||
val payload = SeriesTranslationPayload(
|
||||
title = translatedTitle,
|
||||
introduction = translatedIntroduction,
|
||||
keywords = translatedKeywords
|
||||
)
|
||||
|
||||
seriesTranslationRepository.save(
|
||||
SeriesTranslation(
|
||||
seriesId = seriesId,
|
||||
locale = locale,
|
||||
renderedPayload = payload
|
||||
)
|
||||
)
|
||||
|
||||
val kws = translatedKeywords.ifEmpty { keywordList }
|
||||
translated = TranslatedSeries(
|
||||
title = translatedTitle,
|
||||
introduction = translatedIntroduction,
|
||||
keywords = kws
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 장르 번역 조회 (있으면 반환)
|
||||
val translatedGenre: String? = run {
|
||||
val genreId = series.genre?.id
|
||||
if (genreId != null) {
|
||||
val locale = langContext.lang.code
|
||||
val found = seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(genreId, locale)
|
||||
val text = found?.genre
|
||||
if (!text.isNullOrBlank()) {
|
||||
text
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// publishedDateUtc는 ISO8601(Z 포함)로 반환
|
||||
val publishedDateUtc = series.createdAt!!
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.toInstant()
|
||||
.toString()
|
||||
|
||||
return GetSeriesDetailResponse(
|
||||
seriesId = seriesId,
|
||||
title = series.title,
|
||||
@@ -364,7 +132,6 @@ class ContentSeriesService(
|
||||
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||
.toLocalDateTime()
|
||||
.format(dateTimeFormatter),
|
||||
publishedDateUtc = publishedDateUtc,
|
||||
creator = GetSeriesDetailResponse.GetSeriesDetailCreator(
|
||||
creatorId = series.member!!.id!!,
|
||||
nickname = series.member!!.nickname,
|
||||
@@ -380,9 +147,7 @@ class ContentSeriesService(
|
||||
keywordList = keywordList,
|
||||
publishedDaysOfWeek = publishedDaysOfWeekText(series.publishedDaysOfWeek),
|
||||
contentList = seriesContentList.items,
|
||||
contentCount = seriesContentList.totalCount,
|
||||
translated = translated,
|
||||
translatedGenre = translatedGenre
|
||||
contentCount = seriesContentList.totalCount
|
||||
)
|
||||
}
|
||||
|
||||
@@ -424,33 +189,7 @@ class ContentSeriesService(
|
||||
it
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||
*
|
||||
* 처리 절차:
|
||||
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
||||
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
|
||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||
*
|
||||
* 성능:
|
||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||
*/
|
||||
val contentIds = contentList.map { it.contentId }
|
||||
val translatedItems = 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 GetSeriesContentListResponse(totalCount, translatedItems)
|
||||
return GetSeriesContentListResponse(totalCount, contentList)
|
||||
}
|
||||
|
||||
fun getRecommendSeriesList(
|
||||
@@ -462,16 +201,10 @@ class ContentSeriesService(
|
||||
val seriesList = repository.getRecommendSeriesList(
|
||||
isAuth = isAuth,
|
||||
contentType = contentType,
|
||||
limit = 20
|
||||
limit = 10
|
||||
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
|
||||
|
||||
return getTranslatedSeriesList(
|
||||
seriesToSeriesListItem(
|
||||
seriesList = seriesList,
|
||||
isAdult = isAuth,
|
||||
contentType = contentType
|
||||
)
|
||||
)
|
||||
return seriesToSeriesListItem(seriesList = seriesList, isAdult = isAuth, contentType = contentType)
|
||||
}
|
||||
|
||||
fun fetchSeriesByCurationId(
|
||||
@@ -486,7 +219,7 @@ class ContentSeriesService(
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
)
|
||||
return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType))
|
||||
return seriesToSeriesListItem(seriesList, isAdult, contentType)
|
||||
}
|
||||
|
||||
fun getDayOfWeekSeriesList(
|
||||
@@ -516,7 +249,7 @@ class ContentSeriesService(
|
||||
seriesList
|
||||
}
|
||||
|
||||
return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType))
|
||||
return seriesToSeriesListItem(seriesList, isAdult, contentType)
|
||||
}
|
||||
|
||||
private fun seriesToSeriesListItem(
|
||||
@@ -566,105 +299,27 @@ class ContentSeriesService(
|
||||
}
|
||||
|
||||
private fun publishedDaysOfWeekText(publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>): String {
|
||||
/**
|
||||
* i18n을 적용하여 언어별로 요일 표시를 변경한다.
|
||||
*/
|
||||
val lang = langContext.lang
|
||||
|
||||
val labelRandom = when (lang) {
|
||||
Lang.EN -> "Random"
|
||||
Lang.JA -> "ランダム"
|
||||
else -> "랜덤"
|
||||
}
|
||||
val labels = when (lang) {
|
||||
Lang.EN -> mapOf(
|
||||
SeriesPublishedDaysOfWeek.SUN to "Sun",
|
||||
SeriesPublishedDaysOfWeek.MON to "Mon",
|
||||
SeriesPublishedDaysOfWeek.TUE to "Tue",
|
||||
SeriesPublishedDaysOfWeek.WED to "Wed",
|
||||
SeriesPublishedDaysOfWeek.THU to "Thu",
|
||||
SeriesPublishedDaysOfWeek.FRI to "Fri",
|
||||
SeriesPublishedDaysOfWeek.SAT to "Sat",
|
||||
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
|
||||
)
|
||||
|
||||
Lang.JA -> mapOf(
|
||||
SeriesPublishedDaysOfWeek.SUN to "日",
|
||||
SeriesPublishedDaysOfWeek.MON to "月",
|
||||
SeriesPublishedDaysOfWeek.TUE to "火",
|
||||
SeriesPublishedDaysOfWeek.WED to "水",
|
||||
SeriesPublishedDaysOfWeek.THU to "木",
|
||||
SeriesPublishedDaysOfWeek.FRI to "金",
|
||||
SeriesPublishedDaysOfWeek.SAT to "土",
|
||||
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
|
||||
)
|
||||
|
||||
else -> mapOf(
|
||||
SeriesPublishedDaysOfWeek.SUN to "일",
|
||||
SeriesPublishedDaysOfWeek.MON to "월",
|
||||
SeriesPublishedDaysOfWeek.TUE to "화",
|
||||
SeriesPublishedDaysOfWeek.WED to "수",
|
||||
SeriesPublishedDaysOfWeek.THU to "목",
|
||||
SeriesPublishedDaysOfWeek.FRI to "금",
|
||||
SeriesPublishedDaysOfWeek.SAT to "토",
|
||||
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
|
||||
)
|
||||
}
|
||||
|
||||
val dayOfWeekText = publishedDaysOfWeek.toList().sortedBy { it.ordinal }
|
||||
.map { labels[it] ?: it.name }
|
||||
.map {
|
||||
when (it) {
|
||||
SeriesPublishedDaysOfWeek.SUN -> "일"
|
||||
SeriesPublishedDaysOfWeek.MON -> "월"
|
||||
SeriesPublishedDaysOfWeek.TUE -> "화"
|
||||
SeriesPublishedDaysOfWeek.WED -> "수"
|
||||
SeriesPublishedDaysOfWeek.THU -> "목"
|
||||
SeriesPublishedDaysOfWeek.FRI -> "금"
|
||||
SeriesPublishedDaysOfWeek.SAT -> "토"
|
||||
SeriesPublishedDaysOfWeek.RANDOM -> "랜덤"
|
||||
}
|
||||
}
|
||||
.joinToString(", ") { it }
|
||||
|
||||
val containsRandom = publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM)
|
||||
return if (containsRandom) {
|
||||
return if (publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM)) {
|
||||
dayOfWeekText
|
||||
} else if (publishedDaysOfWeek.size < 7) {
|
||||
when (lang) {
|
||||
Lang.EN -> "Every $dayOfWeekText"
|
||||
Lang.JA -> "毎週 $dayOfWeekText"
|
||||
else -> "매주 $dayOfWeekText"
|
||||
}
|
||||
"매주 $dayOfWeekText"
|
||||
} else {
|
||||
when (lang) {
|
||||
Lang.EN -> "Daily"
|
||||
Lang.JA -> "毎日"
|
||||
else -> "매일"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||
*
|
||||
* 처리 절차:
|
||||
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
||||
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
|
||||
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||
*
|
||||
* 성능:
|
||||
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||
*
|
||||
* @param seriesList 번역 대상 SeriesListItem 목록
|
||||
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
|
||||
*/
|
||||
private fun getTranslatedSeriesList(
|
||||
seriesList: List<GetSeriesListResponse.SeriesListItem>
|
||||
): List<GetSeriesListResponse.SeriesListItem> {
|
||||
val seriesIds = seriesList.map { it.seriesId }
|
||||
if (seriesIds.isEmpty()) return seriesList
|
||||
|
||||
val translations = seriesTranslationRepository
|
||||
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
|
||||
.associateBy { it.seriesId }
|
||||
|
||||
return seriesList.map { item ->
|
||||
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
|
||||
if (translatedTitle.isNullOrBlank()) {
|
||||
item
|
||||
} else {
|
||||
item.copy(title = translatedTitle)
|
||||
}
|
||||
"매일"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.content.series
|
||||
|
||||
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListItem
|
||||
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
|
||||
|
||||
data class GetSeriesDetailResponse(
|
||||
val seriesId: Long,
|
||||
@@ -13,7 +12,6 @@ data class GetSeriesDetailResponse(
|
||||
val writer: String?,
|
||||
val studio: String?,
|
||||
val publishedDate: String,
|
||||
val publishedDateUtc: String,
|
||||
val creator: GetSeriesDetailCreator,
|
||||
var rentalMinPrice: Int,
|
||||
var rentalMaxPrice: Int,
|
||||
@@ -23,9 +21,7 @@ data class GetSeriesDetailResponse(
|
||||
val keywordList: List<String>,
|
||||
val publishedDaysOfWeek: String,
|
||||
val contentList: List<GetSeriesContentListItem>,
|
||||
val contentCount: Int,
|
||||
val translated: TranslatedSeries?,
|
||||
val translatedGenre: String?
|
||||
val contentCount: Int
|
||||
) {
|
||||
data class GetSeriesDetailCreator(
|
||||
val creatorId: Long,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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>
|
||||
)
|
||||
@@ -1,151 +0,0 @@
|
||||
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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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()
|
||||
@@ -1,15 +0,0 @@
|
||||
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?
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.content.series.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(columnNames = ["series_genre_id", "locale"])
|
||||
]
|
||||
)
|
||||
class SeriesGenreTranslation(
|
||||
@Column(name = "series_genre_id")
|
||||
val seriesGenreId: Long,
|
||||
@Column(name = "locale")
|
||||
val locale: String,
|
||||
var genre: String
|
||||
) : BaseEntity()
|
||||
@@ -1,9 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.content.series.translation
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface SeriesGenreTranslationRepository : JpaRepository<SeriesGenreTranslation, Long> {
|
||||
fun findBySeriesGenreIdAndLocale(seriesGenreId: Long, locale: String): SeriesGenreTranslation?
|
||||
|
||||
fun findBySeriesGenreIdInAndLocale(seriesGenreIds: List<Long>, locale: String): List<SeriesGenreTranslation>
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.content.series.translation
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
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
|
||||
|
||||
@Entity
|
||||
class SeriesTranslation(
|
||||
val seriesId: Long,
|
||||
val locale: String,
|
||||
|
||||
@Column(columnDefinition = "json")
|
||||
@Convert(converter = SeriesTranslationPayloadConverter::class)
|
||||
var renderedPayload: SeriesTranslationPayload
|
||||
) : BaseEntity()
|
||||
|
||||
data class SeriesTranslationPayload(
|
||||
val title: String,
|
||||
val introduction: String,
|
||||
val keywords: List<String>
|
||||
)
|
||||
|
||||
@Converter(autoApply = false)
|
||||
class SeriesTranslationPayloadConverter : AttributeConverter<SeriesTranslationPayload, String> {
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: SeriesTranslationPayload?): String {
|
||||
if (attribute == null) return "{}"
|
||||
return objectMapper.writeValueAsString(attribute)
|
||||
}
|
||||
|
||||
override fun convertToEntityAttribute(dbData: String?): SeriesTranslationPayload {
|
||||
if (dbData.isNullOrBlank()) {
|
||||
return SeriesTranslationPayload(
|
||||
title = "",
|
||||
introduction = "",
|
||||
keywords = emptyList()
|
||||
)
|
||||
}
|
||||
// 호환 처리: 과거 스키마에서 keywords가 String 이었을 수 있으므로 유연하게 파싱한다.
|
||||
return try {
|
||||
val node = objectMapper.readTree(dbData)
|
||||
val title = node.get("title")?.asText() ?: ""
|
||||
val introduction = node.get("introduction")?.asText() ?: ""
|
||||
val keywordsNode = node.get("keywords")
|
||||
val keywords: List<String> = when {
|
||||
keywordsNode == null || keywordsNode.isNull -> emptyList()
|
||||
keywordsNode.isArray -> keywordsNode.mapNotNull { it.asText(null) }.filter { it.isNotBlank() }
|
||||
keywordsNode.isTextual -> listOfNotNull(keywordsNode.asText()).filter { it.isNotBlank() }
|
||||
else -> emptyList()
|
||||
}
|
||||
SeriesTranslationPayload(
|
||||
title = title,
|
||||
introduction = introduction,
|
||||
keywords = keywords
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// 파싱 실패 시 안전한 기본값 반환
|
||||
SeriesTranslationPayload(
|
||||
title = "",
|
||||
introduction = "",
|
||||
keywords = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val objectMapper = jacksonObjectMapper()
|
||||
}
|
||||
}
|
||||
|
||||
data class TranslatedSeries(
|
||||
val title: String,
|
||||
val introduction: String,
|
||||
val keywords: List<String>
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.content.series.translation
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface SeriesTranslationRepository : JpaRepository<SeriesTranslation, Long> {
|
||||
fun findBySeriesIdAndLocale(seriesId: Long, locale: String): SeriesTranslation?
|
||||
fun findBySeriesIdInAndLocale(seriesIds: List<Long>, locale: String): List<SeriesTranslation>
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import javax.persistence.Table
|
||||
|
||||
@Entity
|
||||
@Table(name = "content_theme")
|
||||
class AudioContentTheme(
|
||||
data class AudioContentTheme(
|
||||
@Column(nullable = false)
|
||||
var theme: String,
|
||||
@Column(nullable = false)
|
||||
|
||||
@@ -27,26 +27,6 @@ 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,6 +1,5 @@
|
||||
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
|
||||
@@ -15,10 +14,6 @@ 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(
|
||||
@@ -37,7 +32,6 @@ class AudioContentThemeQueryRepository(
|
||||
fun getActiveThemeOfContent(
|
||||
isAdult: Boolean = false,
|
||||
isFree: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false,
|
||||
contentType: ContentType
|
||||
): List<String> {
|
||||
var where = audioContent.isActive.isTrue
|
||||
@@ -65,94 +59,15 @@ class AudioContentThemeQueryRepository(
|
||||
where = where.and(audioContent.price.loe(0))
|
||||
}
|
||||
|
||||
if (isPointAvailableOnly) {
|
||||
where = where.and(audioContent.isPointAvailable.isTrue)
|
||||
}
|
||||
|
||||
val query = queryFactory
|
||||
return queryFactory
|
||||
.select(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()
|
||||
}
|
||||
|
||||
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)!!
|
||||
)
|
||||
}
|
||||
.orderBy(audioContentTheme.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun findThemeByIdAndActive(id: Long): AudioContentTheme? {
|
||||
|
||||
@@ -5,12 +5,6 @@ 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
|
||||
@@ -18,94 +12,24 @@ import org.springframework.transaction.annotation.Transactional
|
||||
@Service
|
||||
class AudioContentThemeService(
|
||||
private val queryRepository: AudioContentThemeQueryRepository,
|
||||
private val contentRepository: AudioContentRepository,
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
|
||||
private val papagoTranslationService: PapagoTranslationService,
|
||||
private val langContext: LangContext
|
||||
private val contentRepository: AudioContentRepository
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getThemes(): List<GetAudioContentThemeResponse> {
|
||||
return queryRepository.getActiveThemes()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Transactional(readOnly = true)
|
||||
fun getActiveThemeOfContent(
|
||||
isAdult: Boolean = false,
|
||||
isFree: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false,
|
||||
contentType: ContentType
|
||||
): List<String> {
|
||||
val themesWithIds = queryRepository.getActiveThemeWithIdsOfContent(
|
||||
return queryRepository.getActiveThemeOfContent(
|
||||
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)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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()
|
||||
@@ -1,9 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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?
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.content.translation
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface ContentTranslationRepository : JpaRepository<ContentTranslation, Long> {
|
||||
fun findByContentIdAndLocale(contentId: Long, locale: String): ContentTranslation?
|
||||
|
||||
fun findByContentIdInAndLocale(contentIds: List<Long>, locale: String): List<ContentTranslation>
|
||||
}
|
||||
@@ -10,12 +10,9 @@ import kr.co.vividnext.sodalive.content.ContentPriceChangeLogRepository
|
||||
import kr.co.vividnext.sodalive.content.hashtag.AudioContentHashTag
|
||||
import kr.co.vividnext.sodalive.content.hashtag.HashTag
|
||||
import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
@@ -30,8 +27,6 @@ class CreatorAdminContentService(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val s3Uploader: S3Uploader,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String,
|
||||
|
||||
@@ -199,13 +194,6 @@ class CreatorAdminContentService(
|
||||
}
|
||||
|
||||
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = request.id,
|
||||
targetType = LanguageTranslationTargetType.CONTENT
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
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.hashtag.HashTag
|
||||
import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest
|
||||
@@ -14,12 +12,9 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveConte
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
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
|
||||
@@ -35,8 +30,6 @@ class CreatorAdminContentSeriesService(
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val objectMapper: ObjectMapper,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val coverImageBucket: String,
|
||||
|
||||
@@ -96,31 +89,6 @@ class CreatorAdminContentSeriesService(
|
||||
)
|
||||
|
||||
series.coverImage = coverImagePath
|
||||
|
||||
if (series.languageCode.isNullOrBlank()) {
|
||||
val papagoQuery = listOf(
|
||||
request.title.trim(),
|
||||
request.introduction.trim(),
|
||||
request.keyword.trim()
|
||||
)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = series.id!!,
|
||||
query = papagoQuery,
|
||||
targetType = LanguageDetectTargetType.SERIES
|
||||
)
|
||||
)
|
||||
} else {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = series.id!!,
|
||||
targetType = LanguageTranslationTargetType.SERIES
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -206,15 +174,6 @@ class CreatorAdminContentSeriesService(
|
||||
if (request.studio != null) {
|
||||
series.studio = request.studio
|
||||
}
|
||||
|
||||
if (request.title != null || request.introduction != null) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = series.id!!,
|
||||
targetType = LanguageTranslationTargetType.SERIES
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSeriesList(offset: Long, limit: Long, creatorId: Long): GetCreatorAdminContentSeriesListResponse {
|
||||
|
||||
@@ -34,7 +34,6 @@ data class Series(
|
||||
var title: String,
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var introduction: String,
|
||||
var languageCode: String? = null,
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
var state: SeriesState = SeriesState.PROCEEDING,
|
||||
var writer: String? = null,
|
||||
|
||||
@@ -10,7 +10,6 @@ import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
||||
import kr.co.vividnext.sodalive.explorer.QCreatorRanking.creatorRanking
|
||||
import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListDto
|
||||
import kr.co.vividnext.sodalive.explorer.follower.QGetFollowerListDto
|
||||
@@ -40,7 +39,6 @@ import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@Repository
|
||||
class ExplorerQueryRepository(
|
||||
@@ -355,6 +353,7 @@ class ExplorerQueryRepository(
|
||||
creatorId: Long,
|
||||
userMember: Member,
|
||||
timezone: String,
|
||||
limit: Int,
|
||||
offset: Long = 0
|
||||
): List<LiveRoomResponse> {
|
||||
var where = liveRoom.member.id.eq(creatorId)
|
||||
@@ -393,14 +392,6 @@ class ExplorerQueryRepository(
|
||||
val beginDateTime = it.beginDateTime
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.withZoneSameInstant(ZoneId.of(timezone))
|
||||
.format(
|
||||
DateTimeFormatter
|
||||
.ofPattern("yyyy년 MM월 dd일 (E) a hh시 mm분")
|
||||
.withLocale(Locale.KOREAN)
|
||||
)
|
||||
|
||||
val beginDateTimeUtc = it.beginDateTime
|
||||
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||
|
||||
val isPaid = if (it.channelName != null) {
|
||||
val useCan = queryFactory
|
||||
@@ -424,8 +415,9 @@ class ExplorerQueryRepository(
|
||||
title = it.title,
|
||||
content = it.notice,
|
||||
isPaid = isPaid,
|
||||
beginDateTime = beginDateTime,
|
||||
beginDateTimeUtc = beginDateTimeUtc,
|
||||
beginDateTime = beginDateTime.format(
|
||||
DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")
|
||||
),
|
||||
isAdult = it.isAdult,
|
||||
price = it.price,
|
||||
channelName = it.channelName,
|
||||
@@ -488,7 +480,6 @@ class ExplorerQueryRepository(
|
||||
"$cloudFrontHost/profile/default-profile.png"
|
||||
},
|
||||
content = it.cheers,
|
||||
languageCode = it.languageCode,
|
||||
date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
|
||||
replyList = it.children.asSequence()
|
||||
.map { cheers ->
|
||||
@@ -506,7 +497,6 @@ class ExplorerQueryRepository(
|
||||
"$cloudFrontHost/profile/default-profile.png"
|
||||
},
|
||||
content = cheers.cheers,
|
||||
languageCode = cheers.languageCode,
|
||||
date = replyDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
|
||||
replyList = listOf()
|
||||
)
|
||||
@@ -663,28 +653,6 @@ class ExplorerQueryRepository(
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
fun getOwnedContentCount(creatorId: Long, memberId: Long): Long {
|
||||
// 활성 주문 + 대여의 경우 유효기간 내 주문만 포함, 동일 콘텐츠 중복 구매는 1개로 카운트
|
||||
return queryFactory
|
||||
.select(audioContent.id)
|
||||
.from(order)
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.where(
|
||||
order.isActive.isTrue,
|
||||
order.member.id.eq(memberId),
|
||||
audioContent.member.id.eq(creatorId),
|
||||
order.type.eq(kr.co.vividnext.sodalive.content.order.OrderType.KEEP)
|
||||
.or(
|
||||
order.type.eq(kr.co.vividnext.sodalive.content.order.OrderType.RENTAL)
|
||||
.and(order.endDate.after(LocalDateTime.now()))
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
.fetch()
|
||||
.size
|
||||
.toLong()
|
||||
}
|
||||
|
||||
fun getVisibleDonationRank(creatorId: Long): Boolean {
|
||||
return queryFactory
|
||||
.select(member.isVisibleDonationRank)
|
||||
|
||||
@@ -3,11 +3,8 @@ package kr.co.vividnext.sodalive.explorer
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.content.SortType
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponse
|
||||
import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponseItem
|
||||
import kr.co.vividnext.sodalive.explorer.profile.ChannelNotice
|
||||
@@ -19,7 +16,6 @@ import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||
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.live.room.detail.GetRoomDetailUser
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
@@ -48,9 +44,6 @@ class ExplorerService(
|
||||
private val seriesService: ContentSeriesService,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
|
||||
private val langContext: LangContext,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
@@ -83,7 +76,7 @@ class ExplorerService(
|
||||
)
|
||||
}
|
||||
|
||||
fun getExplorer(member: Member): GetExplorerResponse {
|
||||
fun getExplorer(member: Member, growthRankingCreatorsLimit: Long = 20): GetExplorerResponse {
|
||||
val sections = mutableListOf<GetExplorerSectionResponse>()
|
||||
|
||||
// 인기 크리에이터
|
||||
@@ -216,7 +209,8 @@ class ExplorerService(
|
||||
queryRepository.getLiveRoomList(
|
||||
creatorId,
|
||||
userMember = member,
|
||||
timezone = timezone
|
||||
timezone = timezone,
|
||||
limit = 3
|
||||
)
|
||||
} else {
|
||||
listOf()
|
||||
@@ -237,45 +231,6 @@ class ExplorerService(
|
||||
listOf()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 크리에이터의 최신 오디오 콘텐츠 1개
|
||||
val latestContent = if (isCreator) {
|
||||
audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 크리에이터의 전체 콘텐츠 개수
|
||||
val totalContentCount = if (isCreator) {
|
||||
queryRepository.getContentCount(creatorId) ?: 0
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
// 조회하는 유저가 소장 중인 크리에이터의 콘텐츠 개수
|
||||
val ownedContentCount = if (isCreator) {
|
||||
queryRepository.getOwnedContentCount(creatorId, member.id!!)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
// 공지사항
|
||||
val notice = if (isCreator) {
|
||||
queryRepository.getNoticeString(creatorId)
|
||||
@@ -355,10 +310,7 @@ class ExplorerService(
|
||||
userDonationRanking = memberDonationRanking,
|
||||
similarCreatorList = similarCreatorList,
|
||||
liveRoomList = liveRoomList,
|
||||
contentList = translatedContentList,
|
||||
latestContent = latestContent,
|
||||
totalContentCount = totalContentCount,
|
||||
ownedContentCount = ownedContentCount,
|
||||
contentList = contentList,
|
||||
notice = notice,
|
||||
communityPostList = communityPostList,
|
||||
cheers = cheers,
|
||||
@@ -466,7 +418,7 @@ class ExplorerService(
|
||||
val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = request.creatorId)
|
||||
if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 팬토크 작성이 제한됩니다.")
|
||||
|
||||
val cheers = CreatorCheers(cheers = request.content, languageCode = request.languageCode)
|
||||
val cheers = CreatorCheers(cheers = request.content)
|
||||
cheers.member = member
|
||||
cheers.creator = creator
|
||||
|
||||
@@ -481,17 +433,6 @@ class ExplorerService(
|
||||
}
|
||||
|
||||
cheersRepository.save(cheers)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (request.languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = cheers.id!!,
|
||||
query = request.content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_CHEERS
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCreatorProfileCheers(
|
||||
|
||||
@@ -11,7 +11,6 @@ data class GetCheersResponseItem(
|
||||
val nickname: String,
|
||||
val profileUrl: String,
|
||||
val content: String,
|
||||
val languageCode: String?,
|
||||
val date: String,
|
||||
val replyList: List<GetCheersResponseItem>
|
||||
)
|
||||
|
||||
@@ -10,9 +10,6 @@ data class GetCreatorProfileResponse(
|
||||
val similarCreatorList: List<SimilarCreatorResponse>,
|
||||
val liveRoomList: List<LiveRoomResponse>,
|
||||
val contentList: List<GetAudioContentListItem>,
|
||||
val latestContent: GetAudioContentListItem?,
|
||||
val totalContentCount: Long,
|
||||
val ownedContentCount: Long,
|
||||
val notice: String,
|
||||
val communityPostList: List<GetCommunityPostListResponse>,
|
||||
val cheers: GetCheersResponse,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package kr.co.vividnext.sodalive.explorer
|
||||
|
||||
data class GetLiveRoomAllResponse(
|
||||
val totalCount: Int,
|
||||
val liveRoomList: List<LiveRoomResponse>
|
||||
)
|
||||
|
||||
data class LiveRoomResponse(
|
||||
val roomId: Long,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val isPaid: Boolean,
|
||||
val beginDateTime: String,
|
||||
val beginDateTimeUtc: String,
|
||||
val coverImageUrl: String,
|
||||
val isAdult: Boolean,
|
||||
val price: Int,
|
||||
|
||||
@@ -13,7 +13,6 @@ import javax.persistence.OneToMany
|
||||
data class CreatorCheers(
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var cheers: String,
|
||||
var languageCode: String?,
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
|
||||
@@ -3,6 +3,5 @@ package kr.co.vividnext.sodalive.explorer.profile
|
||||
data class PostWriteCheersRequest(
|
||||
val parentId: Long? = null,
|
||||
val creatorId: Long,
|
||||
val content: String,
|
||||
val languageCode: String? = null
|
||||
val content: String
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user