From 58a7f87ffd2925d5c269e08f1e7faf0f12510781 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 1 Aug 2023 04:56:47 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EB=B0=A9=20-=20?= =?UTF-8?q?=EC=95=84=EA=B3=A0=EB=9D=BC=20=EC=84=A4=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EB=B0=A9=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vividnext/sodalive/agora/AccessToken.kt | 171 ++++++++ .../co/vividnext/sodalive/agora/AgoraUtils.kt | 72 ++++ .../kr/co/vividnext/sodalive/agora/ByteBuf.kt | 111 ++++++ .../vividnext/sodalive/agora/DynamicKey5.kt | 256 ++++++++++++ .../sodalive/agora/DynamicKeyUtil.kt | 33 ++ .../co/vividnext/sodalive/agora/Packable.kt | 5 + .../co/vividnext/sodalive/agora/PackableEx.kt | 5 + .../sodalive/agora/RtcTokenBuilder.kt | 96 +++++ .../sodalive/agora/RtmTokenBuilder.kt | 31 ++ .../vividnext/sodalive/can/CanRepository.kt | 6 +- .../explorer/ExplorerQueryRepository.kt | 71 ++++ .../explorer/MemberDonationRankingResponse.kt | 8 + .../room/GetLiveRoomUserProfileResponse.kt | 18 + .../sodalive/live/room/LiveRoomController.kt | 123 ++++++ .../sodalive/live/room/LiveRoomRepository.kt | 46 +++ .../sodalive/live/room/LiveRoomService.kt | 372 +++++++++++++++++- .../SetManagerOrSpeakerOrAudienceRequest.kt | 6 + .../donation/DeleteLiveRoomDonationMessage.kt | 6 + .../GetLiveRoomDonationStatusResponse.kt | 16 + .../GetLiveRoomDonationTotalResponse.kt | 3 + .../room/donation/LiveRoomDonationRequest.kt | 8 + .../live/room/info/GetRoomInfoResponse.kt | 25 ++ .../live/room/kickout/LiveRoomKickOut.kt | 32 ++ .../room/kickout/LiveRoomKickOutController.kt | 25 ++ .../kickout/LiveRoomKickOutRedisRepository.kt | 5 + .../room/kickout/LiveRoomKickOutRequest.kt | 6 + .../room/kickout/LiveRoomKickOutService.kt | 61 +++ .../sodalive/member/MemberController.kt | 42 ++ .../sodalive/member/MemberService.kt | 64 +++ .../sodalive/member/block/BlockMember.kt | 12 + .../member/block/BlockMemberRepository.kt | 27 ++ .../member/block/MemberBlockRequest.kt | 3 + .../member/following/CreatorFollowRequest.kt | 3 + .../member/following/CreatorFollowing.kt | 23 ++ .../following/CreatorFollowingRepository.kt | 28 ++ src/main/resources/application.yml | 4 + src/test/resources/application.yml | 6 +- 37 files changed, 1823 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/agora/AccessToken.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/agora/AgoraUtils.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/agora/ByteBuf.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKey5.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKeyUtil.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/agora/Packable.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/agora/PackableEx.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/agora/RtcTokenBuilder.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/agora/RtmTokenBuilder.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/SetManagerOrSpeakerOrAudienceRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/DeleteLiveRoomDonationMessage.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationStatusResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationTotalResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOut.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRedisRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMember.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/block/MemberBlockRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/AccessToken.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/AccessToken.kt new file mode 100644 index 0000000..ebc5648 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/AccessToken.kt @@ -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 + + 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" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/AgoraUtils.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/AgoraUtils.kt new file mode 100644 index 0000000..2af6158 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/AgoraUtils.kt @@ -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()) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/ByteBuf.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/ByteBuf.kt new file mode 100644 index 0000000..9254628 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/ByteBuf.kt @@ -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): ByteBuf { + put(extra.size.toShort()) + for ((key, value) in extra.entries) { + put(key) + put(value) + } + return this + } + + fun putIntMap(extra: TreeMap): 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 { + val map = TreeMap() + val length = readShort() + + for (i in 0 until length) { + val k = readShort() + val v = readString() + map[k] = v + } + + return map + } + + fun readIntMap(): TreeMap { + val map = TreeMap() + val length = readShort() + + for (i in 0 until length) { + val k = readShort() + val v = readInt() + map[k] = v + } + + return map + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKey5.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKey5.kt new file mode 100644 index 0000000..9c35d0a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKey5.kt @@ -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 + ): 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, + 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() + 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 + ) : 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? = null + + constructor( + serviceType: Short, + signature: String?, + appID: ByteArray, + unixTs: Int, + salt: Int, + expiredTs: Int, + extra: TreeMap + ) : 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() + } + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKeyUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKeyUtil.kt new file mode 100644 index 0000000..e98118a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/DynamicKeyUtil.kt @@ -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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/Packable.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/Packable.kt new file mode 100644 index 0000000..4447e33 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/Packable.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.agora + +interface Packable { + fun marshal(out: ByteBuf): ByteBuf +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/PackableEx.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/PackableEx.kt new file mode 100644 index 0000000..e84605e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/PackableEx.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.agora + +interface PackableEx : Packable { + fun unmarshal(input: ByteBuf) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtcTokenBuilder.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtcTokenBuilder.kt new file mode 100644 index 0000000..c5b1677 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtcTokenBuilder.kt @@ -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() + "" + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtmTokenBuilder.kt b/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtmTokenBuilder.kt new file mode 100644 index 0000000..d00999f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/agora/RtmTokenBuilder.kt @@ -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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt index a404594..fd4bc7d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -27,7 +27,7 @@ interface CanQueryRepository { fun getCanUseStatus(member: Member, pageable: Pageable): List fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List 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()) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt new file mode 100644 index 0000000..19a869f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -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 { + 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 { + 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 + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt new file mode 100644 index 0000000..3e6e3f9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt new file mode 100644 index 0000000..42226ab --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLiveRoomUserProfileResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt index 9ff9e48..4f7fd58 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt @@ -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)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt index f9f0873..e2c12c4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomRepository.kt @@ -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 } 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 { + 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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 032a5a6..6d8d20a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -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 { + 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) + } + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/SetManagerOrSpeakerOrAudienceRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/SetManagerOrSpeakerOrAudienceRequest.kt new file mode 100644 index 0000000..e5afd3f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/SetManagerOrSpeakerOrAudienceRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.live.room + +data class SetManagerOrSpeakerOrAudienceRequest( + val roomId: Long, + val accountId: Long +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/DeleteLiveRoomDonationMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/DeleteLiveRoomDonationMessage.kt new file mode 100644 index 0000000..4677041 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/DeleteLiveRoomDonationMessage.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.live.room.donation + +data class DeleteLiveRoomDonationMessage( + val roomId: Long, + val messageUUID: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationStatusResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationStatusResponse.kt new file mode 100644 index 0000000..2cc8a12 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationStatusResponse.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.live.room.donation + +import com.querydsl.core.annotations.QueryProjection + +data class GetLiveRoomDonationStatusResponse( + val donationList: List, + val totalCount: Int, + val totalCan: Int +) + +data class GetLiveRoomDonationItem @QueryProjection constructor( + val profileImage: String, + val nickname: String, + val userId: Long, + val can: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationTotalResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationTotalResponse.kt new file mode 100644 index 0000000..8dc9556 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/GetLiveRoomDonationTotalResponse.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.live.room.donation + +data class GetLiveRoomDonationTotalResponse(val totalDonationCoin: Int) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationRequest.kt new file mode 100644 index 0000000..732bd84 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/donation/LiveRoomDonationRequest.kt @@ -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 = "" +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt new file mode 100644 index 0000000..0bede8c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt @@ -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, + val listenerList: List, + val managerList: List, + val donationRankingTop3UserIds: List, + val isRadioMode: Boolean = false, + val isAvailableDonation: Boolean = false, + val isPrivateRoom: Boolean = false, + val password: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOut.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOut.kt new file mode 100644 index 0000000..aa4bc54 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOut.kt @@ -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 = 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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt new file mode 100644 index 0000000..fa77973 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt @@ -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)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRedisRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRedisRepository.kt new file mode 100644 index 0000000..fb01cd8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRedisRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.live.room.kickout + +import org.springframework.data.repository.CrudRepository + +interface LiveRoomKickOutRedisRepository : CrudRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRequest.kt new file mode 100644 index 0000000..612d5d2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.live.room.kickout + +data class LiveRoomKickOutRequest( + val roomId: Long, + val userId: Long +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt new file mode 100644 index 0000000..133edd0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt @@ -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) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index 30cfcb9..89d996d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -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!!)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 1f5d324..e70aaf0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -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 + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMember.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMember.kt new file mode 100644 index 0000000..57811cd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMember.kt @@ -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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt new file mode 100644 index 0000000..c9a42d9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt @@ -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, 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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/MemberBlockRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/MemberBlockRequest.kt new file mode 100644 index 0000000..1fddf16 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/MemberBlockRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.member.block + +data class MemberBlockRequest(val blockMemberId: Long) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt new file mode 100644 index 0000000..453004b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.member.following + +data class CreatorFollowRequest(val creatorId: Long) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt new file mode 100644 index 0000000..c0a3044 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt @@ -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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt new file mode 100644 index 0000000..d74b551 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt @@ -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, 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() + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 75ee46f..4dfbeac 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 4cbecc3..3fc8d6c 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -8,7 +8,11 @@ logging: 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: