Compare commits

...

5 Commits

Author SHA1 Message Date
c9c09c2998 Merge pull request 'test' (#358) from test into main
Reviewed-on: #358
2025-11-10 06:53:41 +00:00
26c09de7c9 feat(admin-can): 관리자 캔 충전 API를 다중 회원 일괄 충전으로 확장
- AdminCanChargeRequest: memberId → memberIds(List<Long>)로 변경
- AdminCanService.charge: memberIds 선조회 후 다건 충전 로직 추가
- 잘못된/비어있는 회원번호 검증 및 트랜잭션 롤백으로 정합성 보장

배경: 관리자 일괄 충전 요구사항 반영으로 여러 회원에게 동일 수량의 캔을 한 번에 충전할 수 있도록 개선. 중복 ID는 제거하여 중복 충전을 방지하고, 하나라도 유효하지 않으면 전체 롤백되도록 처리하여 데이터 정합성 확보.
2025-11-10 15:15:10 +09:00
82bd93c1ae feat(admin-member): 닉네임 검색으로 회원 id, nickname 반환 API 추가 2025-11-10 14:39:44 +09:00
e24e8372a8 feat(home): 포인트 사용 가능 콘텐츠 리스트 추가 2025-11-10 13:58:17 +09:00
eab7dc4521 feat(home-free-content): 최신 콘텐츠 조회 함수 getLatestContentByTheme에 orderbyRandom flag를 추가하여 랜덤으로 정렬한 후 데이터를 가져올 수 있도록 수정 2025-11-10 12:14:24 +09:00
10 changed files with 103 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
package kr.co.vividnext.sodalive.admin.can
data class AdminCanChargeRequest(
val memberId: Long,
val memberIds: List<Long>,
val method: String,
val can: Int
)

View File

@@ -40,12 +40,16 @@ 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 members = memberRepository.findAllById(ids).toList()
if (members.size != ids.size) throw SodaException("잘못된 회원번호 입니다.")
members.forEach { member ->
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
charge.title = "${request.can.moneyFormat()}"
charge.member = member
@@ -58,4 +62,5 @@ class AdminCanService(
member.pgRewardCan += charge.rewardCan
}
}
}

View File

@@ -36,6 +36,12 @@ class AdminMemberController(private val service: AdminMemberService) {
pageable: Pageable
) = ApiResponse.ok(service.searchMember(searchWord, pageable))
@GetMapping("/search-by-nickname")
fun searchMemberByNickname(
@RequestParam(value = "search_word") searchWord: String,
@RequestParam(value = "size", required = false) size: Int?
) = ApiResponse.ok(service.searchMemberByNickname(searchWord = searchWord, size = size ?: 20))
@GetMapping("/creator/all/list")
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())

View File

@@ -16,6 +16,7 @@ interface AdminMemberQueryRepository {
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
fun findByIdAndActive(memberId: Long): Member?
fun searchMemberByNickname(searchWord: String, limit: Long = 20): List<AdminSimpleMemberResponse>
}
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
@@ -121,4 +122,22 @@ class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
.orderBy(member.id.desc())
.fetchFirst()
}
override fun searchMemberByNickname(searchWord: String, limit: Long): List<AdminSimpleMemberResponse> {
return queryFactory
.select(
QAdminSimpleMemberResponse(
member.id,
member.nickname
)
)
.from(member)
.where(
member.nickname.contains(searchWord)
.and(member.isActive.isTrue)
)
.orderBy(member.id.desc())
.limit(limit)
.fetch()
}
}

View File

@@ -145,6 +145,12 @@ class AdminMemberService(
return repository.getCreatorAllList()
}
fun searchMemberByNickname(searchWord: String, size: Int = 20): List<AdminSimpleMemberResponse> {
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
val limit = if (size <= 0) 20 else size
return repository.searchMemberByNickname(searchWord = searchWord, limit = limit.toLong())
}
@Transactional
fun resetPassword(request: ResetPasswordRequest) {
val member = repository.findByIdAndActive(memberId = request.memberId)

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.admin.member
import com.querydsl.core.annotations.QueryProjection
/**
* 관리자용 간단 회원 응답 DTO
* 닉네임 검색 결과로 사용되며 charge 등에서 memberId 선택에 활용된다.
*/
data class AdminSimpleMemberResponse @QueryProjection constructor(
val id: Long,
val nickname: String
)

View File

@@ -26,5 +26,6 @@ data class GetHomeResponse(
val contentRanking: List<GetAudioContentRankingItem>,
val recommendChannelList: List<RecommendChannelResponse>,
val freeContentList: List<AudioContentMainItem>,
val pointAvailableContentList: List<AudioContentMainItem>,
val curationList: List<GetContentCurationResponse>
)

View File

@@ -165,7 +165,24 @@ class HomeService(
),
contentType = contentType,
isFree = true,
isAdult = isAdult
isAdult = isAdult,
orderByRandom = true
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
val pointAvailableContentList = contentService.getLatestContentByTheme(
theme = emptyList(),
contentType = contentType,
isFree = false,
isAdult = isAdult,
orderByRandom = true,
isPointAvailableOnly = true
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
@@ -195,6 +212,7 @@ class HomeService(
contentRanking = contentRanking,
recommendChannelList = recommendChannelList,
freeContentList = freeContentList,
pointAvailableContentList = pointAvailableContentList,
curationList = curationList
)
}

View File

@@ -183,7 +183,9 @@ interface AudioContentQueryRepository {
offset: Long,
limit: Long,
isFree: Boolean,
isAdult: Boolean
isAdult: Boolean,
orderByRandom: Boolean = false,
isPointAvailableOnly: Boolean = false
): List<AudioContentMainItem>
fun findContentByCurationId(
@@ -1308,7 +1310,9 @@ class AudioContentQueryRepositoryImpl(
offset: Long,
limit: Long,
isFree: Boolean,
isAdult: Boolean
isAdult: Boolean,
orderByRandom: Boolean,
isPointAvailableOnly: Boolean
): List<AudioContentMainItem> {
var where = audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull)
@@ -1343,6 +1347,16 @@ 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 {
audioContent.releaseDate.desc()
}
return queryFactory
.select(
QAudioContentMainItem(
@@ -1360,7 +1374,7 @@ class AudioContentQueryRepositoryImpl(
.where(where)
.offset(offset)
.limit(limit)
.orderBy(audioContent.releaseDate.desc())
.orderBy(orderBy)
.fetch()
}

View File

@@ -989,7 +989,9 @@ class AudioContentService(
offset: Long = 0,
limit: Long = 20,
isFree: Boolean = false,
isAdult: Boolean = false
isAdult: Boolean = false,
orderByRandom: Boolean = false,
isPointAvailableOnly: Boolean = false
): List<AudioContentMainItem> {
return repository.getLatestContentByTheme(
theme = theme,
@@ -997,7 +999,9 @@ class AudioContentService(
offset = offset,
limit = limit,
isFree = isFree,
isAdult = isAdult
isAdult = isAdult,
orderByRandom = orderByRandom,
isPointAvailableOnly = isPointAvailableOnly
)
}
}