feat(creatorname): 크리에이터 언어별 이름 관리와 공통 조회를 추가한다

This commit is contained in:
2026-10-16 14:03:21 +09:00
parent 9da27d3feb
commit 698cdc1fe3
19 changed files with 1443 additions and 3 deletions
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
@@ -22,6 +23,7 @@ class AdminMemberService(
private val passwordEncoder: PasswordEncoder,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
private val creatorLocalizedNameService: CreatorLocalizedNameService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
@@ -93,6 +95,8 @@ class AdminMemberService(
private fun processMemberListToGetAdminMemberListResponseItemList(
memberList: List<Member>
): List<GetAdminMemberListResponseItem> {
val creatorIds = memberList.filter { it.role == MemberRole.CREATOR }.map { requireNotNull(it.id) }
val localizedNames = creatorLocalizedNameService.findAllLocalizedNames(creatorIds)
return memberList
.asSequence()
.map {
@@ -145,7 +149,8 @@ class AdminMemberService(
auth = it.auth != null,
signUpDate = signUpDate,
signOutDate = signOutDate,
isActive = it.isActive
isActive = it.isActive,
localizedNames = localizedNames[it.id].orEmpty()
)
}
.toList()
@@ -16,5 +16,6 @@ data class GetAdminMemberListResponseItem(
val auth: Boolean,
val signUpDate: String,
val signOutDate: String,
val isActive: Boolean
val isActive: Boolean,
val localizedNames: Map<String, String> = emptyMap()
)
@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
import kr.co.vividnext.sodalive.v2.creatorname.dto.CreatorLocalizedNameRequest
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/admin/creators/{creatorId}/localized-names/ja")
@PreAuthorize("hasRole('ADMIN')")
class AdminCreatorLocalizedNameController(private val service: CreatorLocalizedNameService) {
@PutMapping
fun saveLocalizedName(@PathVariable creatorId: Long, @RequestBody request: CreatorLocalizedNameRequest) = ApiResponse.ok(
service.saveLocalizedName(creatorId, Lang.fromCode("ja"), request.displayName)
)
}
@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.ForeignKey
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "creator_localized_name",
uniqueConstraints = [UniqueConstraint(name = "uk_creator_localized_name_member_lang", columnNames = ["member_id", "lang"])]
)
class CreatorLocalizedNameEntity(
@Column(name = "member_id", nullable = false, updatable = false)
val memberId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "lang", nullable = false, length = 10, updatable = false)
val lang: Lang,
@Column(name = "display_name", nullable = false, length = 255)
var displayName: String,
@Column(name = "is_active", nullable = false)
var isActive: Boolean = true
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(
name = "member_id",
nullable = false,
insertable = false,
updatable = false,
foreignKey = ForeignKey(name = "fk_creator_localized_name_member")
)
private var member: Member? = null
fun toDomain(): CreatorLocalizedName = CreatorLocalizedName(memberId, lang, displayName, isActive, id)
}
@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.StringExpression
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
class CreatorNameJoin(owner: QMember, lang: Lang, alias: String) {
val name: QCreatorLocalizedNameEntity = QCreatorLocalizedNameEntity(alias)
val condition: BooleanExpression = name.memberId.eq(owner.id)
.and(name.lang.eq(lang))
.and(name.isActive.isTrue)
.and(owner.role.eq(MemberRole.CREATOR))
val displayName: StringExpression = name.displayName.coalesce(owner.nickname)
}
@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.JPAExpressions
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.QCreatorLocalizedNameEntity.creatorLocalizedNameEntity
fun creatorNameContains(owner: QMember, keyword: String): BooleanExpression {
val alias = creatorLocalizedNameEntity
return owner.nickname.containsIgnoreCase(keyword).or(
owner.role.eq(MemberRole.CREATOR).and(
JPAExpressions.selectOne().from(alias).where(
alias.memberId.eq(owner.id),
alias.lang.eq(Lang.JA),
alias.isActive.isTrue,
alias.displayName.containsIgnoreCase(keyword)
).exists()
)
)
}
@@ -0,0 +1,88 @@
package kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.QCreatorLocalizedNameEntity.creatorLocalizedNameEntity
import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName
import kr.co.vividnext.sodalive.v2.creatorname.port.out.CreatorLocalizedNameRepository
import org.springframework.stereotype.Repository
import javax.persistence.EntityManager
@Repository
class DefaultCreatorLocalizedNameRepository(
private val entityManager: EntityManager,
private val queryFactory: JPAQueryFactory
) : CreatorLocalizedNameRepository {
override fun findByMemberIdAndLang(memberId: Long, lang: Lang): CreatorLocalizedName? = queryFactory
.selectFrom(creatorLocalizedNameEntity)
.where(creatorLocalizedNameEntity.memberId.eq(memberId), creatorLocalizedNameEntity.lang.eq(lang))
.fetchOne()
?.toDomain()
override fun findActiveLocalizedNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String> {
if (memberIds.isEmpty()) return emptyMap()
return queryFactory
.select(creatorLocalizedNameEntity.memberId, creatorLocalizedNameEntity.displayName)
.from(creatorLocalizedNameEntity)
.innerJoin(member).on(member.id.eq(creatorLocalizedNameEntity.memberId))
.where(
creatorLocalizedNameEntity.memberId.`in`(memberIds),
creatorLocalizedNameEntity.lang.eq(lang),
creatorLocalizedNameEntity.isActive.isTrue,
member.role.eq(MemberRole.CREATOR)
)
.fetch()
.associate { row ->
requireNotNull(row[creatorLocalizedNameEntity.memberId]) to
requireNotNull(row[creatorLocalizedNameEntity.displayName])
}
}
override fun findAllActiveLocalizedNames(memberIds: Collection<Long>): Map<Long, Map<String, String>> {
if (memberIds.isEmpty()) return emptyMap()
return queryFactory
.select(creatorLocalizedNameEntity.memberId, creatorLocalizedNameEntity.lang, creatorLocalizedNameEntity.displayName)
.from(creatorLocalizedNameEntity)
.innerJoin(member).on(member.id.eq(creatorLocalizedNameEntity.memberId))
.where(
creatorLocalizedNameEntity.memberId.`in`(memberIds),
creatorLocalizedNameEntity.isActive.isTrue,
member.role.eq(MemberRole.CREATOR)
)
.fetch()
.groupBy { row -> requireNotNull(row[creatorLocalizedNameEntity.memberId]) }
.mapValues { (_, rows) ->
rows.associate { row ->
requireNotNull(row[creatorLocalizedNameEntity.lang]).code to
requireNotNull(row[creatorLocalizedNameEntity.displayName])
}
}
}
override fun findCurrentNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String> {
if (memberIds.isEmpty()) return emptyMap()
val join = CreatorNameJoin(member, lang, "currentCreatorName")
return queryFactory
.select(member.id, join.displayName)
.from(member)
.leftJoin(join.name).on(join.condition)
.where(member.id.`in`(memberIds.distinct()))
.fetch()
.associate { row -> requireNotNull(row[member.id]) to requireNotNull(row[join.displayName]) }
}
override fun save(name: CreatorLocalizedName): CreatorLocalizedName {
val entity = if (name.id == null) {
CreatorLocalizedNameEntity(name.memberId, name.lang, name.displayName, name.isActive)
.also { entityManager.persist(it) }
} else {
entityManager.find(CreatorLocalizedNameEntity::class.java, name.id).apply {
displayName = name.displayName
isActive = name.isActive
}
}
return entity.toDomain()
}
}
@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.v2.creatorname.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName
import kr.co.vividnext.sodalive.v2.creatorname.dto.CreatorLocalizedNameResponse
import kr.co.vividnext.sodalive.v2.creatorname.port.out.CreatorLocalizedNameRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class CreatorLocalizedNameService(
private val memberRepository: MemberRepository,
private val repository: CreatorLocalizedNameRepository
) {
fun findLocalizedNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String> {
if (memberIds.isEmpty()) return emptyMap()
return repository.findActiveLocalizedNames(memberIds, lang)
}
fun findCurrentNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String> {
if (memberIds.isEmpty()) return emptyMap()
return repository.findCurrentNames(memberIds, lang)
}
fun findAllLocalizedNames(memberIds: Collection<Long>): Map<Long, Map<String, String>> {
if (memberIds.isEmpty()) return emptyMap()
return repository.findAllActiveLocalizedNames(memberIds)
}
@Transactional
fun saveLocalizedName(creatorId: Long, lang: Lang, displayName: String): CreatorLocalizedNameResponse {
requireJapanese(lang)
if (displayName.codePointCount(0, displayName.length) > 255) {
throw SodaException(messageKey = "common.error.invalid_request", errorProperty = "displayName")
}
val creator = requireCreator(memberRepository.findByIdForUpdate(creatorId))
val existing = repository.findByMemberIdAndLang(creatorId, lang)
val name = if (displayName.isBlank()) {
existing?.copy(isActive = false)
} else {
existing?.copy(displayName = displayName, isActive = true) ?: CreatorLocalizedName(creatorId, lang, displayName)
}
name?.let { repository.save(it) }
return CreatorLocalizedNameResponse(creatorId, lang.code, creator.nickname, name?.takeIf { it.isActive }?.displayName)
}
private fun requireJapanese(lang: Lang) {
if (lang != Lang.JA) throw SodaException(messageKey = "common.error.invalid_request")
}
private fun requireCreator(member: Member?): Member {
val creator = member ?: throw SodaException(messageKey = "admin.member.not_found")
if (creator.role != MemberRole.CREATOR) throw SodaException(messageKey = "admin.live.creator_required")
return creator
}
}
@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.v2.creatorname.domain
import kr.co.vividnext.sodalive.i18n.Lang
data class CreatorLocalizedName(
val memberId: Long,
val lang: Lang,
val displayName: String,
val isActive: Boolean = true,
val id: Long? = null
)
@@ -0,0 +1,3 @@
package kr.co.vividnext.sodalive.v2.creatorname.dto
data class CreatorLocalizedNameRequest(val displayName: String)
@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.creatorname.dto
data class CreatorLocalizedNameResponse(
val creatorId: Long,
val locale: String,
val originalNickname: String,
val displayName: String?
)
@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.v2.creatorname.port.out
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.v2.creatorname.domain.CreatorLocalizedName
interface CreatorLocalizedNameRepository {
fun findByMemberIdAndLang(memberId: Long, lang: Lang): CreatorLocalizedName?
fun findActiveLocalizedNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String>
fun findAllActiveLocalizedNames(memberIds: Collection<Long>): Map<Long, Map<String, String>>
fun findCurrentNames(memberIds: Collection<Long>, lang: Lang): Map<Long, String>
fun save(name: CreatorLocalizedName): CreatorLocalizedName
}