diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index 986869f..b597a5c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -68,6 +68,7 @@ class SecurityConfig( .antMatchers("/member/check/nickname").permitAll() .antMatchers("/member/signup").permitAll() .antMatchers("/member/login").permitAll() + .antMatchers("/creator-admin/member/login").permitAll() .antMatchers("/member/forgot-password").permitAll() .antMatchers("/stplat/terms_of_service").permitAll() .antMatchers("/stplat/privacy_policy").permitAll() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt index ffbb936..6c38c9c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt @@ -10,6 +10,8 @@ class WebConfig : WebMvcConfigurer { registry.addMapping("/**") .allowedOrigins( "http://localhost:8888", + "https://creator.sodalive.net", + "https://test-creator.sodalive.net", "https://test-admin.sodalive.net", "https://admin.sodalive.net" ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentController.kt new file mode 100644 index 0000000..f19e973 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentController.kt @@ -0,0 +1,52 @@ +package kr.co.vividnext.sodalive.creator.admin.content + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PutMapping +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 +@PreAuthorize("hasRole('CREATOR')") +@RequestMapping("/creator-admin/audio-content") +class CreatorAdminContentController(private val service: CreatorAdminContentService) { + @GetMapping("/list") + fun getAudioContentList( + pageable: Pageable, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.getAudioContentList(pageable, member)) + } + + @GetMapping("/search") + fun searchAudioContent( + @RequestParam(value = "search_word") searchWord: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.searchAudioContent(searchWord, member, pageable)) + } + + @PutMapping + fun modifyAudioContent( + @RequestPart("coverImage", required = false) coverImage: MultipartFile? = null, + @RequestPart("request") requestString: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.updateAudioContent(coverImage, requestString, member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentRepository.kt new file mode 100644 index 0000000..6067cc5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentRepository.kt @@ -0,0 +1,142 @@ +package kr.co.vividnext.sodalive.creator.admin.content + +import com.querydsl.core.types.dsl.DateTimePath +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.core.types.dsl.StringTemplate +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.hashtag.QAudioContentHashTag.audioContentHashTag +import kr.co.vividnext.sodalive.content.hashtag.QHashTag.hashTag +import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime + +interface CreatorAdminContentRepository : JpaRepository, CreatorAdminAudioContentQueryRepository + +interface CreatorAdminAudioContentQueryRepository { + fun getAudioContentTotalCount(memberId: Long, searchWord: String = ""): Int + fun getAudioContentList( + memberId: Long, + offset: Long, + limit: Long, + searchWord: String = "" + ): List + + fun getHashTagList(audioContentId: Long): List + + fun getAudioContent(memberId: Long, audioContentId: Long): AudioContent? +} + +class CreatorAdminAudioContentQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : CreatorAdminAudioContentQueryRepository { + override fun getAudioContentTotalCount(memberId: Long, searchWord: String): Int { + var where = audioContent.duration.isNotNull + .and(audioContent.member.isNotNull) + .and(audioContent.isActive.isTrue) + .and(audioContent.member.id.eq(memberId)) + + if (searchWord.trim().length > 1) { + where = where.and( + audioContent.title.contains(searchWord) + .or(audioContent.member.nickname.contains(searchWord)) + ) + } + + return queryFactory + .select(audioContent.id) + .from(audioContent) + .where(where) + .fetch() + .size + } + + override fun getAudioContentList( + memberId: Long, + offset: Long, + limit: Long, + searchWord: String + ): List { + var where = audioContent.duration.isNotNull + .and(audioContent.member.isNotNull) + .and(audioContent.isActive.isTrue) + .and(audioContent.member.id.eq(memberId)) + + if (searchWord.trim().length > 1) { + where = where.and( + audioContent.title.contains(searchWord) + .or(audioContent.member.nickname.contains(searchWord)) + ) + } + + return queryFactory + .select( + QGetCreatorAdminContentListItem( + audioContent.id, + audioContent.title, + audioContent.detail, + audioContent.coverImage, + audioContent.member!!.nickname, + audioContentTheme.theme, + audioContent.price, + audioContent.isAdult, + audioContent.isCommentAvailable, + audioContent.duration, + audioContent.content, + formattedDateExpression(audioContent.createdAt) + ) + ) + .from(audioContent) + .innerJoin(audioContent.theme, audioContentTheme) + .where(where) + .offset(offset) + .limit(limit) + .orderBy(audioContent.id.desc()) + .fetch() + } + + override fun getHashTagList(audioContentId: Long): List { + return queryFactory + .select(hashTag.tag) + .from(audioContentHashTag) + .innerJoin(audioContentHashTag.hashTag, hashTag) + .innerJoin(audioContentHashTag.audioContent, audioContent) + .where( + audioContent.duration.isNotNull + .and(audioContent.member.isNotNull) + .and(audioContentHashTag.audioContent.id.eq(audioContentId)) + ) + .fetch() + } + + override fun getAudioContent(memberId: Long, audioContentId: Long): AudioContent? { + return queryFactory + .selectFrom(audioContent) + .innerJoin(audioContent.member, member) + .where( + member.id.eq(memberId) + .and(audioContent.id.eq(audioContentId)) + ) + .orderBy(audioContent.id.desc()) + .fetchFirst() + } + + private fun formattedDateExpression( + dateTime: DateTimePath, + format: String = "%Y-%m-%d" + ): StringTemplate { + return Expressions.stringTemplate( + "DATE_FORMAT({0}, {1})", + Expressions.dateTimeTemplate( + LocalDateTime::class.java, + "CONVERT_TZ({0},{1},{2})", + dateTime, + "UTC", + "Asia/Seoul" + ), + format + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt new file mode 100644 index 0000000..9646bdb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt @@ -0,0 +1,140 @@ +package kr.co.vividnext.sodalive.creator.admin.content + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.utils.generateFileName +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile + +@Service +class CreatorAdminContentService( + private val repository: CreatorAdminContentRepository, + private val audioContentCloudFront: AudioContentCloudFront, + private val objectMapper: ObjectMapper, + private val s3Uploader: S3Uploader, + + @Value("\${cloud.aws.s3.bucket}") + private val bucket: String, + + @Value("\${cloud.aws.cloud-front.host}") + private val coverImageHost: String +) { + fun getAudioContentList(pageable: Pageable, member: Member): GetCreatorAdminContentListResponse { + val totalCount = repository.getAudioContentTotalCount(memberId = member.id!!) + val audioContentAndThemeList = repository.getAudioContentList( + memberId = member.id!!, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + + val audioContentList = audioContentAndThemeList + .asSequence() + .map { + val tags = repository + .getHashTagList(audioContentId = it.audioContentId) + .joinToString(" ") { tag -> tag } + it.tags = tags + it + } + .map { + it.contentUrl = audioContentCloudFront.generateSignedURL( + resourcePath = it.contentUrl, + expirationTime = 1000 * 60 * 60 * (it.remainingTime.split(":")[0].toLong() + 2) + ) + it + } + .map { + it.coverImageUrl = "$coverImageHost/${it.coverImageUrl}" + it + } + .toList() + + return GetCreatorAdminContentListResponse(totalCount, audioContentList) + } + + fun searchAudioContent(searchWord: String, member: Member, pageable: Pageable): GetCreatorAdminContentListResponse { + if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.") + val totalCount = repository.getAudioContentTotalCount( + memberId = member.id!!, + searchWord + ) + val audioContentAndThemeList = repository.getAudioContentList( + memberId = member.id!!, + offset = pageable.offset, + limit = pageable.pageSize.toLong(), + searchWord = searchWord + ) + + val audioContentList = audioContentAndThemeList + .asSequence() + .map { + val tags = repository + .getHashTagList(audioContentId = it.audioContentId) + .joinToString(" ") { tag -> tag } + it.tags = tags + it + } + .map { + it.contentUrl = audioContentCloudFront.generateSignedURL( + resourcePath = it.contentUrl, + expirationTime = 1000 * 60 * 60 * (it.remainingTime.split(":")[0].toLong() + 2) + ) + it + } + .map { + it.coverImageUrl = "$coverImageHost/${it.coverImageUrl}" + it + } + .toList() + + return GetCreatorAdminContentListResponse(totalCount, audioContentList) + } + + @Transactional + fun updateAudioContent(coverImage: MultipartFile?, requestString: String, member: Member) { + val request = objectMapper.readValue(requestString, UpdateCreatorAdminContentRequest::class.java) + val audioContent = repository.getAudioContent(memberId = member.id!!, audioContentId = request.id) + ?: throw SodaException("잘못된 콘텐츠 입니다.") + + if (coverImage != null) { + val metadata = ObjectMetadata() + metadata.contentLength = coverImage.size + + val fileName = generateFileName() + val imagePath = s3Uploader.upload( + inputStream = coverImage.inputStream, + bucket = bucket, + filePath = "audio_content_cover/${request.id}/$fileName", + metadata = metadata + ) + audioContent.coverImage = imagePath + } + + if (request.isActive != null) { + audioContent.isActive = request.isActive + } + + if (request.isAdult != null) { + audioContent.isAdult = request.isAdult + } + + if (request.isCommentAvailable != null) { + audioContent.isCommentAvailable = request.isCommentAvailable + } + + if (request.title != null) { + audioContent.title = request.title + } + + if (request.detail != null) { + audioContent.detail = request.detail + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/GetCreatorAdminContentListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/GetCreatorAdminContentListResponse.kt new file mode 100644 index 0000000..7284b11 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/GetCreatorAdminContentListResponse.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.creator.admin.content + +import com.querydsl.core.annotations.QueryProjection + +data class GetCreatorAdminContentListResponse( + val totalCount: Int, + val items: List +) + +data class GetCreatorAdminContentListItem @QueryProjection constructor( + val audioContentId: Long, + val title: String, + val detail: String, + var coverImageUrl: String, + val creatorNickname: String, + val theme: String, + val price: Int, + val isAdult: Boolean, + val isCommentAvailable: Boolean, + val remainingTime: String, + var contentUrl: String, + val date: String +) { + var tags: String = "" +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/UpdateCreatorAdminContentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/UpdateCreatorAdminContentRequest.kt new file mode 100644 index 0000000..b1f85e1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/UpdateCreatorAdminContentRequest.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.creator.admin.content + +data class UpdateCreatorAdminContentRequest( + val id: Long, + val title: String?, + val detail: String?, + val isAdult: Boolean?, + val isActive: Boolean?, + val isCommentAvailable: Boolean? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberController.kt new file mode 100644 index 0000000..3f14263 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberController.kt @@ -0,0 +1,31 @@ +package kr.co.vividnext.sodalive.creator.admin.member + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.login.LoginRequest +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/creator-admin/member") +class CreatorAdminMemberController(private val service: CreatorAdminMemberService) { + @PostMapping("/login") + fun login(@RequestBody loginRequest: LoginRequest) = service.login(loginRequest) + + @PostMapping("/logout") + @PreAuthorize("hasRole('CREATOR')") + fun logout( + @RequestHeader("Authorization") token: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok(service.logout(token.removePrefix("Bearer "), member.id!!)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt new file mode 100644 index 0000000..3e193e9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt @@ -0,0 +1,93 @@ +package kr.co.vividnext.sodalive.creator.admin.member + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.login.LoginRequest +import kr.co.vividnext.sodalive.member.login.LoginResponse +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.write + +@Service +class CreatorAdminMemberService( + private val repository: MemberRepository, + private val tokenRepository: MemberTokenRepository, + private val tokenProvider: TokenProvider, + private val authenticationManagerBuilder: AuthenticationManagerBuilder, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + + private val tokenLocks: MutableMap = mutableMapOf() + + fun login(request: LoginRequest): ApiResponse { + return ApiResponse.ok( + message = "로그인 되었습니다.", + data = login(request.email, request.password) + ) + } + + @Transactional + fun logout(token: String, memberId: Long) { + val member = repository.findByIdOrNull(memberId) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + member.pushToken = null + + val lock = getOrCreateLock(memberId = memberId) + lock.write { + val memberToken = tokenRepository.findByIdOrNull(memberId) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + memberToken.tokenSet.remove(token) + tokenRepository.save(memberToken) + } + } + + private fun login(email: String, password: String): LoginResponse { + val member = repository.findByEmail(email = email) ?: throw SodaException("로그인 정보를 확인해주세요.") + if (!member.isActive) { + throw SodaException("탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.") + } + + if (member.role != MemberRole.CREATOR) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + val authenticationToken = UsernamePasswordAuthenticationToken(email, password) + val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken) + SecurityContextHolder.getContext().authentication = authentication + + val jwt = tokenProvider.createToken( + authentication = authentication, + memberId = member.id!! + ) + + return LoginResponse( + userId = member.id!!, + token = jwt, + nickname = member.nickname, + email = member.email, + profileImage = if (member.profileImage != null) { + "$cloudFrontHost/${member.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + } + ) + } + + private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock { + return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt index 5146119..d9b8e7b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt @@ -12,6 +12,6 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/menu") class MenuController(private val service: MenuService) { @GetMapping - @PreAuthorize("hasAnyRole('AGENT', 'ADMIN')") + @PreAuthorize("hasAnyRole('AGENT', 'ADMIN', 'CREATOR')") fun getMenus(@AuthenticationPrincipal user: User) = ApiResponse.ok(service.getMenus(user)) }