package kr.co.vividnext.sodalive.member import com.amazonaws.services.s3.model.ObjectMetadata import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.can.use.CanUsage 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.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.MemberBlockRequest import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository import kr.co.vividnext.sodalive.member.info.GetMemberInfoResponse import kr.co.vividnext.sodalive.member.login.LoginRequest import kr.co.vividnext.sodalive.member.login.LoginResponse import kr.co.vividnext.sodalive.member.myPage.MyPageResponse import kr.co.vividnext.sodalive.member.nickname.NicknameChangeLog import kr.co.vividnext.sodalive.member.nickname.NicknameChangeLogRepository import kr.co.vividnext.sodalive.member.notification.MemberNotificationService import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.member.signUp.SignUpRequest import kr.co.vividnext.sodalive.member.signUp.SignUpValidator import kr.co.vividnext.sodalive.member.stipulation.Stipulation import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository import kr.co.vividnext.sodalive.member.stipulation.StipulationIds import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag import kr.co.vividnext.sodalive.member.tag.MemberTagRepository import kr.co.vividnext.sodalive.member.token.MemberTokenRepository import kr.co.vividnext.sodalive.utils.generateFileName 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.security.core.userdetails.User import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.multipart.MultipartFile import java.util.concurrent.locks.ReentrantReadWriteLock import kotlin.concurrent.write @Service @Transactional(readOnly = true) class MemberService( private val repository: MemberRepository, private val tokenRepository: MemberTokenRepository, private val stipulationRepository: StipulationRepository, private val stipulationAgreeRepository: StipulationAgreeRepository, private val creatorFollowingRepository: CreatorFollowingRepository, private val blockMemberRepository: BlockMemberRepository, private val signOutRepository: SignOutRepository, private val nicknameChangeLogRepository: NicknameChangeLogRepository, private val memberTagRepository: MemberTagRepository, private val canPaymentService: CanPaymentService, private val memberNotificationService: MemberNotificationService, private val s3Uploader: S3Uploader, private val validator: SignUpValidator, private val tokenProvider: TokenProvider, private val passwordEncoder: PasswordEncoder, private val authenticationManagerBuilder: AuthenticationManagerBuilder, private val objectMapper: ObjectMapper, @Value("\${cloud.aws.s3.bucket}") private val s3Bucket: String, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) : UserDetailsService { private val tokenLocks: MutableMap = mutableMapOf() @Transactional fun signUp( profileImage: MultipartFile?, requestString: String ): ApiResponse { val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID) ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") val request = objectMapper.readValue(requestString, SignUpRequest::class.java) if (!request.isAgreePrivacyPolicy || !request.isAgreeTermsOfService) { throw SodaException("약관에 동의하셔야 회원가입이 가능합니다.") } validatePassword(request.password) duplicateCheckEmail(request.email) duplicateCheckNickname(request.nickname) val member = createMember(request) member.profileImage = uploadProfileImage(profileImage = profileImage, memberId = member.id!!) agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = login(request.email, request.password)) } fun login(request: LoginRequest): ApiResponse { return ApiResponse.ok( message = "로그인 되었습니다.", data = login(request.email, request.password, request.isAdmin, request.isCreator) ) } fun getMember(id: Long, container: String): ProfileResponse { val member = repository.findByIdOrNull(id) ?: throw SodaException("없는 사용자 입니다.") return ProfileResponse(member, cloudFrontHost, container) } fun getMemberInfo(member: Member, container: String): GetMemberInfoResponse { return GetMemberInfoResponse( can = member.getChargeCan(container) + member.getRewardCan(container), isAuth = member.auth != null, role = member.role, messageNotice = member.notification?.message, followingChannelLiveNotice = member.notification?.live, followingChannelUploadContentNotice = member.notification?.uploadContent ) } @Transactional fun updateNotificationSettings(request: UpdateNotificationSettingRequest, member: Member) { memberNotificationService.updateNotification( live = request.live, uploadContent = request.uploadContent, message = request.message, member = member ) } @Transactional fun updatePushToken(memberId: Long, pushToken: String, container: String) { val existsHavePushTokenMemberList = repository.findByPushToken(pushToken = pushToken) for (existsHavePushTokenMember in existsHavePushTokenMemberList) { existsHavePushTokenMember.pushToken = null } val member = repository.findByIdOrNull(id = memberId) ?: throw SodaException("로그인 정보를 확인해주세요.") member.pushToken = pushToken member.container = container } fun getMyPage(member: Member, container: String): MyPageResponse { return MyPageResponse( nickname = member.nickname, profileUrl = if (member.profileImage != null) { "$cloudFrontHost/${member.profileImage}" } else { "$cloudFrontHost/profile/default-profile.png" }, chargeCan = member.getChargeCan(container = container), rewardCan = member.getRewardCan(container = container), youtubeUrl = member.youtubeUrl, instagramUrl = member.instagramUrl, websiteUrl = member.websiteUrl, blogUrl = member.blogUrl, liveReservationCount = 0, isAuth = member.auth != null ) } private fun login( email: String, password: String, isAdmin: Boolean = false, isCreator: Boolean = false ): LoginResponse { val member = repository.findByEmail(email = email) ?: throw SodaException("로그인 정보를 확인해주세요.") if (!member.isActive) { throw SodaException("탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.") } if (isCreator && member.role != MemberRole.CREATOR) { throw SodaException("로그인 정보를 확인해주세요.") } if (isAdmin && member.role != MemberRole.ADMIN) { 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 uploadProfileImage(profileImage: MultipartFile?, memberId: Long): String { return if (profileImage != null) { val metadata = ObjectMetadata() metadata.contentLength = profileImage.size s3Uploader.upload( inputStream = profileImage.inputStream, bucket = s3Bucket, filePath = "profile/$memberId/${generateFileName(prefix = "$memberId-profile")}", metadata = metadata ) } else { "profile/default-profile.png" } } private fun agreeTermsOfServiceAndPrivacyPolicy( member: Member, stipulationTermsOfService: Stipulation, stipulationPrivacyPolicy: Stipulation ) { val termsOfServiceAgree = StipulationAgree(true) termsOfServiceAgree.member = member termsOfServiceAgree.stipulation = stipulationTermsOfService stipulationAgreeRepository.save(termsOfServiceAgree) val privacyPolicyAgree = StipulationAgree(true) privacyPolicyAgree.member = member privacyPolicyAgree.stipulation = stipulationPrivacyPolicy stipulationAgreeRepository.save(privacyPolicyAgree) } private fun createMember(request: SignUpRequest): Member { val member = Member( email = request.email, password = passwordEncoder.encode(request.password), nickname = request.nickname, gender = request.gender, container = request.container ) return repository.save(member) } private fun validatePassword(password: String) { val passwordValidationMessage = validator.passwordValidation(password) if (passwordValidationMessage.trim().isNotEmpty()) { throw SodaException(passwordValidationMessage) } } fun duplicateCheckEmail(email: String): ApiResponse { validateEmail(email) repository.findByEmail(email)?.let { throw SodaException("이미 사용중인 이메일 입니다.", "email") } return ApiResponse.ok(message = "사용 가능한 이메일 입니다.") } private fun validateEmail(email: String) { val emailValidationMessage = validator.emailValidation(email) if (emailValidationMessage.trim().isNotEmpty()) { throw SodaException(emailValidationMessage, "email") } } fun duplicateCheckNickname(nickname: String): ApiResponse { validateNickname(nickname) repository.findByNickname(nickname)?.let { throw SodaException("이미 사용중인 닉네임 입니다.", "nickname") } return ApiResponse.ok(message = "사용 가능한 닉네임 입니다.") } private fun validateNickname(nickname: String) { val nicknameValidationMessage = validator.nicknameValidation(nickname) if (nicknameValidationMessage.trim().isNotEmpty()) { throw SodaException(nicknameValidationMessage, "nickname") } } override fun loadUserByUsername(username: String): UserDetails { val member = repository.findByEmail(email = username) ?: throw UsernameNotFoundException(username) return MemberAdapter(member) } @Transactional fun creatorFollow(creatorId: Long, memberId: Long) { val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId( creatorId = creatorId, memberId = memberId ) if (creatorFollowing == null) { val creator = repository.findByIdOrNull(creatorId) ?: throw SodaException("크리에이터 정보를 확인해주세요.") val member = repository.findByIdOrNull(memberId) ?: throw SodaException("로그인 정보를 확인해주세요.") creatorFollowingRepository.save(CreatorFollowing(creator = creator, member = member)) } else { creatorFollowing.isActive = true } } @Transactional fun creatorUnFollow(creatorId: Long, memberId: Long) { val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId( creatorId = creatorId, memberId = memberId ) if (creatorFollowing != null) { creatorFollowing.isActive = false } } fun memberBlock(request: MemberBlockRequest, memberId: Long) { var blockMember = blockMemberRepository.getBlockAccount( blockedMemberId = request.blockMemberId, memberId = memberId ) if (blockMember == null) { blockMember = BlockMember( blockedMemberId = request.blockMemberId, memberId = memberId ) blockMemberRepository.save(blockMember) } else { blockMember.isActive = true } } fun memberUnBlock(request: MemberBlockRequest, memberId: Long) { val blockMember = blockMemberRepository.getBlockAccount( blockedMemberId = request.blockMemberId, memberId = memberId ) if (blockMember != null) { blockMember.isActive = true } } fun isBlocked(blockedMemberId: Long, memberId: Long) = blockMemberRepository.isBlocked(blockedMemberId, memberId) fun searchMember(nickname: String, memberId: Long): List { if (nickname.length < 2) { throw SodaException("두 글자 이상 입력 하셔야 합니다.") } return repository.findByNicknameAndOtherCondition(nickname, memberId) .asSequence() .filter { !blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = it.id!!) } .map { GetRoomDetailUser(it, cloudFrontHost) } .toList() } @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) } } @Transactional fun logoutAll(memberId: Long) { val member = repository.findByIdOrNull(memberId) ?: throw SodaException("로그인 정보를 확인해주세요.") member.pushToken = null val lock = getOrCreateLock(memberId = memberId) lock.write { tokenRepository.deleteById(memberId) } } @Transactional fun signOut(signOutRequest: SignOutRequest, user: User) { val member = repository.findByEmail(user.username) ?: throw SodaException("로그인 정보를 확인해주세요.") if (!passwordEncoder.matches(signOutRequest.password, member.password)) { throw SodaException("비밀번호가 일치하지 않습니다.") } if (signOutRequest.reason.isBlank()) { throw SodaException("탈퇴하려는 이유를 입력해 주세요.") } logoutAll(memberId = member.id!!) member.isActive = false val signOut = SignOut(reason = signOutRequest.reason) signOut.member = member signOutRepository.save(signOut) } fun getChangeNicknamePrice(memberId: Long): GetChangeNicknamePriceResponse { return repository.getChangeNicknamePrice(memberId = memberId) } @Transactional fun updateNickname(profileUpdateRequest: ProfileUpdateRequest, user: User) { if (profileUpdateRequest.email != user.username) { throw SodaException("로그인 정보를 확인해 주세요.") } val member = repository.findByEmail(user.username) ?: throw SodaException("로그인 정보를 확인해주세요.") if (profileUpdateRequest.nickname != null) { validateNickname(profileUpdateRequest.nickname) repository.findByNickname(profileUpdateRequest.nickname) ?.let { throw SodaException("이미 사용중인 닉네임 입니다.") } val price = repository.getChangeNicknamePrice(memberId = member.id!!).price if (price > 0) { canPaymentService.spendCan( memberId = member.id!!, needCan = price, canUsage = CanUsage.CHANGE_NICKNAME, container = profileUpdateRequest.container ) } val nicknameChangeLog = NicknameChangeLog(prevNickname = member.nickname) nicknameChangeLog.member = member nicknameChangeLogRepository.save(nicknameChangeLog) member.nickname = profileUpdateRequest.nickname } } @Transactional fun profileUpdate(profileUpdateRequest: ProfileUpdateRequest, user: User): ProfileResponse { if (profileUpdateRequest.email != user.username) { throw SodaException("로그인 정보를 확인해 주세요.") } val member = repository.findByEmail(user.username) ?: throw SodaException("로그인 정보를 확인해주세요.") if (profileUpdateRequest.modifyPassword != null) { if (passwordEncoder.matches(profileUpdateRequest.password, member.password)) { validatePassword(profileUpdateRequest.modifyPassword) member.password = passwordEncoder.encode(profileUpdateRequest.modifyPassword) } else { throw SodaException("비밀번호가 일치하지 않습니다.") } } if (profileUpdateRequest.gender != null) { member.gender = profileUpdateRequest.gender } if (profileUpdateRequest.nickname != null) { validateNickname(profileUpdateRequest.nickname) repository.findByNickname(profileUpdateRequest.nickname) ?.let { throw SodaException("이미 사용중인 닉네임 입니다.") } member.nickname = profileUpdateRequest.nickname } val tags = if (!profileUpdateRequest.removeTags.isNullOrEmpty()) { member.tags.filter { !profileUpdateRequest.removeTags.contains(it.tag.tag) } } else { member.tags }.toMutableList() if (!profileUpdateRequest.insertTags.isNullOrEmpty()) { val accountCounselorTags = memberTagRepository.findByMember(member).map { it.tag } profileUpdateRequest.insertTags.forEach { val tag = memberTagRepository.findByTag(it) if (tag != null && !accountCounselorTags.contains(tag)) { tags.add(MemberCreatorTag(member, tag)) } } } if (tags != member.tags) { member.tags.clear() member.tags.addAll(tags) } if (profileUpdateRequest.introduce != null) { member.introduce = profileUpdateRequest.introduce } if (profileUpdateRequest.youtubeUrl != null) { member.youtubeUrl = profileUpdateRequest.youtubeUrl } if (profileUpdateRequest.instagramUrl != null) { member.instagramUrl = profileUpdateRequest.instagramUrl } if (profileUpdateRequest.websiteUrl != null) { member.websiteUrl = profileUpdateRequest.websiteUrl } if (profileUpdateRequest.blogUrl != null) { member.blogUrl = profileUpdateRequest.blogUrl } return ProfileResponse(member, cloudFrontHost, profileUpdateRequest.container) } @Transactional fun profileImageUpdate(multipartFile: MultipartFile, user: User): String { val member = repository.findByEmail(user.username) ?: throw SodaException("로그인 정보를 확인해주세요.") val metadata = ObjectMetadata() metadata.contentLength = multipartFile.size member.profileImage = s3Uploader.upload( inputStream = multipartFile.inputStream, bucket = s3Bucket, filePath = "profile/${member.id}/${generateFileName(prefix = "${member.id}-profile")}", metadata = metadata ) return "$cloudFrontHost/${member.profileImage!!}" } private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock { return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } } }