라이브 방 - 아고라 설정 및 라이브 방 관련 API
This commit is contained in:
		
							
								
								
									
										171
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/agora/AccessToken.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/agora/AccessToken.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,171 @@ | ||||
| package kr.co.vividnext.sodalive.agora | ||||
|  | ||||
| import java.io.ByteArrayOutputStream | ||||
| import java.io.IOException | ||||
| import java.util.TreeMap | ||||
|  | ||||
| class AccessToken( | ||||
|     var appId: String, | ||||
|     private val appCertificate: String, | ||||
|     val channelName: String, | ||||
|     private val uid: String, | ||||
|     var crcChannelName: Int = 0, | ||||
|     private var crcUid: Int = 0, | ||||
|     val message: PrivilegeMessage = PrivilegeMessage() | ||||
| ) { | ||||
|  | ||||
|     private lateinit var signature: ByteArray | ||||
|     private lateinit var messageRawContent: ByteArray | ||||
|  | ||||
|     enum class Privileges(value: Int) { | ||||
|         JoinChannel(1), | ||||
|         PublishAudioStream(2), | ||||
|         PublishVideoStream(3), | ||||
|         PublishDataStream(4), // For RTM only | ||||
|         RtmLogin(1000); | ||||
|  | ||||
|         var intValue: Short | ||||
|  | ||||
|         init { | ||||
|             intValue = value.toShort() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Throws(Exception::class) | ||||
|     fun build(): String { | ||||
|         if (!AgoraUtils.isUUID(appId)) { | ||||
|             return "" | ||||
|         } | ||||
|         if (!AgoraUtils.isUUID(appCertificate)) { | ||||
|             return "" | ||||
|         } | ||||
|  | ||||
|         messageRawContent = AgoraUtils.pack(message) | ||||
|         signature = generateSignature( | ||||
|             appCertificate, | ||||
|             appId, | ||||
|             channelName, | ||||
|             uid, | ||||
|             messageRawContent | ||||
|         ) | ||||
|         crcChannelName = AgoraUtils.crc32(channelName) | ||||
|         crcUid = AgoraUtils.crc32(uid) | ||||
|         val packContent = PackContent(signature, crcChannelName, crcUid, messageRawContent) | ||||
|         val content: ByteArray = AgoraUtils.pack(packContent) | ||||
|         return getVersion() + appId + AgoraUtils.base64Encode(content) | ||||
|     } | ||||
|  | ||||
|     fun addPrivilege(privilege: Privileges, expireTimestamp: Int) { | ||||
|         message.messages[privilege.intValue] = expireTimestamp | ||||
|     } | ||||
|  | ||||
|     private fun getVersion(): String { | ||||
|         return VER | ||||
|     } | ||||
|  | ||||
|     @Throws(java.lang.Exception::class) | ||||
|     fun generateSignature( | ||||
|         appCertificate: String, | ||||
|         appID: String, | ||||
|         channelName: String, | ||||
|         uid: String, | ||||
|         message: ByteArray | ||||
|     ): ByteArray { | ||||
|         val baos = ByteArrayOutputStream() | ||||
|  | ||||
|         try { | ||||
|             baos.write(appID.toByteArray()) | ||||
|             baos.write(channelName.toByteArray()) | ||||
|             baos.write(uid.toByteArray()) | ||||
|             baos.write(message) | ||||
|         } catch (e: IOException) { | ||||
|             e.printStackTrace() | ||||
|         } | ||||
|  | ||||
|         return AgoraUtils.hmacSign(appCertificate, baos.toByteArray()) | ||||
|     } | ||||
|  | ||||
|     fun fromString(token: String): Boolean { | ||||
|         if (getVersion() != token.substring(0, AgoraUtils.VERSION_LENGTH)) { | ||||
|             return false | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             appId = token.substring(AgoraUtils.VERSION_LENGTH, AgoraUtils.VERSION_LENGTH + AgoraUtils.APP_ID_LENGTH) | ||||
|             val packContent = PackContent() | ||||
|             AgoraUtils.unpack( | ||||
|                 AgoraUtils.base64Decode( | ||||
|                     token.substring( | ||||
|                         AgoraUtils.VERSION_LENGTH + AgoraUtils.APP_ID_LENGTH, | ||||
|                         token.length | ||||
|                     ) | ||||
|                 ), | ||||
|                 packContent | ||||
|             ) | ||||
|             signature = packContent.signature | ||||
|             crcChannelName = packContent.crcChannelName | ||||
|             crcUid = packContent.crcUid | ||||
|             messageRawContent = packContent.rawMessage | ||||
|             AgoraUtils.unpack(messageRawContent, message) | ||||
|         } catch (e: java.lang.Exception) { | ||||
|             e.printStackTrace() | ||||
|             return false | ||||
|         } | ||||
|  | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     class PrivilegeMessage : PackableEx { | ||||
|         var salt: Int | ||||
|         var ts: Int | ||||
|         var messages: TreeMap<Short, Int> | ||||
|  | ||||
|         override fun marshal(out: ByteBuf): ByteBuf { | ||||
|             return out.put(salt).put(ts).putIntMap(messages) | ||||
|         } | ||||
|  | ||||
|         override fun unmarshal(input: ByteBuf) { | ||||
|             salt = input.readInt() | ||||
|             ts = input.readInt() | ||||
|             messages = input.readIntMap() | ||||
|         } | ||||
|  | ||||
|         init { | ||||
|             salt = AgoraUtils.randomInt() | ||||
|             ts = AgoraUtils.getTimestamp() + 24 * 3600 | ||||
|             messages = TreeMap() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     class PackContent() : PackableEx { | ||||
|         var signature: ByteArray = byteArrayOf() | ||||
|         var crcChannelName = 0 | ||||
|         var crcUid = 0 | ||||
|         var rawMessage: ByteArray = byteArrayOf() | ||||
|  | ||||
|         constructor(signature: ByteArray, crcChannelName: Int, crcUid: Int, rawMessage: ByteArray) : this() { | ||||
|             this.signature = signature | ||||
|             this.crcChannelName = crcChannelName | ||||
|             this.crcUid = crcUid | ||||
|             this.rawMessage = rawMessage | ||||
|         } | ||||
|  | ||||
|         override fun marshal(out: ByteBuf): ByteBuf { | ||||
|             return out | ||||
|                 .put(signature) | ||||
|                 .put(crcChannelName) | ||||
|                 .put(crcUid).put(rawMessage) | ||||
|         } | ||||
|  | ||||
|         override fun unmarshal(input: ByteBuf) { | ||||
|             signature = input.readBytes() | ||||
|             crcChannelName = input.readInt() | ||||
|             crcUid = input.readInt() | ||||
|             rawMessage = input.readBytes() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val VER = "006" | ||||
|     } | ||||
| } | ||||
							
								
								
									
										72
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/agora/AgoraUtils.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/agora/AgoraUtils.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| package kr.co.vividnext.sodalive.agora | ||||
|  | ||||
| import org.apache.commons.codec.binary.Base64 | ||||
| import java.security.InvalidKeyException | ||||
| import java.security.NoSuchAlgorithmException | ||||
| import java.security.SecureRandom | ||||
| import java.util.Date | ||||
| import java.util.zip.CRC32 | ||||
| import javax.crypto.Mac | ||||
| import javax.crypto.spec.SecretKeySpec | ||||
|  | ||||
| object AgoraUtils { | ||||
|     const val HMAC_SHA256_LENGTH: Long = 32 | ||||
|     const val VERSION_LENGTH = 3 | ||||
|     const val APP_ID_LENGTH = 32 | ||||
|  | ||||
|     @Throws(InvalidKeyException::class, NoSuchAlgorithmException::class) | ||||
|     fun hmacSign(keyString: String, msg: ByteArray?): ByteArray { | ||||
|         val keySpec = SecretKeySpec(keyString.toByteArray(), "HmacSHA256") | ||||
|         val mac = Mac.getInstance("HmacSHA256") | ||||
|         mac.init(keySpec) | ||||
|         return mac.doFinal(msg) | ||||
|     } | ||||
|  | ||||
|     fun pack(packableEx: PackableEx): ByteArray { | ||||
|         val buffer = ByteBuf() | ||||
|         packableEx.marshal(buffer) | ||||
|         return buffer.asBytes() | ||||
|     } | ||||
|  | ||||
|     fun unpack(data: ByteArray?, packableEx: PackableEx) { | ||||
|         val buffer = ByteBuf(data!!) | ||||
|         packableEx.unmarshal(buffer) | ||||
|     } | ||||
|  | ||||
|     fun base64Encode(data: ByteArray?): String { | ||||
|         val encodedBytes: ByteArray = Base64.encodeBase64(data) | ||||
|         return String(encodedBytes) | ||||
|     } | ||||
|  | ||||
|     fun base64Decode(data: String): ByteArray { | ||||
|         return Base64.decodeBase64(data.toByteArray()) | ||||
|     } | ||||
|  | ||||
|     fun crc32(data: String): Int { | ||||
|         // get bytes from string | ||||
|         val bytes = data.toByteArray() | ||||
|         return crc32(bytes) | ||||
|     } | ||||
|  | ||||
|     fun crc32(bytes: ByteArray?): Int { | ||||
|         val checksum = CRC32() | ||||
|         checksum.update(bytes) | ||||
|         return checksum.value.toInt() | ||||
|     } | ||||
|  | ||||
|     fun getTimestamp(): Int { | ||||
|         return (Date().time / 1000).toInt() | ||||
|     } | ||||
|  | ||||
|     fun randomInt(): Int { | ||||
|         return SecureRandom().nextInt() | ||||
|     } | ||||
|  | ||||
|     fun isUUID(uuid: String): Boolean { | ||||
|         return if (uuid.length != 32) { | ||||
|             false | ||||
|         } else { | ||||
|             uuid.matches("\\p{XDigit}+".toRegex()) | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										111
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/agora/ByteBuf.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/agora/ByteBuf.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | ||||
| package kr.co.vividnext.sodalive.agora | ||||
|  | ||||
| import java.nio.ByteBuffer | ||||
| import java.nio.ByteOrder | ||||
| import java.util.TreeMap | ||||
|  | ||||
| class ByteBuf() { | ||||
|     private var buffer = ByteBuffer.allocate(1024).order(ByteOrder.LITTLE_ENDIAN) | ||||
|  | ||||
|     constructor(bytes: ByteArray) : this() { | ||||
|         this.buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) | ||||
|     } | ||||
|  | ||||
|     fun asBytes(): ByteArray { | ||||
|         val out = ByteArray(buffer.position()) | ||||
|         buffer.rewind() | ||||
|         buffer[out, 0, out.size] | ||||
|         return out | ||||
|     } | ||||
|  | ||||
|     // packUint16 | ||||
|     fun put(v: Short): ByteBuf { | ||||
|         buffer.putShort(v) | ||||
|         return this | ||||
|     } | ||||
|  | ||||
|     fun put(v: ByteArray): ByteBuf { | ||||
|         put(v.size.toShort()) | ||||
|         buffer.put(v) | ||||
|         return this | ||||
|     } | ||||
|  | ||||
|     // packUint32 | ||||
|     fun put(v: Int): ByteBuf { | ||||
|         buffer.putInt(v) | ||||
|         return this | ||||
|     } | ||||
|  | ||||
|     fun put(v: Long): ByteBuf { | ||||
|         buffer.putLong(v) | ||||
|         return this | ||||
|     } | ||||
|  | ||||
|     fun put(v: String): ByteBuf { | ||||
|         return put(v.toByteArray()) | ||||
|     } | ||||
|  | ||||
|     fun put(extra: TreeMap<Short, String>): ByteBuf { | ||||
|         put(extra.size.toShort()) | ||||
|         for ((key, value) in extra.entries) { | ||||
|             put(key) | ||||
|             put(value) | ||||
|         } | ||||
|         return this | ||||
|     } | ||||
|  | ||||
|     fun putIntMap(extra: TreeMap<Short, Int>): ByteBuf { | ||||
|         put(extra.size.toShort()) | ||||
|         for ((key, value) in extra.entries) { | ||||
|             put(key) | ||||
|             put(value) | ||||
|         } | ||||
|         return this | ||||
|     } | ||||
|  | ||||
|     fun readShort(): Short { | ||||
|         return buffer.short | ||||
|     } | ||||
|  | ||||
|     fun readInt(): Int { | ||||
|         return buffer.int | ||||
|     } | ||||
|  | ||||
|     fun readBytes(): ByteArray { | ||||
|         val length = readShort() | ||||
|         val bytes = ByteArray(length.toInt()) | ||||
|         buffer[bytes] | ||||
|         return bytes | ||||
|     } | ||||
|  | ||||
|     fun readString(): String { | ||||
|         val bytes = readBytes() | ||||
|         return String(bytes) | ||||
|     } | ||||
|  | ||||
|     fun readMap(): TreeMap<Short, String> { | ||||
|         val map = TreeMap<Short, String>() | ||||
|         val length = readShort() | ||||
|  | ||||
|         for (i in 0 until length) { | ||||
|             val k = readShort() | ||||
|             val v = readString() | ||||
|             map[k] = v | ||||
|         } | ||||
|  | ||||
|         return map | ||||
|     } | ||||
|  | ||||
|     fun readIntMap(): TreeMap<Short, Int> { | ||||
|         val map = TreeMap<Short, Int>() | ||||
|         val length = readShort() | ||||
|  | ||||
|         for (i in 0 until length) { | ||||
|             val k = readShort() | ||||
|             val v = readInt() | ||||
|             map[k] = v | ||||
|         } | ||||
|  | ||||
|         return map | ||||
|     } | ||||
| } | ||||
							
								
								
									
										256
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKey5.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKey5.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,256 @@ | ||||
| package kr.co.vividnext.sodalive.agora | ||||
|  | ||||
| import org.apache.commons.codec.binary.Base64 | ||||
| import org.apache.commons.codec.binary.Hex | ||||
| import java.util.TreeMap | ||||
|  | ||||
| class DynamicKey5 { | ||||
|     lateinit var content: DynamicKey5Content | ||||
|  | ||||
|     fun fromString(key: String): Boolean { | ||||
|         if (key.substring(0, 3) != version) { | ||||
|             return false | ||||
|         } | ||||
|         val rawContent: ByteArray = Base64().decode(key.substring(3)) | ||||
|         if (rawContent.isEmpty()) { | ||||
|             return false | ||||
|         } | ||||
|         content = DynamicKey5Content() | ||||
|         val buffer = ByteBuf(rawContent) | ||||
|         content.unmarshall(buffer) | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val version = "005" | ||||
|         const val noUpload = "0" | ||||
|         const val audioVideoUpload = "3" | ||||
|  | ||||
|         // ServiceType | ||||
|         const val MEDIA_CHANNEL_SERVICE: Short = 1 | ||||
|         const val RECORDING_SERVICE: Short = 2 | ||||
|         const val PUBLIC_SHARING_SERVICE: Short = 3 | ||||
|         const val IN_CHANNEL_PERMISSION: Short = 4 | ||||
|  | ||||
|         // InChannelPermissionKey | ||||
|         const val ALLOW_UPLOAD_IN_CHANNEL: Short = 1 | ||||
|  | ||||
|         @Throws(Exception::class) | ||||
|         fun generateSignature( | ||||
|             appCertificate: String, | ||||
|             service: Short, | ||||
|             appID: String, | ||||
|             unixTs: Int, | ||||
|             salt: Int, | ||||
|             channelName: String, | ||||
|             uid: Long, | ||||
|             expiredTs: Int, | ||||
|             extra: TreeMap<Short, String> | ||||
|         ): String { | ||||
|             // decode hex to avoid case problem | ||||
|             val hex = Hex() | ||||
|             val rawAppID: ByteArray = hex.decode(appID.toByteArray()) | ||||
|             val rawAppCertificate: ByteArray = hex.decode(appCertificate.toByteArray()) | ||||
|             val m = Message( | ||||
|                 service, | ||||
|                 rawAppID, | ||||
|                 unixTs, | ||||
|                 salt, | ||||
|                 channelName, | ||||
|                 (uid and 0xFFFFFFFFL).toInt(), | ||||
|                 expiredTs, | ||||
|                 extra | ||||
|             ) | ||||
|             val toSign: ByteArray = pack(m) | ||||
|             return String(Hex.encodeHex(DynamicKeyUtil.encodeHMAC(rawAppCertificate, toSign), false)) | ||||
|         } | ||||
|  | ||||
|         @Throws(java.lang.Exception::class) | ||||
|         fun generateDynamicKey( | ||||
|             appID: String, | ||||
|             appCertificate: String, | ||||
|             channel: String, | ||||
|             ts: Int, | ||||
|             salt: Int, | ||||
|             uid: Long, | ||||
|             expiredTs: Int, | ||||
|             extra: TreeMap<Short, String>, | ||||
|             service: Short | ||||
|         ): String { | ||||
|             val signature = generateSignature(appCertificate, service, appID, ts, salt, channel, uid, expiredTs, extra) | ||||
|             val content = | ||||
|                 DynamicKey5Content(service, signature, Hex().decode(appID.toByteArray()), ts, salt, expiredTs, extra) | ||||
|             val bytes: ByteArray = pack(content) | ||||
|             val encoded = Base64().encode(bytes) | ||||
|             val base64 = String(encoded) | ||||
|             return version + base64 | ||||
|         } | ||||
|  | ||||
|         private fun pack(content: Packable): ByteArray { | ||||
|             val buffer = ByteBuf() | ||||
|             content.marshal(buffer) | ||||
|             return buffer.asBytes() | ||||
|         } | ||||
|  | ||||
|         @Throws(Exception::class) | ||||
|         fun generatePublicSharingKey( | ||||
|             appID: String, | ||||
|             appCertificate: String, | ||||
|             channel: String, | ||||
|             ts: Int, | ||||
|             salt: Int, | ||||
|             uid: Long, | ||||
|             expiredTs: Int | ||||
|         ) = generateDynamicKey( | ||||
|             appID, | ||||
|             appCertificate, | ||||
|             channel, | ||||
|             ts, | ||||
|             salt, | ||||
|             uid, | ||||
|             expiredTs, | ||||
|             TreeMap(), | ||||
|             PUBLIC_SHARING_SERVICE | ||||
|         ) | ||||
|  | ||||
|         @Throws(Exception::class) | ||||
|         fun generateRecordingKey( | ||||
|             appID: String, | ||||
|             appCertificate: String, | ||||
|             channel: String, | ||||
|             ts: Int, | ||||
|             salt: Int, | ||||
|             uid: Long, | ||||
|             expiredTs: Int | ||||
|         ) = generateDynamicKey( | ||||
|             appID, | ||||
|             appCertificate, | ||||
|             channel, | ||||
|             ts, | ||||
|             salt, | ||||
|             uid, | ||||
|             expiredTs, | ||||
|             TreeMap(), | ||||
|             RECORDING_SERVICE | ||||
|         ) | ||||
|  | ||||
|         @Throws(Exception::class) | ||||
|         fun generateMediaChannelKey( | ||||
|             appID: String, | ||||
|             appCertificate: String, | ||||
|             channel: String, | ||||
|             ts: Int, | ||||
|             salt: Int, | ||||
|             uid: Long, | ||||
|             expiredTs: Int | ||||
|         ) = generateDynamicKey( | ||||
|             appID, | ||||
|             appCertificate, | ||||
|             channel, | ||||
|             ts, | ||||
|             salt, | ||||
|             uid, | ||||
|             expiredTs, | ||||
|             TreeMap(), | ||||
|             MEDIA_CHANNEL_SERVICE | ||||
|         ) | ||||
|  | ||||
|         @Throws(Exception::class) | ||||
|         fun generateInChannelPermissionKey( | ||||
|             appID: String, | ||||
|             appCertificate: String, | ||||
|             channel: String, | ||||
|             ts: Int, | ||||
|             salt: Int, | ||||
|             uid: Long, | ||||
|             expiredTs: Int, | ||||
|             permission: String | ||||
|         ): String { | ||||
|             val extra = TreeMap<Short, String>() | ||||
|             extra[ALLOW_UPLOAD_IN_CHANNEL] = permission | ||||
|             return generateDynamicKey( | ||||
|                 appID, | ||||
|                 appCertificate, | ||||
|                 channel, | ||||
|                 ts, | ||||
|                 salt, | ||||
|                 uid, | ||||
|                 expiredTs, | ||||
|                 extra, | ||||
|                 IN_CHANNEL_PERMISSION | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         internal class Message( | ||||
|             var serviceType: Short, | ||||
|             var appID: ByteArray, | ||||
|             var unixTs: Int, | ||||
|             var salt: Int, | ||||
|             var channelName: String, | ||||
|             var uid: Int, | ||||
|             var expiredTs: Int, | ||||
|             var extra: TreeMap<Short, String> | ||||
|         ) : Packable { | ||||
|             override fun marshal(out: ByteBuf): ByteBuf { | ||||
|                 return out | ||||
|                     .put(serviceType) | ||||
|                     .put(appID) | ||||
|                     .put(unixTs) | ||||
|                     .put(salt) | ||||
|                     .put(channelName) | ||||
|                     .put(uid) | ||||
|                     .put(expiredTs) | ||||
|                     .put(extra) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         class DynamicKey5Content() : Packable { | ||||
|             var serviceType: Short = 0 | ||||
|             var signature: String? = null | ||||
|             var appID: ByteArray = byteArrayOf() | ||||
|             var unixTs = 0 | ||||
|             var salt = 0 | ||||
|             var expiredTs = 0 | ||||
|             var extra: TreeMap<Short, String>? = null | ||||
|  | ||||
|             constructor( | ||||
|                 serviceType: Short, | ||||
|                 signature: String?, | ||||
|                 appID: ByteArray, | ||||
|                 unixTs: Int, | ||||
|                 salt: Int, | ||||
|                 expiredTs: Int, | ||||
|                 extra: TreeMap<Short, String> | ||||
|             ) : this() { | ||||
|                 this.serviceType = serviceType | ||||
|                 this.signature = signature | ||||
|                 this.appID = appID | ||||
|                 this.unixTs = unixTs | ||||
|                 this.salt = salt | ||||
|                 this.expiredTs = expiredTs | ||||
|                 this.extra = extra | ||||
|             } | ||||
|  | ||||
|             override fun marshal(out: ByteBuf): ByteBuf { | ||||
|                 return out | ||||
|                     .put(serviceType) | ||||
|                     .put(signature!!) | ||||
|                     .put(appID) | ||||
|                     .put(unixTs) | ||||
|                     .put(salt) | ||||
|                     .put(expiredTs) | ||||
|                     .put(extra!!) | ||||
|             } | ||||
|  | ||||
|             fun unmarshall(input: ByteBuf) { | ||||
|                 serviceType = input.readShort() | ||||
|                 signature = input.readString() | ||||
|                 appID = input.readBytes() | ||||
|                 unixTs = input.readInt() | ||||
|                 salt = input.readInt() | ||||
|                 expiredTs = input.readInt() | ||||
|                 extra = input.readMap() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,33 @@ | ||||
| package kr.co.vividnext.sodalive.agora | ||||
|  | ||||
| import java.security.InvalidKeyException | ||||
| import java.security.NoSuchAlgorithmException | ||||
| import javax.crypto.Mac | ||||
| import javax.crypto.spec.SecretKeySpec | ||||
|  | ||||
| /** | ||||
|  * Created by hefeng on 15/8/10. | ||||
|  * Util to generate Agora media dynamic key. | ||||
|  */ | ||||
| object DynamicKeyUtil { | ||||
|     @Throws(NoSuchAlgorithmException::class, InvalidKeyException::class) | ||||
|     fun encodeHMAC(key: String, message: ByteArray?): ByteArray? { | ||||
|         return encodeHMAC(key.toByteArray(), message) | ||||
|     } | ||||
|  | ||||
|     @Throws(NoSuchAlgorithmException::class, InvalidKeyException::class) | ||||
|     fun encodeHMAC(key: ByteArray?, message: ByteArray?): ByteArray? { | ||||
|         val keySpec = SecretKeySpec(key, "HmacSHA1") | ||||
|         val mac = Mac.getInstance("HmacSHA1") | ||||
|         mac.init(keySpec) | ||||
|         return mac.doFinal(message) | ||||
|     } | ||||
|  | ||||
|     fun bytesToHex(`in`: ByteArray): String { | ||||
|         val builder = StringBuilder() | ||||
|         for (b in `in`) { | ||||
|             builder.append(String.format("%02x", b)) | ||||
|         } | ||||
|         return builder.toString() | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,5 @@ | ||||
| package kr.co.vividnext.sodalive.agora | ||||
|  | ||||
| interface Packable { | ||||
|     fun marshal(out: ByteBuf): ByteBuf | ||||
| } | ||||
| @@ -0,0 +1,5 @@ | ||||
| package kr.co.vividnext.sodalive.agora | ||||
|  | ||||
| interface PackableEx : Packable { | ||||
|     fun unmarshal(input: ByteBuf) | ||||
| } | ||||
| @@ -0,0 +1,96 @@ | ||||
| package kr.co.vividnext.sodalive.agora | ||||
|  | ||||
| import org.springframework.stereotype.Component | ||||
|  | ||||
| @Component | ||||
| class RtcTokenBuilder { | ||||
|     /** | ||||
|      * Builds an RTC token using an int uid. | ||||
|      * | ||||
|      * @param appId The App ID issued to you by Agora. | ||||
|      * @param appCertificate Certificate of the application that you registered in | ||||
|      * the Agora Dashboard. | ||||
|      * @param channelName The unique channel name for the AgoraRTC session in the string format. The string length must be less than 64 bytes. Supported character scopes are: | ||||
|      * | ||||
|      *  *  The 26 lowercase English letters: a to z. | ||||
|      *  *  The 26 uppercase English letters: A to Z. | ||||
|      *  *  The 10 digits: 0 to 9. | ||||
|      *  *  The space. | ||||
|      *  *  "!", "#", "$", "%", "&", "(", ")", "+", "-", ":", ";", "<", "=", ".", ">", "?", "@", "[", "]", "^", "_", " {", "}", "|", "~", ",". | ||||
|      * | ||||
|      * @param uid  User ID. A 32-bit unsigned integer with a value ranging from | ||||
|      * 1 to (2^32-1). | ||||
|      * @param role The user role. | ||||
|      * | ||||
|      *  *  Role_Publisher = 1: RECOMMENDED. Use this role for a voice/video call or a live broadcast. | ||||
|      *  *  Role_Subscriber = 2: ONLY use this role if your live-broadcast scenario requires authentication for [Hosting-in](https://docs.agora.io/en/Agora%20Platform/terms?platform=All%20Platforms#hosting-in). In order for this role to take effect, please contact our support team to enable authentication for Hosting-in for you. Otherwise, Role_Subscriber still has the same privileges as Role_Publisher. | ||||
|      * | ||||
|      * @param privilegeTs Represented by the number of seconds elapsed since 1/1/1970. | ||||
|      * If, for example, you want to access the Agora Service within 10 minutes | ||||
|      * after the token is generated, set expireTimestamp as the current time stamp | ||||
|      * + 600 (seconds). | ||||
|      */ | ||||
|     fun buildTokenWithUid( | ||||
|         appId: String, | ||||
|         appCertificate: String, | ||||
|         channelName: String, | ||||
|         uid: Int, | ||||
|         privilegeTs: Int | ||||
|     ): String { | ||||
|         val account = if (uid == 0) "" else uid.toString() | ||||
|         return buildTokenWithUserAccount( | ||||
|             appId, | ||||
|             appCertificate, | ||||
|             channelName, | ||||
|             account, | ||||
|             privilegeTs | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Builds an RTC token using a string userAccount. | ||||
|      * | ||||
|      * @param appId The App ID issued to you by Agora. | ||||
|      * @param appCertificate Certificate of the application that you registered in | ||||
|      * the Agora Dashboard. | ||||
|      * @param channelName The unique channel name for the AgoraRTC session in the string format. The string length must be less than 64 bytes. Supported character scopes are: | ||||
|      * | ||||
|      *  *  The 26 lowercase English letters: a to z. | ||||
|      *  *  The 26 uppercase English letters: A to Z. | ||||
|      *  *  The 10 digits: 0 to 9. | ||||
|      *  *  The space. | ||||
|      *  *  "!", "#", "$", "%", "&", "(", ")", "+", "-", ":", ";", "<", "=", ".", ">", "?", "@", "[", "]", "^", "_", " {", "}", "|", "~", ",". | ||||
|      * | ||||
|      * @param account  The user account. | ||||
|      * @param role The user role. | ||||
|      * | ||||
|      *  *  Role_Publisher = 1: RECOMMENDED. Use this role for a voice/video call or a live broadcast. | ||||
|      *  *  Role_Subscriber = 2: ONLY use this role if your live-broadcast scenario requires authentication for [Hosting-in](https://docs.agora.io/en/Agora%20Platform/terms?platform=All%20Platforms#hosting-in). In order for this role to take effect, please contact our support team to enable authentication for Hosting-in for you. Otherwise, Role_Subscriber still has the same privileges as Role_Publisher. | ||||
|      * | ||||
|      * @param privilegeTs represented by the number of seconds elapsed since 1/1/1970. | ||||
|      * If, for example, you want to access the Agora Service within 10 minutes | ||||
|      * after the token is generated, set expireTimestamp as the current time stamp | ||||
|      * + 600 (seconds). | ||||
|      */ | ||||
|     fun buildTokenWithUserAccount( | ||||
|         appId: String, | ||||
|         appCertificate: String, | ||||
|         channelName: String, | ||||
|         account: String, | ||||
|         privilegeTs: Int | ||||
|     ): String { | ||||
|         // Assign appropriate access privileges to each role. | ||||
|         val builder = AccessToken(appId, appCertificate, channelName, account) | ||||
|         builder.addPrivilege(AccessToken.Privileges.JoinChannel, privilegeTs) | ||||
|         builder.addPrivilege(AccessToken.Privileges.PublishAudioStream, privilegeTs) | ||||
|         builder.addPrivilege(AccessToken.Privileges.PublishVideoStream, privilegeTs) | ||||
|         builder.addPrivilege(AccessToken.Privileges.PublishDataStream, privilegeTs) | ||||
|  | ||||
|         return try { | ||||
|             builder.build() | ||||
|         } catch (e: Exception) { | ||||
|             e.printStackTrace() | ||||
|             "" | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,31 @@ | ||||
| package kr.co.vividnext.sodalive.agora | ||||
|  | ||||
| import kr.co.vividnext.sodalive.agora.AccessToken.Privileges | ||||
| import org.springframework.stereotype.Component | ||||
|  | ||||
| @Component | ||||
| class RtmTokenBuilder { | ||||
|  | ||||
|     lateinit var mTokenCreator: AccessToken | ||||
|  | ||||
|     @Throws(Exception::class) | ||||
|     fun buildToken( | ||||
|         appId: String, | ||||
|         appCertificate: String, | ||||
|         uid: String, | ||||
|         privilegeTs: Int | ||||
|     ): String { | ||||
|         mTokenCreator = AccessToken(appId, appCertificate, uid, "") | ||||
|         mTokenCreator.addPrivilege(Privileges.RtmLogin, privilegeTs) | ||||
|         return mTokenCreator.build() | ||||
|     } | ||||
|  | ||||
|     fun setPrivilege(privilege: Privileges?, expireTs: Int) { | ||||
|         mTokenCreator.addPrivilege(privilege!!, expireTs) | ||||
|     } | ||||
|  | ||||
|     fun initTokenBuilder(originToken: String?): Boolean { | ||||
|         mTokenCreator.fromString(originToken!!) | ||||
|         return true | ||||
|     } | ||||
| } | ||||
| @@ -27,7 +27,7 @@ interface CanQueryRepository { | ||||
|     fun getCanUseStatus(member: Member, pageable: Pageable): List<UseCan> | ||||
|     fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List<Charge> | ||||
|     fun isExistPaidLiveRoom(memberId: Long, roomId: Long): UseCan? | ||||
|     fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long): UseCan? | ||||
|     fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage = CanUsage.LIVE): UseCan? | ||||
| } | ||||
|  | ||||
| @Repository | ||||
| @@ -113,7 +113,7 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue | ||||
|             .fetchFirst() | ||||
|     } | ||||
|  | ||||
|     override fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long): UseCan? { | ||||
|     override fun getCanUsedForLiveRoomNotRefund(memberId: Long, roomId: Long, canUsage: CanUsage): UseCan? { | ||||
|         return queryFactory | ||||
|             .selectFrom(useCan) | ||||
|             .innerJoin(useCan.member, member) | ||||
| @@ -121,7 +121,7 @@ class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQue | ||||
|             .where( | ||||
|                 member.id.eq(memberId) | ||||
|                     .and(liveRoom.id.eq(roomId)) | ||||
|                     .and(useCan.canUsage.eq(CanUsage.LIVE)) | ||||
|                     .and(useCan.canUsage.eq(canUsage)) | ||||
|                     .and(useCan.isRefund.isFalse) | ||||
|             ) | ||||
|             .orderBy(useCan.id.desc()) | ||||
|   | ||||
| @@ -0,0 +1,71 @@ | ||||
| package kr.co.vividnext.sodalive.explorer | ||||
|  | ||||
| import com.querydsl.jpa.impl.JPAQueryFactory | ||||
| import kr.co.vividnext.sodalive.can.use.CanUsage | ||||
| import kr.co.vividnext.sodalive.can.use.QUseCan.useCan | ||||
| import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom | ||||
| import kr.co.vividnext.sodalive.member.QMember | ||||
| import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing | ||||
| import org.springframework.beans.factory.annotation.Value | ||||
| import org.springframework.stereotype.Repository | ||||
|  | ||||
| @Repository | ||||
| class ExplorerQueryRepository( | ||||
|     private val queryFactory: JPAQueryFactory, | ||||
|  | ||||
|     @Value("\${cloud.aws.cloud-front.host}") | ||||
|     private val cloudFrontHost: String | ||||
| ) { | ||||
|     fun getNotificationUserIds(creatorId: Long): List<Long> { | ||||
|         return queryFactory | ||||
|             .select(creatorFollowing.member.id) | ||||
|             .from(creatorFollowing) | ||||
|             .where( | ||||
|                 creatorFollowing.isActive.isTrue | ||||
|                     .and(creatorFollowing.creator.id.eq(creatorId)) | ||||
|             ) | ||||
|             .fetch() | ||||
|     } | ||||
|  | ||||
|     fun getMemberDonationRanking( | ||||
|         creatorId: Long, | ||||
|         limit: Long, | ||||
|         offset: Long = 0, | ||||
|         withDonationCoin: Boolean | ||||
|     ): List<MemberDonationRankingResponse> { | ||||
|         val creator = QMember("creator") | ||||
|         val member = QMember("user") | ||||
|  | ||||
|         val donation = useCan.rewardCan.add(useCan.can).sum() | ||||
|         return queryFactory | ||||
|             .select(member, donation) | ||||
|             .from(useCan) | ||||
|             .join(useCan.room, liveRoom) | ||||
|             .join(liveRoom.member, creator) | ||||
|             .join(useCan.member, member) | ||||
|             .offset(offset) | ||||
|             .limit(limit) | ||||
|             .where( | ||||
|                 useCan.canUsage.eq(CanUsage.DONATION) | ||||
|                     .and(useCan.isRefund.isFalse) | ||||
|                     .and(creator.id.eq(creatorId)) | ||||
|             ) | ||||
|             .groupBy(useCan.member.id) | ||||
|             .orderBy(donation.desc(), member.id.desc()) | ||||
|             .fetch() | ||||
|             .map { | ||||
|                 val account = it.get(member)!! | ||||
|                 val donationCoin = it.get(donation)!! | ||||
|                 MemberDonationRankingResponse( | ||||
|                     account.id!!, | ||||
|                     account.nickname, | ||||
|                     if (account.profileImage != null) { | ||||
|                         "$cloudFrontHost/${account.profileImage}" | ||||
|                     } else { | ||||
|                         "$cloudFrontHost/profile/default-profile.png" | ||||
|                     }, | ||||
|                     if (withDonationCoin) donationCoin else 0 | ||||
|                 ) | ||||
|             } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| package kr.co.vividnext.sodalive.explorer | ||||
|  | ||||
| data class MemberDonationRankingResponse( | ||||
|     val userId: Long, | ||||
|     val nickname: String, | ||||
|     val profileImage: String, | ||||
|     val donationCoin: Int | ||||
| ) | ||||
| @@ -0,0 +1,18 @@ | ||||
| package kr.co.vividnext.sodalive.live.room | ||||
|  | ||||
| data class GetLiveRoomUserProfileResponse( | ||||
|     val userId: Long, | ||||
|     val nickname: String, | ||||
|     val profileUrl: String, | ||||
|     val gender: String, | ||||
|     val instagramUrl: String, | ||||
|     val youtubeUrl: String, | ||||
|     val websiteUrl: String, | ||||
|     val blogUrl: String, | ||||
|     val introduce: String, | ||||
|     val tags: String, | ||||
|     val isSpeaker: Boolean?, | ||||
|     val isManager: Boolean?, | ||||
|     val isFollowing: Boolean?, | ||||
|     val isBlock: Boolean | ||||
| ) | ||||
| @@ -3,9 +3,12 @@ package kr.co.vividnext.sodalive.live.room | ||||
| import kr.co.vividnext.sodalive.common.ApiResponse | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest | ||||
| import kr.co.vividnext.sodalive.live.room.donation.DeleteLiveRoomDonationMessage | ||||
| import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationRequest | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| import org.springframework.data.domain.Pageable | ||||
| import org.springframework.security.core.annotation.AuthenticationPrincipal | ||||
| 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 | ||||
| @@ -106,4 +109,124 @@ class LiveRoomController(private val service: LiveRoomService) { | ||||
|  | ||||
|         ApiResponse.ok(service.editLiveRoomInfo(roomId, coverImage, requestString, member)) | ||||
|     } | ||||
|  | ||||
|     @GetMapping("/info/{id}") | ||||
|     fun getRoomInfo( | ||||
|         @PathVariable id: Long, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.getRoomInfo(roomId = id, member)) | ||||
|     } | ||||
|  | ||||
|     @GetMapping("/donation-message") | ||||
|     fun getDonationMessageList( | ||||
|         @RequestParam roomId: Long, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.getDonationMessageList(roomId, member)) | ||||
|     } | ||||
|  | ||||
|     @DeleteMapping("/donation-message") | ||||
|     fun removeDonationMessage( | ||||
|         @RequestBody request: DeleteLiveRoomDonationMessage, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.deleteDonationMessage(request, member)) | ||||
|     } | ||||
|  | ||||
|     @GetMapping("/{room_id}/profile/{user_id}") | ||||
|     fun getUserProfile( | ||||
|         @PathVariable("room_id") roomId: Long, | ||||
|         @PathVariable("user_id") userId: Long, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.getUserProfile(roomId, userId, member)) | ||||
|     } | ||||
|  | ||||
|     @GetMapping("/{id}/donation-total") | ||||
|     fun donationTotal( | ||||
|         @PathVariable("id") roomId: Long, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.getDonationTotal(roomId)) | ||||
|     } | ||||
|  | ||||
|     @PutMapping("/info/set/speaker") | ||||
|     fun setSpeaker( | ||||
|         @RequestBody request: SetManagerOrSpeakerOrAudienceRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.setSpeaker(request)) | ||||
|     } | ||||
|  | ||||
|     @PutMapping("/info/set/listener") | ||||
|     fun setListener( | ||||
|         @RequestBody request: SetManagerOrSpeakerOrAudienceRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.setListener(request)) | ||||
|     } | ||||
|  | ||||
|     @PutMapping("/info/set/manager") | ||||
|     fun setManager( | ||||
|         @RequestBody request: SetManagerOrSpeakerOrAudienceRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.setManager(request, member)) | ||||
|     } | ||||
|  | ||||
|     @PostMapping("/donation") | ||||
|     fun donation( | ||||
|         @RequestBody request: LiveRoomDonationRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.donation(request, member)) | ||||
|     } | ||||
|  | ||||
|     @PostMapping("/donation/refund/{id}") | ||||
|     fun refundDonation( | ||||
|         @PathVariable id: Long, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.refundDonation(id, member)) | ||||
|     } | ||||
|  | ||||
|     @GetMapping("/{id}/donation-list") | ||||
|     fun donationList( | ||||
|         @PathVariable("id") roomId: Long, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.getDonationStatus(roomId, member)) | ||||
|     } | ||||
|  | ||||
|     @PostMapping("/quit") | ||||
|     fun quitRoom( | ||||
|         @RequestParam("id") roomId: Long, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|         ApiResponse.ok(service.quitRoom(roomId, member)) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -6,7 +6,12 @@ import com.querydsl.core.types.Projections | ||||
| import com.querydsl.core.types.dsl.CaseBuilder | ||||
| import com.querydsl.core.types.dsl.Expressions | ||||
| import com.querydsl.jpa.impl.JPAQueryFactory | ||||
| import kr.co.vividnext.sodalive.can.use.CanUsage | ||||
| import kr.co.vividnext.sodalive.can.use.QUseCan.useCan | ||||
| import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate | ||||
| import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom | ||||
| import kr.co.vividnext.sodalive.live.room.donation.GetLiveRoomDonationItem | ||||
| import kr.co.vividnext.sodalive.live.room.donation.QGetLiveRoomDonationItem | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| import kr.co.vividnext.sodalive.member.QMember | ||||
| import kr.co.vividnext.sodalive.member.QMember.member | ||||
| @@ -34,6 +39,8 @@ interface LiveRoomQueryRepository { | ||||
|     fun getLiveRoom(id: Long): LiveRoom? | ||||
|     fun getLiveRoomAndAccountId(roomId: Long, memberId: Long): LiveRoom? | ||||
|     fun getRecentRoomInfo(memberId: Long, cloudFrontHost: String): GetRecentRoomInfoResponse? | ||||
|     fun getDonationTotal(roomId: Long): Int? | ||||
|     fun getDonationList(roomId: Long, cloudFrontHost: String): List<GetLiveRoomDonationItem> | ||||
| } | ||||
|  | ||||
| class LiveRoomQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveRoomQueryRepository { | ||||
| @@ -143,6 +150,45 @@ class LiveRoomQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : L | ||||
|             .fetchFirst() | ||||
|     } | ||||
|  | ||||
|     override fun getDonationTotal(roomId: Long): Int? { | ||||
|         return queryFactory | ||||
|             .select(useCanCalculate.can.sum()) | ||||
|             .from(useCanCalculate) | ||||
|             .innerJoin(useCanCalculate.useCan, useCan) | ||||
|             .innerJoin(useCan.room, liveRoom) | ||||
|             .where( | ||||
|                 liveRoom.id.eq(roomId) | ||||
|                     .and(useCan.canUsage.eq(CanUsage.DONATION)) | ||||
|                     .and(useCan.isRefund.isFalse) | ||||
|             ) | ||||
|             .fetchOne() | ||||
|     } | ||||
|  | ||||
|     override fun getDonationList(roomId: Long, cloudFrontHost: String): List<GetLiveRoomDonationItem> { | ||||
|         return queryFactory | ||||
|             .select( | ||||
|                 QGetLiveRoomDonationItem( | ||||
|                     member.profileImage | ||||
|                         .coalesce("profile/default-profile.png") | ||||
|                         .prepend("/") | ||||
|                         .prepend(cloudFrontHost), | ||||
|                     member.nickname, | ||||
|                     member.id.coalesce(0), | ||||
|                     useCan.can.sum().add(useCan.rewardCan.sum()) | ||||
|                 ) | ||||
|             ) | ||||
|             .from(useCan) | ||||
|             .join(useCan.member, member) | ||||
|             .groupBy(useCan.member) | ||||
|             .where( | ||||
|                 useCan.room.id.eq(roomId) | ||||
|                     .and(useCan.canUsage.eq(CanUsage.DONATION)) | ||||
|                     .and(useCan.isRefund.isFalse) | ||||
|             ) | ||||
|             .orderBy(useCan.can.sum().add(useCan.rewardCan.sum()).desc()) | ||||
|             .fetch() | ||||
|     } | ||||
|  | ||||
|     private fun orderByFieldAccountId( | ||||
|         memberId: Long, | ||||
|         status: LiveRoomStatus, | ||||
|   | ||||
| @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.live.room | ||||
|  | ||||
| import com.amazonaws.services.s3.model.ObjectMetadata | ||||
| import com.fasterxml.jackson.databind.ObjectMapper | ||||
| import kr.co.vividnext.sodalive.agora.RtcTokenBuilder | ||||
| import kr.co.vividnext.sodalive.agora.RtmTokenBuilder | ||||
| import kr.co.vividnext.sodalive.aws.s3.S3Uploader | ||||
| import kr.co.vividnext.sodalive.can.CanRepository | ||||
| import kr.co.vividnext.sodalive.can.charge.Charge | ||||
| @@ -15,6 +17,7 @@ import kr.co.vividnext.sodalive.can.use.CanUsage | ||||
| import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository | ||||
| import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository | ||||
| import kr.co.vividnext.sodalive.extensions.convertLocalDateTime | ||||
| import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository | ||||
| import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest | ||||
| @@ -23,12 +26,22 @@ import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancelRepository | ||||
| import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailManager | ||||
| import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse | ||||
| import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser | ||||
| import kr.co.vividnext.sodalive.live.room.donation.DeleteLiveRoomDonationMessage | ||||
| import kr.co.vividnext.sodalive.live.room.donation.GetLiveRoomDonationStatusResponse | ||||
| import kr.co.vividnext.sodalive.live.room.donation.GetLiveRoomDonationTotalResponse | ||||
| import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationMessage | ||||
| import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationRequest | ||||
| import kr.co.vividnext.sodalive.live.room.info.GetRoomInfoResponse | ||||
| import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfo | ||||
| import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository | ||||
| import kr.co.vividnext.sodalive.live.room.info.LiveRoomMember | ||||
| import kr.co.vividnext.sodalive.live.room.kickout.LiveRoomKickOutService | ||||
| import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService | ||||
| import kr.co.vividnext.sodalive.live.tag.LiveTagRepository | ||||
| import kr.co.vividnext.sodalive.member.Gender | ||||
| 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.utils.generateFileName | ||||
| import org.springframework.beans.factory.annotation.Value | ||||
| import org.springframework.data.domain.Pageable | ||||
| @@ -39,6 +52,7 @@ import org.springframework.web.multipart.MultipartFile | ||||
| import java.time.LocalDateTime | ||||
| import java.time.ZoneId | ||||
| import java.time.format.DateTimeFormatter | ||||
| import java.util.Date | ||||
|  | ||||
| @Service | ||||
| @Transactional(readOnly = true) | ||||
| @@ -46,9 +60,11 @@ class LiveRoomService( | ||||
|     private val repository: LiveRoomRepository, | ||||
|     private val roomInfoRepository: LiveRoomInfoRedisRepository, | ||||
|     private val roomCancelRepository: LiveRoomCancelRepository, | ||||
|     private val kickOutService: LiveRoomKickOutService, | ||||
|  | ||||
|     private val useCanCalculateRepository: UseCanCalculateRepository, | ||||
|     private val reservationRepository: LiveReservationRepository, | ||||
|     private val explorerQueryRepository: ExplorerQueryRepository, | ||||
|     private val roomVisitService: LiveRoomVisitService, | ||||
|     private val canPaymentService: CanPaymentService, | ||||
|     private val chargeRepository: ChargeRepository, | ||||
| @@ -58,6 +74,15 @@ class LiveRoomService( | ||||
|     private val objectMapper: ObjectMapper, | ||||
|     private val s3Uploader: S3Uploader, | ||||
|  | ||||
|     private val rtcTokenBuilder: RtcTokenBuilder, | ||||
|     private val rtmTokenBuilder: RtmTokenBuilder, | ||||
|  | ||||
|     @Value("\${agora.app-id}") | ||||
|     private val agoraAppId: String, | ||||
|  | ||||
|     @Value("\${agora.app-certificate}") | ||||
|     private val agoraAppCertificate: String, | ||||
|  | ||||
|     @Value("\${cloud.aws.s3.bucket}") | ||||
|     private val coverImageBucket: String, | ||||
|     @Value("\${cloud.aws.cloud-front.host}") | ||||
| @@ -322,6 +347,7 @@ class LiveRoomService( | ||||
|             "${dateTime.hour}_${dateTime.minute}" | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun cancelLive(request: CancelLiveRequest, member: Member) { | ||||
|         val room = repository.getLiveRoomAndAccountId(request.roomId, member.id!!) | ||||
|             ?: throw SodaException("해당하는 라이브가 없습니다.") | ||||
| @@ -339,8 +365,11 @@ class LiveRoomService( | ||||
|         if (room.price > 0) { | ||||
|             val bookerList = reservationRepository.getReservationBookerList(roomId = room.id!!) | ||||
|             for (booker in bookerList) { | ||||
|                 val useCan = canRepository.getCanUsedForLiveRoomNotRefund(memberId = booker.id!!, roomId = room.id!!) | ||||
|                     ?: continue | ||||
|                 val useCan = canRepository.getCanUsedForLiveRoomNotRefund( | ||||
|                     memberId = booker.id!!, | ||||
|                     roomId = room.id!!, | ||||
|                     canUsage = CanUsage.LIVE | ||||
|                 ) ?: continue | ||||
|                 useCan.isRefund = true | ||||
|  | ||||
|                 val useCanCalculate = useCanCalculateRepository.findByUseCanIdAndStatus(useCanId = useCan.id!!) | ||||
| @@ -373,6 +402,7 @@ class LiveRoomService( | ||||
|         reservationRepository.cancelReservation(roomId = room.id!!) | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun enterLive(request: EnterOrQuitLiveRoomRequest, member: Member) { | ||||
|         val room = repository.getLiveRoom(id = request.roomId) | ||||
|             ?: throw SodaException("해당하는 라이브가 없습니다.") | ||||
| @@ -491,4 +521,342 @@ class LiveRoomService( | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun getRoomInfo(roomId: Long, member: Member): GetRoomInfoResponse { | ||||
|         val roomInfo = roomInfoRepository.findByIdOrNull(roomId) | ||||
|             ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|  | ||||
|         val room = repository.findByIdOrNull(roomId) | ||||
|             ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|  | ||||
|         val currentTimeStamp = Date().time | ||||
|         val expireTimestamp = (currentTimeStamp + (60 * 60 * 24 * 1000)) / 1000 | ||||
|  | ||||
|         val rtcToken = rtcTokenBuilder.buildTokenWithUid( | ||||
|             agoraAppId, | ||||
|             agoraAppCertificate, | ||||
|             room.channelName!!, | ||||
|             member.id!!.toInt(), | ||||
|             expireTimestamp.toInt() | ||||
|         ) | ||||
|  | ||||
|         val rtmToken = rtmTokenBuilder.buildToken( | ||||
|             agoraAppId, | ||||
|             agoraAppCertificate, | ||||
|             member.id!!.toString(), | ||||
|             expireTimestamp.toInt() | ||||
|         ) | ||||
|  | ||||
|         val tags = room.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList() | ||||
|         val isRadioMode = tags.contains("라디오") or tags.contains("콘서트") | ||||
|         val isAvailableDonation = room.member!!.id!! != member.id!! && | ||||
|             room.member!!.role == MemberRole.CREATOR | ||||
|         val isFollowingManager = explorerQueryRepository | ||||
|             .getNotificationUserIds(room.member!!.id!!) | ||||
|             .contains(member.id) | ||||
|  | ||||
|         val donationRankingTop3UserIds = explorerQueryRepository | ||||
|             .getMemberDonationRanking( | ||||
|                 room.member!!.id!!, | ||||
|                 3, | ||||
|                 withDonationCoin = false | ||||
|             ) | ||||
|             .asSequence() | ||||
|             .map { it.userId } | ||||
|             .toList() | ||||
|  | ||||
|         return GetRoomInfoResponse( | ||||
|             roomId = roomId, | ||||
|             title = room.title, | ||||
|             notice = room.notice, | ||||
|             coverImageUrl = if (room.bgImage != null) { | ||||
|                 if (room.bgImage!!.startsWith("https://")) { | ||||
|                     room.bgImage!! | ||||
|                 } else { | ||||
|                     "$cloudFrontHost/${room.bgImage!!}" | ||||
|                 } | ||||
|             } else { | ||||
|                 if (room.coverImage!!.startsWith("https://")) { | ||||
|                     room.coverImage!! | ||||
|                 } else { | ||||
|                     "$cloudFrontHost/${room.coverImage!!}" | ||||
|                 } | ||||
|             }, | ||||
|             channelName = room.channelName!!, | ||||
|             rtcToken = rtcToken, | ||||
|             rtmToken = rtmToken, | ||||
|             managerId = room.member!!.id!!, | ||||
|             managerNickname = room.member!!.nickname, | ||||
|             managerProfileUrl = if (room.member!!.profileImage != null) { | ||||
|                 "$cloudFrontHost/${room.member!!.profileImage}" | ||||
|             } else { | ||||
|                 "$cloudFrontHost/profile/default-profile.png" | ||||
|             }, | ||||
|             isFollowingManager = isFollowingManager, | ||||
|             participantsCount = roomInfo.listenerCount + roomInfo.speakerCount + roomInfo.managerCount, | ||||
|             totalAvailableParticipantsCount = room.numberOfPeople, | ||||
|             speakerList = roomInfo.speakerList, | ||||
|             listenerList = roomInfo.listenerList, | ||||
|             managerList = roomInfo.managerList, | ||||
|             donationRankingTop3UserIds = donationRankingTop3UserIds, | ||||
|             isRadioMode = isRadioMode, | ||||
|             isAvailableDonation = isAvailableDonation, | ||||
|             isPrivateRoom = room.type == LiveRoomType.PRIVATE, | ||||
|             password = room.password | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     fun getDonationMessageList(roomId: Long, member: Member): List<LiveRoomDonationMessage> { | ||||
|         val room = repository.findByIdOrNull(roomId) | ||||
|             ?: throw SodaException("해당하는 라이브가 없습니다.") | ||||
|  | ||||
|         if (member.id!! != room.member!!.id!!) { | ||||
|             throw SodaException("잘못된 요청입니다.") | ||||
|         } | ||||
|  | ||||
|         val roomInfo = roomInfoRepository.findByIdOrNull(roomId) | ||||
|             ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|  | ||||
|         return roomInfo.donationMessageList | ||||
|     } | ||||
|  | ||||
|     fun deleteDonationMessage(request: DeleteLiveRoomDonationMessage, member: Member) { | ||||
|         val room = repository.findByIdOrNull(request.roomId) | ||||
|             ?: throw SodaException("해당하는 라이브가 없습니다.") | ||||
|  | ||||
|         if (member.id!! != room.member!!.id!!) { | ||||
|             throw SodaException("잘못된 요청입니다.") | ||||
|         } | ||||
|  | ||||
|         val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) | ||||
|             ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|  | ||||
|         roomInfo.removeDonationMessage(request.messageUUID) | ||||
|         roomInfoRepository.save(roomInfo) | ||||
|     } | ||||
|  | ||||
|     fun getUserProfile(roomId: Long, userId: Long, member: Member): GetLiveRoomUserProfileResponse { | ||||
|         val room = repository.getLiveRoom(roomId) | ||||
|             ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|         val roomInfo = roomInfoRepository.findByIdOrNull(roomId) | ||||
|             ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|  | ||||
|         val user = memberRepository.findByIdOrNull(userId) | ||||
|             ?: throw SodaException("잘못된 요청입니다.") | ||||
|  | ||||
|         val isFollowing = if (user.role == MemberRole.CREATOR) { | ||||
|             explorerQueryRepository | ||||
|                 .getNotificationUserIds(userId) | ||||
|                 .contains(member.id!!) | ||||
|         } else { | ||||
|             null | ||||
|         } | ||||
|  | ||||
|         // 조회 하는 유저 | ||||
|         val memberResponse = LiveRoomMember(member) | ||||
|         // 조회 당하는 유저 | ||||
|         val userResponse = LiveRoomMember(user) | ||||
|  | ||||
|         val isSpeaker = if ( | ||||
|             room.member!!.id!! != userId && | ||||
|             (room.member!!.id!! == member.id!! || roomInfo.managerList.contains(memberResponse)) | ||||
|         ) { | ||||
|             roomInfo.speakerList.contains(userResponse) | ||||
|         } else { | ||||
|             null | ||||
|         } | ||||
|  | ||||
|         val isManager = if (room.member!!.id!! != userId && room.member!!.id!! == member.id!!) { | ||||
|             roomInfo.managerList.contains(userResponse) | ||||
|         } else { | ||||
|             null | ||||
|         } | ||||
|  | ||||
|         return GetLiveRoomUserProfileResponse( | ||||
|             userId = user.id!!, | ||||
|             nickname = user.nickname, | ||||
|             profileUrl = if (user.profileImage != null) { | ||||
|                 "$cloudFrontHost/${user.profileImage}" | ||||
|             } else { | ||||
|                 "$cloudFrontHost/profile/default-profile.png" | ||||
|             }, | ||||
|             gender = if (user.gender == Gender.FEMALE) "여" else if (user.gender == Gender.MALE) "남" else "미", | ||||
|             instagramUrl = user.instagramUrl, | ||||
|             youtubeUrl = user.youtubeUrl, | ||||
|             websiteUrl = user.websiteUrl, | ||||
|             blogUrl = user.blogUrl, | ||||
|             introduce = user.introduce, | ||||
|             tags = "", | ||||
|             isSpeaker = isSpeaker, | ||||
|             isManager = isManager, | ||||
|             isFollowing = isFollowing, | ||||
|             isBlock = false | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     fun getDonationTotal(roomId: Long): GetLiveRoomDonationTotalResponse { | ||||
|         return GetLiveRoomDonationTotalResponse( | ||||
|             totalDonationCoin = repository.getDonationTotal(roomId = roomId) ?: 0 | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     fun setSpeaker(request: SetManagerOrSpeakerOrAudienceRequest) { | ||||
|         val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) | ||||
|             ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|  | ||||
|         val account = memberRepository.findByIdOrNull(request.accountId) | ||||
|             ?: throw SodaException("로그인 정보를 확인해 주세요.") | ||||
|  | ||||
|         if (roomInfo.speakerCount > 9) { | ||||
|             throw SodaException("스피커 정원이 초과하였습니다.") | ||||
|         } | ||||
|  | ||||
|         roomInfo.removeListener(account) | ||||
|         roomInfo.removeManager(account) | ||||
|         roomInfo.addSpeaker(account) | ||||
|  | ||||
|         roomInfoRepository.save(roomInfo) | ||||
|     } | ||||
|  | ||||
|     fun setListener(request: SetManagerOrSpeakerOrAudienceRequest) { | ||||
|         val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) | ||||
|             ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|  | ||||
|         val member = memberRepository.findByIdOrNull(request.accountId) | ||||
|             ?: throw SodaException("로그인 정보를 확인해 주세요.") | ||||
|  | ||||
|         roomInfo.removeSpeaker(member) | ||||
|         roomInfo.removeManager(member) | ||||
|         roomInfo.addListener(member) | ||||
|  | ||||
|         roomInfoRepository.save(roomInfo) | ||||
|     } | ||||
|  | ||||
|     fun setManager(request: SetManagerOrSpeakerOrAudienceRequest, member: Member) { | ||||
|         val room = repository.getLiveRoom(request.roomId) ?: throw SodaException("잘못된 요청입니다.") | ||||
|         if (room.member!!.id!! != member.id!!) { | ||||
|             throw SodaException("권한이 없습니다.") | ||||
|         } | ||||
|  | ||||
|         val user = memberRepository.findByIdOrNull(request.accountId) ?: throw SodaException("해당하는 유저가 없습니다.") | ||||
|         val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) | ||||
|             ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|  | ||||
|         val roomAccountResponse = LiveRoomMember(member = user) | ||||
|         if (roomInfo.managerList.contains(roomAccountResponse)) { | ||||
|             throw SodaException("이미 매니저 입니다.") | ||||
|         } | ||||
|  | ||||
|         if ( | ||||
|             !roomInfo.speakerList.contains(roomAccountResponse) && | ||||
|             !roomInfo.listenerList.contains(roomAccountResponse) | ||||
|         ) { | ||||
|             throw SodaException("해당하는 유저가 없습니다.") | ||||
|         } | ||||
|  | ||||
|         roomInfo.removeListener(user) | ||||
|         roomInfo.removeSpeaker(user) | ||||
|         roomInfo.addManager(user) | ||||
|  | ||||
|         roomInfoRepository.save(roomInfo) | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun donation(request: LiveRoomDonationRequest, member: Member) { | ||||
|         val room = repository.findByIdOrNull(request.roomId) | ||||
|             ?: throw SodaException("해당하는 라이브가 없습니다.") | ||||
|  | ||||
|         val host = room.member ?: throw SodaException("잘못된 요청입니다.") | ||||
|  | ||||
|         if (host.role != MemberRole.CREATOR) { | ||||
|             throw SodaException("비비드넥스트와 계약한\n요즘친구에게만 후원을 하실 수 있습니다.") | ||||
|         } | ||||
|  | ||||
|         canPaymentService.spendCan( | ||||
|             memberId = member.id!!, | ||||
|             needCan = request.can, | ||||
|             canUsage = CanUsage.DONATION, | ||||
|             liveRoom = room, | ||||
|             container = request.container | ||||
|         ) | ||||
|  | ||||
|         if (request.message.isNotBlank()) { | ||||
|             val roomInfo = roomInfoRepository.findByIdOrNull(room.id!!) | ||||
|                 ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|  | ||||
|             roomInfo.addDonationMessage( | ||||
|                 nickname = member.nickname, | ||||
|                 can = request.can, | ||||
|                 donationMessage = request.message | ||||
|             ) | ||||
|  | ||||
|             roomInfoRepository.save(roomInfo) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Transactional | ||||
|     fun refundDonation(roomId: Long, member: Member) { | ||||
|         val donator = memberRepository.findByIdOrNull(member.id) | ||||
|             ?: throw SodaException("후원에 실패한 코인이 환불되지 않았습니다\n고객센터로 문의해주세요.") | ||||
|  | ||||
|         val useCan = canRepository.getCanUsedForLiveRoomNotRefund( | ||||
|             memberId = member.id!!, | ||||
|             roomId = roomId, | ||||
|             canUsage = CanUsage.DONATION | ||||
|         ) ?: throw SodaException("후원에 실패한 코인이 환불되지 않았습니다\n고객센터로 문의해주세요.") | ||||
|         useCan.isRefund = true | ||||
|  | ||||
|         val useCoinCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!) | ||||
|         useCoinCalculates.forEach { | ||||
|             it.status = UseCanCalculateStatus.REFUND | ||||
|             val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE) | ||||
|             charge.title = "${it.can} 코인" | ||||
|             charge.useCan = useCan | ||||
|  | ||||
|             when (it.paymentGateway) { | ||||
|                 PaymentGateway.PG -> donator.pgRewardCan += charge.rewardCan | ||||
|                 PaymentGateway.GOOGLE_IAP -> donator.googleRewardCan += charge.rewardCan | ||||
|                 PaymentGateway.APPLE_IAP -> donator.appleRewardCan += charge.rewardCan | ||||
|             } | ||||
|             charge.member = donator | ||||
|  | ||||
|             val payment = Payment( | ||||
|                 status = PaymentStatus.COMPLETE, | ||||
|                 paymentGateway = it.paymentGateway | ||||
|             ) | ||||
|             payment.method = "환불" | ||||
|             charge.payment = payment | ||||
|  | ||||
|             chargeRepository.save(charge) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun getDonationStatus(roomId: Long, member: Member): GetLiveRoomDonationStatusResponse { | ||||
|         val room = repository.getLiveRoom(roomId) ?: throw SodaException("잘못된 요청입니다.") | ||||
|         val donationList = repository.getDonationList(roomId = room.id!!, cloudFrontHost = cloudFrontHost) | ||||
|         val totalCan = donationList.sumOf { it.can } | ||||
|  | ||||
|         return GetLiveRoomDonationStatusResponse( | ||||
|             donationList = donationList, | ||||
|             totalCount = donationList.size, | ||||
|             totalCan = totalCan | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     fun quitRoom(roomId: Long, member: Member) { | ||||
|         val room = repository.getLiveRoom(roomId) | ||||
|         val roomInfo = roomInfoRepository.findByIdOrNull(roomId) | ||||
|         if (roomInfo != null) { | ||||
|             if (room?.member != null && room.member!! == member) { | ||||
|                 room.isActive = false | ||||
|                 kickOutService.deleteKickOutData(roomId = room.id!!) | ||||
|                 roomInfoRepository.deleteById(roomInfo.roomId) | ||||
|             } else { | ||||
|                 roomInfo.removeSpeaker(member) | ||||
|                 roomInfo.removeListener(member) | ||||
|                 roomInfo.removeManager(member) | ||||
|                 roomInfoRepository.save(roomInfo) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,6 @@ | ||||
| package kr.co.vividnext.sodalive.live.room | ||||
|  | ||||
| data class SetManagerOrSpeakerOrAudienceRequest( | ||||
|     val roomId: Long, | ||||
|     val accountId: Long | ||||
| ) | ||||
| @@ -0,0 +1,6 @@ | ||||
| package kr.co.vividnext.sodalive.live.room.donation | ||||
|  | ||||
| data class DeleteLiveRoomDonationMessage( | ||||
|     val roomId: Long, | ||||
|     val messageUUID: String | ||||
| ) | ||||
| @@ -0,0 +1,16 @@ | ||||
| package kr.co.vividnext.sodalive.live.room.donation | ||||
|  | ||||
| import com.querydsl.core.annotations.QueryProjection | ||||
|  | ||||
| data class GetLiveRoomDonationStatusResponse( | ||||
|     val donationList: List<GetLiveRoomDonationItem>, | ||||
|     val totalCount: Int, | ||||
|     val totalCan: Int | ||||
| ) | ||||
|  | ||||
| data class GetLiveRoomDonationItem @QueryProjection constructor( | ||||
|     val profileImage: String, | ||||
|     val nickname: String, | ||||
|     val userId: Long, | ||||
|     val can: Int | ||||
| ) | ||||
| @@ -0,0 +1,3 @@ | ||||
| package kr.co.vividnext.sodalive.live.room.donation | ||||
|  | ||||
| data class GetLiveRoomDonationTotalResponse(val totalDonationCoin: Int) | ||||
| @@ -0,0 +1,8 @@ | ||||
| package kr.co.vividnext.sodalive.live.room.donation | ||||
|  | ||||
| data class LiveRoomDonationRequest( | ||||
|     val roomId: Long, | ||||
|     val can: Int, | ||||
|     val container: String, | ||||
|     val message: String = "" | ||||
| ) | ||||
| @@ -0,0 +1,25 @@ | ||||
| package kr.co.vividnext.sodalive.live.room.info | ||||
|  | ||||
| data class GetRoomInfoResponse( | ||||
|     val roomId: Long, | ||||
|     val title: String, | ||||
|     val notice: String, | ||||
|     val coverImageUrl: String, | ||||
|     val channelName: String, | ||||
|     val rtcToken: String, | ||||
|     val rtmToken: String, | ||||
|     val managerId: Long, | ||||
|     val managerNickname: String, | ||||
|     val managerProfileUrl: String, | ||||
|     val isFollowingManager: Boolean, | ||||
|     val participantsCount: Int, | ||||
|     val totalAvailableParticipantsCount: Int, | ||||
|     val speakerList: List<LiveRoomMember>, | ||||
|     val listenerList: List<LiveRoomMember>, | ||||
|     val managerList: List<LiveRoomMember>, | ||||
|     val donationRankingTop3UserIds: List<Long>, | ||||
|     val isRadioMode: Boolean = false, | ||||
|     val isAvailableDonation: Boolean = false, | ||||
|     val isPrivateRoom: Boolean = false, | ||||
|     val password: String? = null | ||||
| ) | ||||
| @@ -0,0 +1,32 @@ | ||||
| package kr.co.vividnext.sodalive.live.room.kickout | ||||
|  | ||||
| import org.springframework.data.annotation.Id | ||||
| import org.springframework.data.redis.core.RedisHash | ||||
|  | ||||
| @RedisHash("live_room_kick_out") | ||||
| data class LiveRoomKickOut( | ||||
|     @Id | ||||
|     val roomId: Long, | ||||
|     var userList: MutableList<LiveRoomKickOutUser> = mutableListOf() | ||||
| ) { | ||||
|     fun kickOut(userId: Long) { | ||||
|         var liveRoomKickOutUser = userList.find { it.userId == userId } | ||||
|         if (liveRoomKickOutUser == null) { | ||||
|             liveRoomKickOutUser = LiveRoomKickOutUser(userId) | ||||
|         } else { | ||||
|             liveRoomKickOutUser.plusCount() | ||||
|         } | ||||
|  | ||||
|         userList.removeIf { it.userId == userId } | ||||
|         userList.add(liveRoomKickOutUser) | ||||
|     } | ||||
| } | ||||
|  | ||||
| data class LiveRoomKickOutUser( | ||||
|     val userId: Long, | ||||
|     var count: Int = 1 | ||||
| ) { | ||||
|     fun plusCount() { | ||||
|         count += 1 | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,25 @@ | ||||
| package kr.co.vividnext.sodalive.live.room.kickout | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.ApiResponse | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| 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.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RestController | ||||
|  | ||||
| @RestController | ||||
| @RequestMapping("/live/room/kick-out") | ||||
| class LiveRoomKickOutController(private val service: LiveRoomKickOutService) { | ||||
|  | ||||
|     @PostMapping | ||||
|     fun liveRoomKickOut( | ||||
|         @RequestBody request: LiveRoomKickOutRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.kickOut(request = request, member = member)) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,5 @@ | ||||
| package kr.co.vividnext.sodalive.live.room.kickout | ||||
|  | ||||
| import org.springframework.data.repository.CrudRepository | ||||
|  | ||||
| interface LiveRoomKickOutRedisRepository : CrudRepository<LiveRoomKickOut, Long> | ||||
| @@ -0,0 +1,6 @@ | ||||
| package kr.co.vividnext.sodalive.live.room.kickout | ||||
|  | ||||
| data class LiveRoomKickOutRequest( | ||||
|     val roomId: Long, | ||||
|     val userId: Long | ||||
| ) | ||||
| @@ -0,0 +1,61 @@ | ||||
| package kr.co.vividnext.sodalive.live.room.kickout | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| import kr.co.vividnext.sodalive.live.room.LiveRoomRepository | ||||
| import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository | ||||
| import kr.co.vividnext.sodalive.live.room.info.LiveRoomMember | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| import kr.co.vividnext.sodalive.member.MemberRepository | ||||
| import org.springframework.data.repository.findByIdOrNull | ||||
| import org.springframework.stereotype.Service | ||||
|  | ||||
| @Service | ||||
| class LiveRoomKickOutService( | ||||
|     private val roomInfoRepository: LiveRoomInfoRedisRepository, | ||||
|     private val repository: LiveRoomKickOutRedisRepository, | ||||
|     private val accountRepository: MemberRepository, | ||||
|     private val roomRepository: LiveRoomRepository | ||||
| ) { | ||||
|     fun kickOut(request: LiveRoomKickOutRequest, member: Member) { | ||||
|         val room = roomRepository.findByIdOrNull(request.roomId) | ||||
|             ?: throw SodaException("해당하는 라이브가 없습니다.") | ||||
|  | ||||
|         val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) | ||||
|             ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") | ||||
|  | ||||
|         if (room.member == null || room.member!!.id == null) { | ||||
|             throw SodaException("해당하는 라이브가 없습니다.") | ||||
|         } | ||||
|  | ||||
|         if (!roomInfo.managerList.contains(LiveRoomMember(member)) && room.member!!.id != member.id) { | ||||
|             throw SodaException("권한이 없습니다.") | ||||
|         } | ||||
|  | ||||
|         var liveRoomKickOut = repository.findByIdOrNull(request.roomId) | ||||
|         if (liveRoomKickOut == null) { | ||||
|             liveRoomKickOut = repository.save(LiveRoomKickOut(roomId = request.roomId)) | ||||
|         } | ||||
|  | ||||
|         liveRoomKickOut.kickOut(request.userId) | ||||
|         repository.save(liveRoomKickOut) | ||||
|  | ||||
|         val kickOutUser = accountRepository.findByIdOrNull(request.userId) | ||||
|         if (kickOutUser != null) { | ||||
|             roomInfo.removeSpeaker(kickOutUser) | ||||
|             roomInfo.removeListener(kickOutUser) | ||||
|             roomInfo.removeManager(kickOutUser) | ||||
|             roomInfoRepository.save(roomInfo) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun getKickOutCount(roomId: Long, userId: Long): Int { | ||||
|         val liveRoomKickOut = repository.findByIdOrNull(roomId) ?: return 0 | ||||
|  | ||||
|         val findUser = liveRoomKickOut.userList.find { it.userId == userId } ?: return 0 | ||||
|         return findUser.count | ||||
|     } | ||||
|  | ||||
|     fun deleteKickOutData(roomId: Long) { | ||||
|         repository.deleteById(roomId) | ||||
|     } | ||||
| } | ||||
| @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.member | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.ApiResponse | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| import kr.co.vividnext.sodalive.member.block.MemberBlockRequest | ||||
| import kr.co.vividnext.sodalive.member.following.CreatorFollowRequest | ||||
| import kr.co.vividnext.sodalive.member.login.LoginRequest | ||||
| import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest | ||||
| import org.springframework.security.core.annotation.AuthenticationPrincipal | ||||
| @@ -72,4 +74,44 @@ class MemberController(private val service: MemberService) { | ||||
|  | ||||
|         ApiResponse.ok(service.getMyPage(member, container)) | ||||
|     } | ||||
|  | ||||
|     @PostMapping("/creator/follow") | ||||
|     fun creatorFollow( | ||||
|         @RequestBody request: CreatorFollowRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.creatorFollow(creatorId = request.creatorId, memberId = member.id!!)) | ||||
|     } | ||||
|  | ||||
|     @PostMapping("/creator/unfollow") | ||||
|     fun creatorUnFollow( | ||||
|         @RequestBody request: CreatorFollowRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.creatorUnFollow(creatorId = request.creatorId, memberId = member.id!!)) | ||||
|     } | ||||
|  | ||||
|     @PostMapping("/block") | ||||
|     fun memberBlock( | ||||
|         @RequestBody request: MemberBlockRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.memberBlock(request = request, memberId = member.id!!)) | ||||
|     } | ||||
|  | ||||
|     @PostMapping("/unblock") | ||||
|     fun memberUnBlock( | ||||
|         @RequestBody request: MemberBlockRequest, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|  | ||||
|         ApiResponse.ok(service.memberUnBlock(request = request, memberId = member.id!!)) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -6,6 +6,11 @@ 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.jwt.TokenProvider | ||||
| 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 | ||||
| @@ -39,6 +44,8 @@ class MemberService( | ||||
|     private val repository: MemberRepository, | ||||
|     private val stipulationRepository: StipulationRepository, | ||||
|     private val stipulationAgreeRepository: StipulationAgreeRepository, | ||||
|     private val creatorFollowingRepository: CreatorFollowingRepository, | ||||
|     private val blockMemberRepository: BlockMemberRepository, | ||||
|  | ||||
|     private val memberNotificationService: MemberNotificationService, | ||||
|  | ||||
| @@ -264,4 +271,61 @@ class MemberService( | ||||
|  | ||||
|         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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,12 @@ | ||||
| package kr.co.vividnext.sodalive.member.block | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Entity | ||||
|  | ||||
| @Entity | ||||
| data class BlockMember( | ||||
|     val blockedMemberId: Long, | ||||
|     val memberId: Long | ||||
| ) : BaseEntity() { | ||||
|     var isActive: Boolean = true | ||||
| } | ||||
| @@ -0,0 +1,27 @@ | ||||
| package kr.co.vividnext.sodalive.member.block | ||||
|  | ||||
| import com.querydsl.jpa.impl.JPAQueryFactory | ||||
| import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember | ||||
| import org.springframework.data.jpa.repository.JpaRepository | ||||
| import org.springframework.stereotype.Repository | ||||
|  | ||||
| @Repository | ||||
| interface BlockMemberRepository : JpaRepository<BlockMember, Long>, BlockMemberQueryRepository | ||||
|  | ||||
| interface BlockMemberQueryRepository { | ||||
|     fun getBlockAccount(blockedMemberId: Long, memberId: Long): BlockMember? | ||||
| } | ||||
|  | ||||
| @Repository | ||||
| class BlockMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : BlockMemberQueryRepository { | ||||
|     override fun getBlockAccount(blockedMemberId: Long, memberId: Long): BlockMember? { | ||||
|         return queryFactory | ||||
|             .selectFrom(blockMember) | ||||
|             .where( | ||||
|                 blockMember.blockedMemberId.eq(blockedMemberId) | ||||
|                     .and(blockMember.memberId.eq(memberId)) | ||||
|             ) | ||||
|             .orderBy(blockMember.id.desc()) | ||||
|             .fetchFirst() | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,3 @@ | ||||
| package kr.co.vividnext.sodalive.member.block | ||||
|  | ||||
| data class MemberBlockRequest(val blockMemberId: Long) | ||||
| @@ -0,0 +1,3 @@ | ||||
| package kr.co.vividnext.sodalive.member.following | ||||
|  | ||||
| data class CreatorFollowRequest(val creatorId: Long) | ||||
| @@ -0,0 +1,23 @@ | ||||
| package kr.co.vividnext.sodalive.member.following | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| import javax.persistence.Entity | ||||
| import javax.persistence.FetchType | ||||
| import javax.persistence.JoinColumn | ||||
| import javax.persistence.ManyToOne | ||||
|  | ||||
| @Entity | ||||
| class CreatorFollowing( | ||||
|     // 유저가 알림받기 한 크리에이터 | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "creator_id", nullable = false) | ||||
|     var creator: Member, | ||||
|  | ||||
|     // 크리에이터를 알림받기 한 유저 | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "member_id", nullable = false) | ||||
|     var member: Member, | ||||
|  | ||||
|     var isActive: Boolean = true | ||||
| ) : BaseEntity() | ||||
| @@ -0,0 +1,28 @@ | ||||
| package kr.co.vividnext.sodalive.member.following | ||||
|  | ||||
| import com.querydsl.jpa.impl.JPAQueryFactory | ||||
| import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing | ||||
| import org.springframework.data.jpa.repository.JpaRepository | ||||
| import org.springframework.stereotype.Repository | ||||
|  | ||||
| @Repository | ||||
| interface CreatorFollowingRepository : JpaRepository<CreatorFollowing, Long>, CreatorFollowingQueryRepository | ||||
|  | ||||
| interface CreatorFollowingQueryRepository { | ||||
|     fun findByCreatorIdAndMemberId(creatorId: Long, memberId: Long): CreatorFollowing? | ||||
| } | ||||
|  | ||||
| @Repository | ||||
| class CreatorFollowingQueryRepositoryImpl( | ||||
|     private val queryFactory: JPAQueryFactory | ||||
| ) : CreatorFollowingQueryRepository { | ||||
|     override fun findByCreatorIdAndMemberId(creatorId: Long, memberId: Long): CreatorFollowing? { | ||||
|         return queryFactory | ||||
|             .selectFrom(creatorFollowing) | ||||
|             .where( | ||||
|                 creatorFollowing.creator.id.eq(creatorId) | ||||
|                     .and(creatorFollowing.member.id.eq(memberId)) | ||||
|             ) | ||||
|             .fetchFirst() | ||||
|     } | ||||
| } | ||||
| @@ -16,6 +16,10 @@ apple: | ||||
|     iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt | ||||
|     iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt | ||||
|  | ||||
| agora: | ||||
|     appId: ${AGORA_APP_ID} | ||||
|     appCertificate: ${AGORA_APP_CERTIFICATE} | ||||
|  | ||||
| cloud: | ||||
|     aws: | ||||
|         credentials: | ||||
|   | ||||
| @@ -9,6 +9,10 @@ apple: | ||||
|     iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt | ||||
|     iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt | ||||
|  | ||||
| agora: | ||||
|     appId: ${AGORA_APP_ID} | ||||
|     appCertificate: ${AGORA_APP_CERTIFICATE} | ||||
|  | ||||
| cloud: | ||||
|     aws: | ||||
|         credentials: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user