diff --git a/.github/workflows/android-cd.yml b/.github/workflows/android-cd.yml index ef139b4..8a5f0d4 100644 --- a/.github/workflows/android-cd.yml +++ b/.github/workflows/android-cd.yml @@ -41,7 +41,7 @@ jobs: uses: actions/upload-artifact@v3 with: name: release-artifacts - path: ./android/app/build/outputs/apk/release/ + path: android/app/build/outputs/apk/release/ if-no-files-found: error - name: Create Github Release @@ -49,4 +49,12 @@ jobs: with: generate_release_notes: true files: | - ./android/app/build/outputs/apk/release/*.apk + android/app/build/outputs/apk/release/app-release.apk + + - name: Upload artifact to Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@v1 + with: + appId: ${{secrets.FIREBASE_APP_ID}} + serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }} + groups: testers + file: android/app/build/outputs/apk/release/app-release.apk diff --git a/.github/workflows/android-pull-request-ci.yml b/.github/workflows/android-pull-request-ci.yml index fecc9fb..6cc7755 100644 --- a/.github/workflows/android-pull-request-ci.yml +++ b/.github/workflows/android-pull-request-ci.yml @@ -36,10 +36,10 @@ jobs: KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }} LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} run: | - echo "$DEBUG_KEYSTORE" | base64 -d > keystore.properties + echo "$DEBUG_KEYSTORE" | base64 -d > debug.keystore echo "$KEYSTORE_PROPERTIES" > keystore.properties echo "$LOCAL_PROPERTIES" > local.properties - ./gradlew testDebugUnitTest --stacktrace + ./gradlew debugUnitTest --stacktrace - name: Publish Test Results if: always() diff --git a/.github/workflows/server-cd.yml b/.github/workflows/server-cd.yml new file mode 100644 index 0000000..ccfbc59 --- /dev/null +++ b/.github/workflows/server-cd.yml @@ -0,0 +1,79 @@ +name: Server CD + +on: + pull_request: + branches: [ "develop", "main" ] + paths: + - "server/**" + types: + - closed + +jobs: + deploy: + runs-on: ubuntu-20.04 + + strategy: + matrix: + node-version: [20.x] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: '**/package-lock.json' + + - name: Install depenencies + run: | + cd server + npm install + + - name: Create prod.env file + env: + DB_HOST_IP: ${{ secrets.SERVER_ENV_DB_HOST_IP }} + DB_PORT: ${{ secrets.SERVER_ENV_DB_PORT }} + DB_USER_NAME: ${{ secrets.SERVER_ENV_DB_USER_NAME }} + DB_PASSWORD: ${{ secrets.SERVER_ENV_DB_PASSWORD }} + DB_DATABASE_NAME: ${{ secrets.SERVER_ENV_DB_DATABASE_NAME }} + ACCESS_ID: ${{ secrets.SERVER_ENV_ACCESS_ID }} + SECRET_ACCESS_KEY: ${{ secrets.SERVER_ENV_SECRET_ACCESS_KEY }} + JWT_SECRET_KEY: ${{ secrets.SERVER_ENV_JWT_SECRET_KEY }} + run: | + cd server + touch prod.env + echo "DB_HOST_IP=$DB_HOST_IP" >> prod.env + echo "DB_PORT=$DB_PORT" >> prod.env + echo "DB_USER_NAME=$DB_USER_NAME" >> prod.env + echo "DB_PASSWORD=$DB_PASSWORD" >> prod.env + echo "DB_DATABASE_NAME=$DB_DATABASE_NAME" >> prod.env + echo "ACCESS_ID=$ACCESS_ID" >> prod.env + echo "SECRET_ACCESS_KEY=$SECRET_ACCESS_KEY" >> prod.env + echo "JWT_SECRET_KEY=$JWT_SECRET_KEY" >> prod.env + + - name: Build Docker image + run: docker build --platform linux/amd64 ./server -t ${{ secrets.NCP_REGISTRY }}/catchy-tape:latest + + - name: Login NCP container registry + run: docker login ${{ secrets.NCP_REGISTRY }} -u ${{ secrets.NCP_DOCKER_ACCESS_KEY_ID }} -p ${{ secrets.NCP_DOCKER_SECRET_KEY }} + + - name: Push Docker image to registry + run: docker push ${{ secrets.NCP_REGISTRY }}/catchy-tape:latest + + - name: SSH into Server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_SSH_HOST }} + username: ${{ secrets.SERVER_SSH_USER }} + password: ${{ secrets.SERVER_SSH_PASSWORD }} + port: ${{ secrets.SERVER_SSH_PORT }} + script: | + docker login ${{ secrets.NCP_REGISTRY }} -u ${{ secrets.NCP_DOCKER_ACCESS_KEY_ID }} -p ${{ secrets.NCP_DOCKER_SECRET_KEY }} + docker pull ${{ secrets.NCP_REGISTRY }}/catchy-tape:latest + docker stop catchy-tape-latest + docker rm catchy-tape-latest + docker run -d -p 3000:3000 --name catchy-tape-latest ${{ secrets.NCP_REGISTRY }}/catchy-tape:latest + curl -X POST -H 'Content-type: application/json' --data '{"text":"서버 배포 성공!"}' ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/server-dev-ci.yml b/.github/workflows/server-dev-ci.yml new file mode 100644 index 0000000..2a02fb5 --- /dev/null +++ b/.github/workflows/server-dev-ci.yml @@ -0,0 +1,38 @@ +name: Backend Dev CI + +on: + pull_request: + branches: [develop] + paths: + - "server/**" + +defaults: + run: + working-directory: ./server + +jobs: + BACKEND-CI: + runs-on: ubuntu-20.04 + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Use NodeJS + uses: actions/setup-node@v2 + with: + node-version: 20.8.1 + + - name: Cache node modules + id: cache + uses: actions/cache@v2 + with: + path: "**/node_modules" + key: npm-packages-${{ hashFiles('**/package-lock.json') }} + + - name: Install Dependency + if: steps.cache.outputs.cache-hit != 'true' + run: npm install + + - name: Execute Test + run: npm run test diff --git a/README.md b/README.md index 33f39df..d7c3755 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,15 @@ ### 🤖 Android | Category | TechStack | 기록 | | ------------- | ------------- | ------------- | -| Architecture | Clean Architecture, Multi Module, MVVM | [프로젝트 구조](https://tral-lalala.tistory.com/126) +| Architecture | Clean Architecture, Multi Module, MVVM | [프로젝트 구조](https://tral-lalala.tistory.com/126)⎮[build-logic](https://algosketch.tistory.com/179) | DI | Hilt | | Network | Retrofit, Kotlin Serialization | [역/직렬화 라이브러리 비교](https://github.com/boostcampwm2023/and04-catchy-tape/wiki/%EC%97%AD-%EC%A7%81%EB%A0%AC%ED%99%94-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%B9%84%EA%B5%90) -| Jetpack | Navigation | -| CI | Github Actions | [PR에 대한 단위 테스트 자동화](https://algosketch.tistory.com/178) -
+| Asynchronous | Coroutines, Flow +| Jetpack | DataBinding, Navigation | +| CI/CD | Github Actions |[PR 단위 테스트 자동화](https://algosketch.tistory.com/178)⎮[Github Release 자동화](https://tral-lalala.tistory.com/127)⎮[Firebase App 배포 자동화](https://tral-lalala.tistory.com/128) +| Test | Kotest + +
그 외 기록 - [프로젝트 생성](https://github.com/boostcampwm2023/and04-catchy-tape/wiki/Android#%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1%EC%8B%9C-%EA%B3%A0%EB%A0%A4%ED%95%9C-%EB%82%B4%EC%9A%A9) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 87a95cd..b00cdc4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -21,7 +21,7 @@ android { minSdk = 26 targetSdk = 33 versionCode = 1 - versionName = "1.0.0" + versionName = "0.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -63,6 +63,7 @@ dependencies { implementation(project(":feature:home")) implementation(project(":feature:login")) implementation(project(":feature:upload")) + implementation(project(":feature:player")) implementation(project(":core:data")) implementation(libs.core.ktx) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7e1812a..11d1934 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + @@ -20,6 +21,14 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/ohdodok/catchytape/CtApplication.kt b/android/app/src/main/java/com/ohdodok/catchytape/CtApplication.kt index d43fb73..522fef1 100644 --- a/android/app/src/main/java/com/ohdodok/catchytape/CtApplication.kt +++ b/android/app/src/main/java/com/ohdodok/catchytape/CtApplication.kt @@ -10,6 +10,14 @@ class CtApplication : Application() { super.onCreate() if (BuildConfig.DEBUG) { Timber.plant(object : Timber.DebugTree() { + override fun d(message: String?, vararg args: Any?) { + if (message != null && message.isEmpty()) { + super.d(getString(R.string.timber_blank_string), *args) + } else { + super.d(message, *args) + } + } + override fun createStackElementTag(element: StackTraceElement): String { return String.format( getString(R.string.timber_log_format), diff --git a/android/app/src/main/java/com/ohdodok/catchytape/MainActivity.kt b/android/app/src/main/java/com/ohdodok/catchytape/MainActivity.kt index 8e7eb22..f7bb5a7 100644 --- a/android/app/src/main/java/com/ohdodok/catchytape/MainActivity.kt +++ b/android/app/src/main/java/com/ohdodok/catchytape/MainActivity.kt @@ -1,19 +1,35 @@ package com.ohdodok.catchytape -import android.content.Intent -import androidx.appcompat.app.AppCompatActivity +import android.net.ConnectivityManager +import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED import android.os.Bundle -import com.ohdodok.catchytape.feature.login.LoginActivity +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity import dagger.hilt.android.AndroidEntryPoint +import com.ohdodok.catchytape.core.ui.R.string as uiString @AndroidEntryPoint class MainActivity : AppCompatActivity() { + private lateinit var connectivityManager: ConnectivityManager + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - // TODO : 자동로그인 할 때 수정 필요 - val intent = Intent(this, LoginActivity::class.java) - startActivity(intent) + connectivityManager = getSystemService(ConnectivityManager::class.java) + checkNetworkState() + + val networkStateObserver = NetworkStateObserver(connectivityManager, ::checkNetworkState) + lifecycle.addObserver(networkStateObserver) + } + + private fun checkNetworkState() { + val activeNetwork = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + val isNetworkAvailable = capabilities?.hasCapability(NET_CAPABILITY_VALIDATED) ?: false + + if (!isNetworkAvailable) { + Toast.makeText(this, getString(uiString.check_network), Toast.LENGTH_LONG).show() + } } } \ No newline at end of file diff --git a/android/app/src/main/java/com/ohdodok/catchytape/NetworkStateObserver.kt b/android/app/src/main/java/com/ohdodok/catchytape/NetworkStateObserver.kt new file mode 100644 index 0000000..f3bd7af --- /dev/null +++ b/android/app/src/main/java/com/ohdodok/catchytape/NetworkStateObserver.kt @@ -0,0 +1,29 @@ +package com.ohdodok.catchytape + +import android.net.ConnectivityManager +import android.net.Network +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +class NetworkStateObserver( + private val connectivityManager: ConnectivityManager, + checkNetworkState: () -> Unit +) : DefaultLifecycleObserver { + + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onLost(network: Network) { + super.onLost(network) + checkNetworkState() + } + } + + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + connectivityManager.registerDefaultNetworkCallback(networkCallback) + } + + override fun onPause(owner: LifecycleOwner) { + super.onPause(owner) + connectivityManager.unregisterNetworkCallback(networkCallback) + } +} \ No newline at end of file diff --git a/android/app/src/main/res/navigation/catchytape_navigation.xml b/android/app/src/main/res/navigation/catchytape_navigation.xml index dc9a4e9..a4cda82 100644 --- a/android/app/src/main/res/navigation/catchytape_navigation.xml +++ b/android/app/src/main/res/navigation/catchytape_navigation.xml @@ -7,5 +7,6 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b9ef990..5bcb116 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Catchy Tape C: %s, L: %s + "BLANK("")" \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 09b3107..91b40a9 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -9,4 +9,17 @@ plugins { alias(libs.plugins.kotlinx.serialization) apply false alias(libs.plugins.navigation.safe.args) apply false } + + +tasks.register("domainUnitTest") { + commandLine = listOf("gradle", "core:domain:test") +} + + +tasks.register("debugUnitTest") { + dependsOn("domainUnitTest") + commandLine = listOf("gradle", "testDebugUnitTest") +} + + true // Needed to make the Suppress annotation work for the plugins block \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt new file mode 100644 index 0000000..58a77c1 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/MusicApi.kt @@ -0,0 +1,24 @@ +package com.ohdodok.catchytape.core.data.api + +import com.ohdodok.catchytape.core.data.model.MusicGenresResponse +import com.ohdodok.catchytape.core.data.model.MusicRequest +import com.ohdodok.catchytape.core.data.model.MusicResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface MusicApi { + + @GET("musics/genres") + suspend fun getGenres(): Response + + @POST("musics") + suspend fun postMusic( + @Body music: MusicRequest + ): Response + + @GET("musics/recent-uploads") + suspend fun getRecentUploads(): Response> + +} \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UploadApi.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UploadApi.kt new file mode 100644 index 0000000..88a47f4 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UploadApi.kt @@ -0,0 +1,38 @@ +package com.ohdodok.catchytape.core.data.api + +import com.ohdodok.catchytape.core.data.model.PreSignedUrlResponse +import com.ohdodok.catchytape.core.data.model.UrlResponse +import com.ohdodok.catchytape.core.data.model.UuidResponse +import okhttp3.MultipartBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Query + +interface UploadApi { + + @GET("upload/uuid") + suspend fun getUuid( + ): Response + + @GET("upload") + suspend fun getPreSignedUrl( + @Query("uuid") uuid: String, + @Query("type") type: String, + ): Response + + @Multipart + @POST("upload/music") + suspend fun postMusic( + @Part part: MultipartBody.Part, + ): Response + + @Multipart + @POST("upload/image") + suspend fun postImage( + @Part part: MultipartBody.Part, + ): Response + +} diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UserApi.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UserApi.kt index b80452d..659df72 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UserApi.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/api/UserApi.kt @@ -2,10 +2,14 @@ package com.ohdodok.catchytape.core.data.api import com.ohdodok.catchytape.core.data.model.LoginRequest import com.ohdodok.catchytape.core.data.model.LoginResponse +import com.ohdodok.catchytape.core.data.model.NicknameResponse import com.ohdodok.catchytape.core.data.model.SignUpRequest import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.POST +import retrofit2.http.Path interface UserApi { @@ -19,4 +23,13 @@ interface UserApi { @Body signUpRequest: SignUpRequest ): Response + @GET("users/duplicate/{nickname}") + suspend fun verifyDuplicatedNickname( + @Path("nickname") nickname: String, + ): Response + + @GET("users/verify") + suspend fun verify( + @Header("Authorization") accessToken: String, + ): Response } \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/datasource/TokenLocalDataSource.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/datasource/TokenLocalDataSource.kt new file mode 100644 index 0000000..2bc0e49 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/datasource/TokenLocalDataSource.kt @@ -0,0 +1,23 @@ +package com.ohdodok.catchytape.core.data.datasource + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class TokenLocalDataSource @Inject constructor( + private val dataStore: DataStore +) { + + private val accessTokenKey = stringPreferencesKey("accessToken") + + suspend fun getAccessToken(): String = + dataStore.data.map { preferences -> preferences[accessTokenKey] ?: "" }.first() + + suspend fun saveAccessToken(token: String) { + dataStore.edit { preferences -> preferences[accessTokenKey] = token } + } +} \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/ApiModule.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/ApiModule.kt index 9d94f86..7ad8229 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/ApiModule.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/ApiModule.kt @@ -1,5 +1,7 @@ package com.ohdodok.catchytape.core.data.di +import com.ohdodok.catchytape.core.data.api.MusicApi +import com.ohdodok.catchytape.core.data.api.UploadApi import com.ohdodok.catchytape.core.data.api.UserApi import dagger.Module import dagger.Provides @@ -17,4 +19,16 @@ object ApiModule { fun provideSignupApi(retrofit: Retrofit): UserApi { return retrofit.create(UserApi::class.java) } + + @Provides + @Singleton + fun provideMusicApi(retrofit: Retrofit): MusicApi { + return retrofit.create(MusicApi::class.java) + } + + @Provides + @Singleton + fun provideUploadApi(retrofit: Retrofit): UploadApi { + return retrofit.create(UploadApi::class.java) + } } \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/NetworkModule.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/NetworkModule.kt index 3f759e7..d59c83d 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/NetworkModule.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/NetworkModule.kt @@ -2,11 +2,15 @@ package com.ohdodok.catchytape.core.data.di import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.ohdodok.catchytape.core.data.BuildConfig +import com.ohdodok.catchytape.core.data.datasource.TokenLocalDataSource +import com.ohdodok.catchytape.core.data.di.qualifier.AuthInterceptor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json +import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -14,28 +18,51 @@ import retrofit2.Retrofit import timber.log.Timber import javax.inject.Singleton - @Module @InstallIn(SingletonComponent::class) object NetworkModule { + @AuthInterceptor + @Singleton + @Provides + fun provideAuthInterceptor(tokenDataSource: TokenLocalDataSource): Interceptor { + + return Interceptor { chain -> + val accessToken = runBlocking { tokenDataSource.getAccessToken() } + val newRequest = chain.request().newBuilder() + .addHeader("Authorization", "Bearer $accessToken") + .build() + + chain.proceed(newRequest) + } + } + @Singleton @Provides - fun provideOkHttpClient(): OkHttpClient { + fun provideLoggingInterceptor(): HttpLoggingInterceptor { val logger = HttpLoggingInterceptor.Logger { message -> Timber.tag("okHttp").d(message) } - val httpInterceptor = HttpLoggingInterceptor(logger) + return HttpLoggingInterceptor(logger) .setLevel(HttpLoggingInterceptor.Level.BODY) + } + @Singleton + @Provides + fun provideOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor, + @AuthInterceptor authInterceptor: Interceptor, + ): OkHttpClient { return OkHttpClient.Builder() - .addInterceptor(httpInterceptor) + .addInterceptor(authInterceptor) + .addInterceptor(loggingInterceptor) .build() } @Singleton @Provides fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + val jsonConfig = Json { ignoreUnknownKeys = true } return Retrofit.Builder() - .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) + .addConverterFactory(jsonConfig.asConverterFactory("application/json".toMediaType())) .client(okHttpClient) .baseUrl(BuildConfig.BASE_URL) .build() diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/RepositoryModule.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/RepositoryModule.kt index 8aff86e..b58ac30 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/RepositoryModule.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/RepositoryModule.kt @@ -1,7 +1,11 @@ package com.ohdodok.catchytape.core.data.di import com.ohdodok.catchytape.core.data.repository.AuthRepositoryImpl +import com.ohdodok.catchytape.core.data.repository.MusicRepositoryImpl +import com.ohdodok.catchytape.core.data.repository.UrlRepositoryImpl import com.ohdodok.catchytape.core.domain.repository.AuthRepository +import com.ohdodok.catchytape.core.domain.repository.MusicRepository +import com.ohdodok.catchytape.core.domain.repository.UrlRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -13,4 +17,10 @@ interface RepositoryModule { @Binds fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository + + @Binds + fun bindMusicRepository(musicRepositoryImpl: MusicRepositoryImpl): MusicRepository + + @Binds + fun bindUrlRepository(urlRepositoryImpl: UrlRepositoryImpl): UrlRepository } \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/qualifier/AuthInterceptor.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/qualifier/AuthInterceptor.kt new file mode 100644 index 0000000..044f6d7 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/di/qualifier/AuthInterceptor.kt @@ -0,0 +1,6 @@ +package com.ohdodok.catchytape.core.data.di.qualifier + +import javax.inject.Qualifier + +@Qualifier +annotation class AuthInterceptor \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicGenresResponse.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicGenresResponse.kt new file mode 100644 index 0000000..2d57e52 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicGenresResponse.kt @@ -0,0 +1,8 @@ +package com.ohdodok.catchytape.core.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicGenresResponse( + val genres: List +) \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicRequest.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicRequest.kt new file mode 100644 index 0000000..2dab5f8 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicRequest.kt @@ -0,0 +1,11 @@ +package com.ohdodok.catchytape.core.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicRequest ( + val title: String, + val cover: String, + val file: String, + val genre: String +) \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicResponse.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicResponse.kt new file mode 100644 index 0000000..f0595fc --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/MusicResponse.kt @@ -0,0 +1,13 @@ +package com.ohdodok.catchytape.core.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicResponse ( + val musicId: Int, + val title: String, + val cover: String, + val musicFile : String, + val genre: String, + val user: NicknameResponse +) \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/NicknameResponse.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/NicknameResponse.kt new file mode 100644 index 0000000..6218f16 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/NicknameResponse.kt @@ -0,0 +1,8 @@ +package com.ohdodok.catchytape.core.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class NicknameResponse( + val nickname: String, +) \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UrlResponse.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UrlResponse.kt new file mode 100644 index 0000000..a816196 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UrlResponse.kt @@ -0,0 +1,13 @@ +package com.ohdodok.catchytape.core.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class PreSignedUrlResponse( + val signedUrl: String +) + +@Serializable +data class UrlResponse( + val url: String +) \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UuidResponse.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UuidResponse.kt new file mode 100644 index 0000000..e760d56 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/model/UuidResponse.kt @@ -0,0 +1,8 @@ +package com.ohdodok.catchytape.core.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UuidResponse ( + val uuid: String +) \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/AuthRepositoryImpl.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/AuthRepositoryImpl.kt index 06fae39..6885e01 100644 --- a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/AuthRepositoryImpl.kt +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/AuthRepositoryImpl.kt @@ -1,10 +1,7 @@ package com.ohdodok.catchytape.core.data.repository -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey import com.ohdodok.catchytape.core.data.api.UserApi +import com.ohdodok.catchytape.core.data.datasource.TokenLocalDataSource import com.ohdodok.catchytape.core.data.model.LoginRequest import com.ohdodok.catchytape.core.data.model.SignUpRequest import com.ohdodok.catchytape.core.domain.repository.AuthRepository @@ -14,11 +11,9 @@ import javax.inject.Inject class AuthRepositoryImpl @Inject constructor( private val userApi: UserApi, - private val preferenceDataStore: DataStore + private val tokenDataSource: TokenLocalDataSource, ) : AuthRepository { - private val tokenKey = stringPreferencesKey("token") - override fun loginWithGoogle(googleToken: String): Flow = flow { val response = userApi.login(LoginRequest(idToken = googleToken)) if (response.isSuccessful) { @@ -43,9 +38,25 @@ class AuthRepositoryImpl @Inject constructor( } } + override suspend fun saveAccessToken(token: String) { + tokenDataSource.saveAccessToken(token) + } + + override fun isDuplicatedNickname(nickname: String): Flow = flow { + val response = userApi.verifyDuplicatedNickname(nickname = nickname) - override suspend fun saveToken(token: String) { - preferenceDataStore.edit { preferences -> preferences[tokenKey] = token } + when (response.code()) { + in 200..299 -> emit(false) + 409 -> emit(true) + else -> throw RuntimeException("네트워크 에러") // fixme : 예외 처리 로직이 정해지면 수정 + } } + override suspend fun tryLoginAutomatically(): Boolean { + val accessToken = tokenDataSource.getAccessToken() + + if (accessToken.isBlank()) return false + + return userApi.verify("Bearer $accessToken").isSuccessful + } } \ No newline at end of file diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/MusicRepositoryImpl.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/MusicRepositoryImpl.kt new file mode 100644 index 0000000..798b85f --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/MusicRepositoryImpl.kt @@ -0,0 +1,64 @@ +package com.ohdodok.catchytape.core.data.repository + +import com.ohdodok.catchytape.core.data.api.MusicApi +import com.ohdodok.catchytape.core.data.model.MusicResponse +import com.ohdodok.catchytape.core.domain.model.Music +import com.ohdodok.catchytape.core.data.model.MusicRequest +import com.ohdodok.catchytape.core.domain.repository.MusicRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class MusicRepositoryImpl @Inject constructor( + private val musicApi: MusicApi +) : MusicRepository { + + override fun getGenres(): Flow> = flow { + val response = musicApi.getGenres() + when (response.code()) { + // TODO : 네트워크 에러 로직 처리 + in 200..299 -> emit(response.body()?.genres ?: emptyList()) + else -> throw RuntimeException("네트워크 에러") + } + } + + override fun getRecentUploadedMusic(): Flow> = flow { + val response = musicApi.getRecentUploads() + when (response.code()) { + // TODO : 네트워크 에러 로직 처리 + in 200..299 -> emit(response.body()?.map { it.toDomain() } ?: emptyList()) + else -> throw RuntimeException("네트워크 에러") + } + } + + override fun postMusic( + title: String, + imageUrl: String, + audioUrl: String, + genre: String + ): Flow = flow { + val response = musicApi.postMusic( + MusicRequest( + title = title, + cover = imageUrl, + file = audioUrl, + genre = genre + ) + ) + when (response.code()) { + // TODO : 네트워크 에러 로직 처리 + in 200..299 -> emit(response.body() ?: Unit) + else -> throw RuntimeException("네트워크 에러") + } + } +} + +fun MusicResponse.toDomain(): Music { + return Music( + id = musicId, + title = title, + artist = user.nickname, + imageUrl = cover + ) +} + diff --git a/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/UrlRepositoryImpl.kt b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/UrlRepositoryImpl.kt new file mode 100644 index 0000000..3650d38 --- /dev/null +++ b/android/core/data/src/main/java/com/ohdodok/catchytape/core/data/repository/UrlRepositoryImpl.kt @@ -0,0 +1,51 @@ +package com.ohdodok.catchytape.core.data.repository + +import com.ohdodok.catchytape.core.data.api.UploadApi +import com.ohdodok.catchytape.core.domain.repository.UrlRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import javax.inject.Inject + +class UrlRepositoryImpl @Inject constructor( + private val uploadApi: UploadApi +) : UrlRepository { + + override fun getUuid(): Flow = flow { + val response = uploadApi.getUuid() + if (response.isSuccessful) { + response.body()?.let { uuidResponse -> emit(uuidResponse.uuid) } + } else { + // TODO : 네트워크 에러 로직 + throw Exception("uuid 생성 실패") + } + } + + override fun getImageUrl(file: File): Flow = flow { + val response = uploadApi.postImage(file.toMultipart("image/png")) + if (response.isSuccessful) { + response.body()?.let { urlResponse -> emit(urlResponse.url) } + } else { + // TODO : 네트워크 에러 로직 + throw Exception("이미지 업로드 실패") + } + } + + override fun getAudioUrl(file: File): Flow = flow { + val response = uploadApi.postMusic(file.toMultipart("audio/mpeg")) + if (response.isSuccessful) { + response.body()?.let { urlResponse -> emit(urlResponse.url) } + } else { + // TODO : 네트워크 에러 로직 + throw Exception("음악 업로드 실패") + } + } + + private fun File.toMultipart(contentType: String): MultipartBody.Part { + val fileBody = this.asRequestBody(contentType.toMediaTypeOrNull()) + return MultipartBody.Part.createFormData("file", this.name, fileBody) + } +} \ No newline at end of file diff --git a/android/core/domain/build.gradle.kts b/android/core/domain/build.gradle.kts index d57e39b..084c287 100644 --- a/android/core/domain/build.gradle.kts +++ b/android/core/domain/build.gradle.kts @@ -3,7 +3,27 @@ plugins { alias(libs.plugins.org.jetbrains.kotlin.jvm) } +tasks.withType().configureEach { + useJUnitPlatform() +} + +tasks.getByName("test") { + useJUnitPlatform() + reports { + junitXml.required.set(false) + } + systemProperty("gradle.build.dir", project.buildDir) +} + dependencies { api(libs.coroutines) + implementation(libs.inject) + + // fixme : kotest 사용이 확정되면 junit 지우기 + testImplementation(libs.junit) + testImplementation(libs.kotest.runner) + testImplementation(libs.kotest.property) + testImplementation(libs.kotest.extentions.junitxml) + testImplementation(libs.mockk) } diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/Music.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/Music.kt index d157001..442e3c8 100644 --- a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/Music.kt +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/model/Music.kt @@ -1,7 +1,7 @@ package com.ohdodok.catchytape.core.domain.model data class Music( - val id: String, + val id: Int, val title: String, val artist: String, val imageUrl: String diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/AuthRepository.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/AuthRepository.kt index 7fe17e7..24a75d4 100644 --- a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/AuthRepository.kt +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/AuthRepository.kt @@ -8,6 +8,10 @@ interface AuthRepository { fun signUpWithGoogle(googleToken: String, nickname: String): Flow - suspend fun saveToken(token: String) + suspend fun saveAccessToken(token: String) + + suspend fun tryLoginAutomatically(): Boolean + + fun isDuplicatedNickname(nickname: String): Flow } \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/MusicRepository.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/MusicRepository.kt new file mode 100644 index 0000000..f38a755 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/MusicRepository.kt @@ -0,0 +1,14 @@ +package com.ohdodok.catchytape.core.domain.repository + +import com.ohdodok.catchytape.core.domain.model.Music +import kotlinx.coroutines.flow.Flow + +interface MusicRepository { + + fun getGenres(): Flow> + + fun getRecentUploadedMusic(): Flow> + + fun postMusic(title: String, imageUrl: String, audioUrl: String, genre: String): Flow + +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/UrlRepository.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/UrlRepository.kt new file mode 100644 index 0000000..82f2f07 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/repository/UrlRepository.kt @@ -0,0 +1,14 @@ +package com.ohdodok.catchytape.core.domain.repository + +import kotlinx.coroutines.flow.Flow +import java.io.File + +interface UrlRepository { + + fun getUuid(): Flow + + fun getImageUrl(file: File): Flow + + fun getAudioUrl(file: File): Flow + +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/AutomaticallyLoginUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/AutomaticallyLoginUseCase.kt new file mode 100644 index 0000000..27ec79c --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/AutomaticallyLoginUseCase.kt @@ -0,0 +1,10 @@ +package com.ohdodok.catchytape.core.domain.usecase + +import com.ohdodok.catchytape.core.domain.repository.AuthRepository +import javax.inject.Inject + +class AutomaticallyLoginUseCase @Inject constructor( + private val authRepository: AuthRepository +) { + suspend operator fun invoke(): Boolean = authRepository.tryLoginAutomatically() +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/GetMusicGenresUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/GetMusicGenresUseCase.kt new file mode 100644 index 0000000..4f93f64 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/GetMusicGenresUseCase.kt @@ -0,0 +1,12 @@ +package com.ohdodok.catchytape.core.domain.usecase + +import com.ohdodok.catchytape.core.domain.repository.MusicRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetMusicGenresUseCase @Inject constructor( + private val musicRepository: MusicRepository +) { + + operator fun invoke(): Flow> = musicRepository.getGenres() +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/GetRecentUploadedMusic.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/GetRecentUploadedMusic.kt new file mode 100644 index 0000000..3335e56 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/GetRecentUploadedMusic.kt @@ -0,0 +1,12 @@ +package com.ohdodok.catchytape.core.domain.usecase + +import com.ohdodok.catchytape.core.domain.model.Music +import com.ohdodok.catchytape.core.domain.repository.MusicRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetRecentUploadedMusic @Inject constructor( + private val musicRepository: MusicRepository +) { + operator fun invoke(): Flow> = musicRepository.getRecentUploadedMusic() +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/LoginUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/LoginUseCase.kt index 972ad47..bd7af22 100644 --- a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/LoginUseCase.kt +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/LoginUseCase.kt @@ -10,5 +10,5 @@ class LoginUseCase @Inject constructor( ) { operator fun invoke(googleToken: String): Flow = - authRepository.loginWithGoogle(googleToken).map { authRepository.saveToken(it) } + authRepository.loginWithGoogle(googleToken).map { authRepository.saveAccessToken(it) } } \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/SignUpUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/SignUpUseCase.kt index ea1ff17..9684369 100644 --- a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/SignUpUseCase.kt +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/SignUpUseCase.kt @@ -10,6 +10,6 @@ class SignUpUseCase @Inject constructor( ) { operator fun invoke(googleToken: String, nickname: String): Flow = - authRepository.signUpWithGoogle(googleToken, nickname).map { authRepository.saveToken(it) } + authRepository.signUpWithGoogle(googleToken, nickname).map { authRepository.saveAccessToken(it) } } \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadFileUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadFileUseCase.kt new file mode 100644 index 0000000..9a3cfa3 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadFileUseCase.kt @@ -0,0 +1,22 @@ +package com.ohdodok.catchytape.core.domain.usecase + +import com.ohdodok.catchytape.core.domain.repository.UrlRepository +import kotlinx.coroutines.flow.Flow +import java.io.File +import javax.inject.Inject + +class UploadFileUseCase @Inject constructor( + private val urlRepository: UrlRepository +) { + + fun getImgUrl(file: File): Flow = urlRepository.getImageUrl(file) + + fun getAudioUrl(file: File): Flow = urlRepository.getAudioUrl(file) + + + // TODO : 나중에 쓸 부분임 +// fun getAudioUrl(file: File): Flow = urlRepository.getUuid().map { uuid -> +// urlRepository.getAudioUrl(uuid, file).single() +// } + +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadMusicUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadMusicUseCase.kt new file mode 100644 index 0000000..4490ae6 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/UploadMusicUseCase.kt @@ -0,0 +1,22 @@ +package com.ohdodok.catchytape.core.domain.usecase + +import com.ohdodok.catchytape.core.domain.repository.MusicRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class UploadMusicUseCase @Inject constructor( + private val musicRepository: MusicRepository +) { + + operator fun invoke( + imageUrl: String, + audioUrl: String, + title: String, + genre: String + ): Flow = musicRepository.postMusic( + title = title, + genre = genre, + imageUrl = imageUrl, + audioUrl = audioUrl + ) +} \ No newline at end of file diff --git a/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/signup/ValidateNicknameUseCase.kt b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/signup/ValidateNicknameUseCase.kt new file mode 100644 index 0000000..18924a3 --- /dev/null +++ b/android/core/domain/src/main/java/com/ohdodok/catchytape/core/domain/usecase/signup/ValidateNicknameUseCase.kt @@ -0,0 +1,45 @@ +package com.ohdodok.catchytape.core.domain.usecase.signup + +import com.ohdodok.catchytape.core.domain.repository.AuthRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.single +import javax.inject.Inject + +enum class NicknameValidationResult { + VALID, + EMPTY, + INVALID_LENGTH, + INVALID_CHARACTER, + DUPLICATED, +} + +class ValidateNicknameUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + operator fun invoke(nicknameStream: Flow): Flow = + nicknameStream.mapLatest { nickname -> + val regex = "(^[ㄱ-ㅎ가-힣\\w_.]{2,10}$)".toRegex() + + val result = when { + regex.matches(nickname) -> { + val validNickname = nicknameStream.debounce(300).first() + val response = authRepository.isDuplicatedNickname(validNickname).single() + + if (response) NicknameValidationResult.DUPLICATED + else NicknameValidationResult.VALID + } + + nickname.isBlank() -> NicknameValidationResult.EMPTY + nickname.length !in 2..10 -> NicknameValidationResult.INVALID_LENGTH + else -> NicknameValidationResult.INVALID_CHARACTER + } + + result + } +} \ No newline at end of file diff --git a/android/core/domain/src/test/java/com/ohdodok/catchytape/core/domain/KoTestConfig.kt b/android/core/domain/src/test/java/com/ohdodok/catchytape/core/domain/KoTestConfig.kt new file mode 100644 index 0000000..86d39d8 --- /dev/null +++ b/android/core/domain/src/test/java/com/ohdodok/catchytape/core/domain/KoTestConfig.kt @@ -0,0 +1,16 @@ +package com.ohdodok.catchytape.core.domain + +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.extensions.Extension +import io.kotest.extensions.junitxml.JunitXmlReporter + +class KoTestConfig : AbstractProjectConfig() { + + override fun extensions(): List = listOf( + JunitXmlReporter( + includeContainers = false, // don't write out status for all tests + useTestPathAsName = true, // use the full test path (ie, includes parent test names) + outputDir = "../build/test-results" + ) + ) +} \ No newline at end of file diff --git a/android/core/domain/src/test/java/com/ohdodok/catchytape/core/domain/usecase/signup/NicknameValidationUseCaseTest.kt b/android/core/domain/src/test/java/com/ohdodok/catchytape/core/domain/usecase/signup/NicknameValidationUseCaseTest.kt new file mode 100644 index 0000000..ff01cfc --- /dev/null +++ b/android/core/domain/src/test/java/com/ohdodok/catchytape/core/domain/usecase/signup/NicknameValidationUseCaseTest.kt @@ -0,0 +1,67 @@ +package com.ohdodok.catchytape.core.domain.usecase.signup + +import com.ohdodok.catchytape.core.domain.repository.AuthRepository +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class NicknameValidationUseCaseTest : BehaviorSpec() { + private val authRepository: AuthRepository = mockk() + private val validateNicknameUseCase = ValidateNicknameUseCase(authRepository) + + init { + every { + authRepository.isDuplicatedNickname(any()) + } returns flow { emit(false) } + + given("유효한 닉네임이 주어지고") { + `when`("유효성을 검사하면") { + then("Valid를 반환한다") { + // fixme : 화이트 박스 테스트임 (중복 검사에 대해서만 300ms를 기다리는데 이거 때문에 블랙 박스 테스트가 어려움) + // 현재 VALID 여부를 정확하게 테스트 하고 있기는 하다. (거짓 음성을 뱉지 않음) + val nicknameFlow = flowOf("아이유", "iu", "20", "가a1_.", "특수문자_.") + validateNicknameUseCase(nicknameFlow).onEach { + it shouldBe NicknameValidationResult.VALID + }.launchIn(this) + } + } + } + + given("비어 있는 닉네임이 주어지고") { + `when`("유효성을 검사하면") { + then("Empty를 반환한다") { + validateNicknameUseCase(flowOf("")).onEach { + it shouldBe NicknameValidationResult.EMPTY + }.launchIn(this) + } + } + } + + given("짧거나 긴 닉네임이 주어지고") { + `when`("유효성을 검사하면") { + then("Invalid length를 반환한다") { + val nicknameFlow = flowOf("한", "닉네임을이렇게길게지으면어떡해", "a") + validateNicknameUseCase(nicknameFlow).onEach { + it shouldBe NicknameValidationResult.INVALID_LENGTH + }.launchIn(this) + } + } + } + + given("사용할 수 없는 문자가 포함된 닉네임이 주어지고") { + `when`("유효성을 검사하면") { + then("Invalid length를 반환한다") { + val nicknameFlow = flowOf("안 돼", "특수문자^", "특수문자*") + validateNicknameUseCase(nicknameFlow).onEach { + it shouldBe NicknameValidationResult.INVALID_CHARACTER + }.launchIn(this) + } + } + } + } +} \ No newline at end of file diff --git a/android/core/ui/build.gradle.kts b/android/core/ui/build.gradle.kts index c8d81ba..f664468 100644 --- a/android/core/ui/build.gradle.kts +++ b/android/core/ui/build.gradle.kts @@ -33,8 +33,13 @@ android { dependencies { + implementation(project(":core:domain")) + api(libs.material) api(libs.navigation.fragment.ktx) api(libs.navigation.ui.ktx) + + api(libs.glide) + } \ No newline at end of file diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BaseFragment.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BaseFragment.kt index 32b89e3..9f3f91c 100644 --- a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BaseFragment.kt +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BaseFragment.kt @@ -50,4 +50,5 @@ abstract class BaseFragment( lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED, block) } } + } \ No newline at end of file diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt new file mode 100644 index 0000000..54821fa --- /dev/null +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/BindingAdapter.kt @@ -0,0 +1,21 @@ +package com.ohdodok.catchytape.core.ui + +import android.widget.ImageView +import androidx.databinding.BindingAdapter +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide + +@BindingAdapter("submitList") +fun RecyclerView.bindItems(items: List) { + val adapter = this.adapter ?: return + val listAdapter: ListAdapter = adapter as ListAdapter + listAdapter.submitList(items) +} + +@BindingAdapter("imgUrl") +fun ImageView.bindImg(url: String) { + Glide.with(this.context) + .load(url) + .into(this) +} \ No newline at end of file diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/MusicAdapter.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/MusicAdapter.kt new file mode 100644 index 0000000..7944224 --- /dev/null +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/MusicAdapter.kt @@ -0,0 +1,74 @@ +package com.ohdodok.catchytape.core.ui + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.ohdodok.catchytape.core.domain.model.Music +import com.ohdodok.catchytape.core.ui.databinding.ItemMusicHorizontalBinding +import com.ohdodok.catchytape.core.ui.databinding.ItemMusicVerticalBinding + + +class MusicAdapter(private val musicItemOrientation: Orientation) : + ListAdapter(MusicDiffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (musicItemOrientation) { + Orientation.Horizontal -> HorizontalViewHolder.from(parent) + Orientation.Vertical -> VerticalViewHolder.from(parent) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is HorizontalViewHolder -> holder.bind(currentList[position]) + is VerticalViewHolder -> holder.bind(currentList[position]) + } + } + + + class HorizontalViewHolder private constructor(private val binding: ItemMusicHorizontalBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: Music) { + binding.music = item + } + + companion object { + fun from(parent: ViewGroup) = HorizontalViewHolder( + ItemMusicHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + } + + class VerticalViewHolder private constructor(private val binding: ItemMusicVerticalBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: Music) { + binding.music = item + } + + companion object { + fun from(parent: ViewGroup) = VerticalViewHolder( + ItemMusicVerticalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + } +} + +object MusicDiffUtil : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Music, newItem: Music) = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: Music, newItem: Music) = + oldItem == newItem +} \ No newline at end of file diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/Orientation.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/Orientation.kt new file mode 100644 index 0000000..191be40 --- /dev/null +++ b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/Orientation.kt @@ -0,0 +1,5 @@ +package com.ohdodok.catchytape.core.ui + +enum class Orientation { + Horizontal, Vertical +} \ No newline at end of file diff --git a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/bindingadapter/UploadBindingAdapter.kt b/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/bindingadapter/UploadBindingAdapter.kt deleted file mode 100644 index 4ade183..0000000 --- a/android/core/ui/src/main/java/com/ohdodok/catchytape/core/ui/bindingadapter/UploadBindingAdapter.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.ohdodok.catchytape.core.ui.bindingadapter - -import android.widget.AutoCompleteTextView -import androidx.databinding.BindingAdapter - - -@BindingAdapter("changeSelectedPosition") -fun AutoCompleteTextView.bindPosition(onChange: (Int) -> Unit) { - setOnItemClickListener { _, _, position, _ -> onChange(position) } -} \ No newline at end of file diff --git a/android/core/ui/src/main/res/drawable/btn_background.xml b/android/core/ui/src/main/res/drawable/btn_background.xml index 50da2f6..a33a838 100644 --- a/android/core/ui/src/main/res/drawable/btn_background.xml +++ b/android/core/ui/src/main/res/drawable/btn_background.xml @@ -1,6 +1,19 @@ - - - - \ No newline at end of file + + + + + + + + + + + + + + + + diff --git a/android/core/ui/src/main/res/drawable/ic_camera.xml b/android/core/ui/src/main/res/drawable/ic_camera.xml index 364476e..5a798d6 100644 --- a/android/core/ui/src/main/res/drawable/ic_camera.xml +++ b/android/core/ui/src/main/res/drawable/ic_camera.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + android:fillColor="@color/black"/> diff --git a/android/core/ui/src/main/res/drawable/ic_more.xml b/android/core/ui/src/main/res/drawable/ic_more.xml new file mode 100644 index 0000000..2fe5b80 --- /dev/null +++ b/android/core/ui/src/main/res/drawable/ic_more.xml @@ -0,0 +1,4 @@ + + + diff --git a/android/core/ui/src/main/res/drawable/ic_next.xml b/android/core/ui/src/main/res/drawable/ic_next.xml new file mode 100644 index 0000000..a0f7a6f --- /dev/null +++ b/android/core/ui/src/main/res/drawable/ic_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/ui/src/main/res/drawable/ic_pause.xml b/android/core/ui/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..604f7cb --- /dev/null +++ b/android/core/ui/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/ui/src/main/res/drawable/ic_play.xml b/android/core/ui/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..f711f9c --- /dev/null +++ b/android/core/ui/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/ui/src/main/res/drawable/ic_playlist.xml b/android/core/ui/src/main/res/drawable/ic_playlist.xml new file mode 100644 index 0000000..e82f36b --- /dev/null +++ b/android/core/ui/src/main/res/drawable/ic_playlist.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/ui/src/main/res/drawable/ic_previous.xml b/android/core/ui/src/main/res/drawable/ic_previous.xml new file mode 100644 index 0000000..5e76bb2 --- /dev/null +++ b/android/core/ui/src/main/res/drawable/ic_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/core/ui/src/main/res/drawable/ic_view_radius_12.xml b/android/core/ui/src/main/res/drawable/ic_view_radius_12.xml new file mode 100644 index 0000000..98eb7c3 --- /dev/null +++ b/android/core/ui/src/main/res/drawable/ic_view_radius_12.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/android/feature/home/src/main/res/layout/item_music_horizontal.xml b/android/core/ui/src/main/res/layout/item_music_horizontal.xml similarity index 88% rename from android/feature/home/src/main/res/layout/item_music_horizontal.xml rename to android/core/ui/src/main/res/layout/item_music_horizontal.xml index fa078e8..0c1edcb 100644 --- a/android/feature/home/src/main/res/layout/item_music_horizontal.xml +++ b/android/core/ui/src/main/res/layout/item_music_horizontal.xml @@ -19,6 +19,7 @@ android:id="@+id/iv_thumbnail" android:layout_width="@dimen/music_horizontal_img" android:layout_height="@dimen/music_horizontal_img" + app:imgUrl="@{music.imageUrl}" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -30,6 +31,9 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="@dimen/small" + android:ellipsize="end" + android:maxLines="1" + android:text="@{music.title}" android:textColor="@color/on_surface" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -42,6 +46,9 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="@dimen/extra_small" + android:ellipsize="end" + android:maxLines="1" + android:text="@{music.artist}" android:textColor="@color/on_surface_variant" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/android/core/ui/src/main/res/layout/item_music_vertical.xml b/android/core/ui/src/main/res/layout/item_music_vertical.xml new file mode 100644 index 0000000..90b107c --- /dev/null +++ b/android/core/ui/src/main/res/layout/item_music_vertical.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/core/ui/src/main/res/values-night/colors.xml b/android/core/ui/src/main/res/values-night/colors.xml index 10f2491..d22039b 100644 --- a/android/core/ui/src/main/res/values-night/colors.xml +++ b/android/core/ui/src/main/res/values-night/colors.xml @@ -8,4 +8,6 @@ #FFFFFFFF #FFF2B8B5 #FF424242 + + #FF48CAE4 \ No newline at end of file diff --git a/android/core/ui/src/main/res/values/arrays.xml b/android/core/ui/src/main/res/values/arrays.xml deleted file mode 100644 index 02cfab3..0000000 --- a/android/core/ui/src/main/res/values/arrays.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - 장르를 선택해 주세요. - genre1 - genre2 - genre3 - genre4 - genre5 - - \ No newline at end of file diff --git a/android/core/ui/src/main/res/values/colors.xml b/android/core/ui/src/main/res/values/colors.xml index c8cb6d0..7682a75 100644 --- a/android/core/ui/src/main/res/values/colors.xml +++ b/android/core/ui/src/main/res/values/colors.xml @@ -9,9 +9,11 @@ #FFBB2649 #FFF8E9ED #FFFCF4F6 - #FF212121 + #FFFCF4F6 #FF757575 #FF212121 #FFB00020 #FF424242 + + #FF0096C7 \ No newline at end of file diff --git a/android/core/ui/src/main/res/values/dimens.xml b/android/core/ui/src/main/res/values/dimens.xml index aaac538..62fa497 100644 --- a/android/core/ui/src/main/res/values/dimens.xml +++ b/android/core/ui/src/main/res/values/dimens.xml @@ -9,6 +9,7 @@ 32dp 120dp + 56dp 168dp 50dp diff --git a/android/core/ui/src/main/res/values/strings.xml b/android/core/ui/src/main/res/values/strings.xml index f03fc8d..725656c 100644 --- a/android/core/ui/src/main/res/values/strings.xml +++ b/android/core/ui/src/main/res/values/strings.xml @@ -1,5 +1,7 @@ + 네트워크 연결을 확인해 주세요. + 검색 재생목록 diff --git a/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeFragment.kt b/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeFragment.kt index 2fff1b5..0bb5d6d 100644 --- a/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeFragment.kt +++ b/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeFragment.kt @@ -7,6 +7,8 @@ import androidx.fragment.app.viewModels import androidx.navigation.NavDeepLinkRequest import androidx.navigation.fragment.findNavController import com.ohdodok.catchytape.core.ui.BaseFragment +import com.ohdodok.catchytape.core.ui.MusicAdapter +import com.ohdodok.catchytape.core.ui.Orientation import com.ohdodok.catchytape.feature.home.databinding.FragmentHomeBinding import dagger.hilt.android.AndroidEntryPoint @@ -18,15 +20,22 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.viewModel = viewModel - binding.rvRecentlyAddedSong.adapter = MusicHorizontalAdapter() - - + binding.rvRecentlyAddedSong.adapter = MusicAdapter(musicItemOrientation = Orientation.Horizontal) + viewModel.fetchUploadedMusics() binding.ibUpload.setOnClickListener { val request = NavDeepLinkRequest.Builder .fromUri("android-app://com.ohdodok.catchytape/upload_fragment".toUri()) .build() findNavController().navigate(request) } + + binding.ivRecentlyPlayedSong.setOnClickListener { + val request = NavDeepLinkRequest.Builder + .fromUri("android-app://com.ohdodok.catchytape/player_fragment".toUri()) + .build() + + findNavController().navigate(request) + } } } \ No newline at end of file diff --git a/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeViewModel.kt b/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeViewModel.kt index 067f8e7..dbe14b8 100644 --- a/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeViewModel.kt +++ b/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/HomeViewModel.kt @@ -2,35 +2,31 @@ package com.ohdodok.catchytape.feature.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.Flow +import com.ohdodok.catchytape.core.domain.model.Music +import com.ohdodok.catchytape.core.domain.usecase.GetRecentUploadedMusic +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import javax.inject.Inject data class HomeUiState( - val recentlyUploadedMusics: List = emptyList() + val recentlyUploadedMusics: List = emptyList() ) -class HomeViewModel constructor( - // todo : DI로 주입하는 코드로 변경 - private val getMusicUseCase: GetMusicUseCase = GetMusicUseCase { - flow { emit(listOf()) } - }, +@HiltViewModel +class HomeViewModel @Inject constructor( + private val getRecentUploadedMusicUseCase: GetRecentUploadedMusic ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) val uiState: StateFlow = _uiState.asStateFlow() - init { - fetchUploadedMusics() - } - - private fun fetchUploadedMusics() { - getMusicUseCase() + fun fetchUploadedMusics() { + getRecentUploadedMusicUseCase() .onEach { musics -> _uiState.update { it.copy( @@ -40,9 +36,4 @@ class HomeViewModel constructor( } .launchIn(viewModelScope) } -} - -// todo : domain layer로 이동 -fun interface GetMusicUseCase { - operator fun invoke(): Flow> -} +} \ No newline at end of file diff --git a/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/MusicHorizontalAdapter.kt b/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/MusicHorizontalAdapter.kt deleted file mode 100644 index 56c5ab1..0000000 --- a/android/feature/home/src/main/java/com/ohdodok/catchytape/feature/home/MusicHorizontalAdapter.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.ohdodok.catchytape.feature.home - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.ohdodok.catchytape.core.domain.model.Music -import com.ohdodok.catchytape.feature.home.databinding.ItemMusicHorizontalBinding - -class MusicHorizontalAdapter : - ListAdapter(MusicDiffUtil) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MusicHorizontalViewHolder { - return MusicHorizontalViewHolder.from(parent) - } - - override fun onBindViewHolder(holder: MusicHorizontalViewHolder, position: Int) { - holder.bind(currentList[position]) - } - - class MusicHorizontalViewHolder private constructor( - private val binding: ItemMusicHorizontalBinding - ) : RecyclerView.ViewHolder(binding.root) { - - fun bind(item: Music) { - binding.music = item - } - - companion object { - fun from(parent: ViewGroup): MusicHorizontalViewHolder { - return MusicHorizontalViewHolder( - ItemMusicHorizontalBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - } - } -} - -object MusicDiffUtil : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Music, newItem: Music) = - oldItem.id == newItem.id - - override fun areContentsTheSame(oldItem: Music, newItem: Music) = - oldItem == newItem -} \ No newline at end of file diff --git a/android/feature/home/src/main/res/layout/fragment_home.xml b/android/feature/home/src/main/res/layout/fragment_home.xml index 96dcdef..0b61b41 100644 --- a/android/feature/home/src/main/res/layout/fragment_home.xml +++ b/android/feature/home/src/main/res/layout/fragment_home.xml @@ -10,77 +10,87 @@ type="com.ohdodok.catchytape.feature.home.HomeViewModel" /> - - + - + - + - + - + - + - + + + + + \ No newline at end of file diff --git a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginActivity.kt b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginActivity.kt index 59d0628..4685815 100644 --- a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginActivity.kt +++ b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginActivity.kt @@ -1,14 +1,36 @@ package com.ohdodok.catchytape.feature.login -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import android.view.View +import android.view.ViewTreeObserver +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class LoginActivity : AppCompatActivity() { + private val viewModel: LoginViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_login) + viewModel.automaticallyLogin() + + val content: View = findViewById(android.R.id.content) + content.viewTreeObserver.addOnPreDrawListener( + object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + if (viewModel.isAutoLoginFinished) { + content.viewTreeObserver.removeOnPreDrawListener(this) + return true + } else { + return false + } + } + } + ) } -} \ No newline at end of file +} + + diff --git a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginFragment.kt b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginFragment.kt index bf52ba9..66f72a0 100644 --- a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginFragment.kt +++ b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginFragment.kt @@ -1,12 +1,13 @@ package com.ohdodok.catchytape.feature.login import android.app.Activity +import android.content.ComponentName import android.content.Intent import android.os.Bundle import android.view.View import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.viewModels +import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInOptions @@ -14,10 +15,11 @@ import com.ohdodok.catchytape.core.ui.BaseFragment import com.ohdodok.catchytape.feature.login.databinding.FragmentLoginBinding import dagger.hilt.android.AndroidEntryPoint + @AndroidEntryPoint class LoginFragment : BaseFragment(R.layout.fragment_login) { - private val viewModel: LoginViewModel by viewModels() + private val viewModel: LoginViewModel by activityViewModels() private val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) .requestIdToken(BuildConfig.GOOGLE_CLIENT_ID) @@ -51,6 +53,9 @@ class LoginFragment : BaseFragment(R.layout.fragment_login viewModel.events.collect { event -> when (event) { is LoginEvent.NavigateToHome -> { + val intent = Intent() + intent.component = ComponentName("com.ohdodok.catchytape", "com.ohdodok.catchytape.MainActivity") + startActivity(intent) activity?.finish() } @@ -65,4 +70,6 @@ class LoginFragment : BaseFragment(R.layout.fragment_login } } } + + } diff --git a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginViewModel.kt b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginViewModel.kt index 53e4340..c065a53 100644 --- a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginViewModel.kt +++ b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/LoginViewModel.kt @@ -2,6 +2,7 @@ package com.ohdodok.catchytape.feature.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.ohdodok.catchytape.core.domain.usecase.AutomaticallyLoginUseCase import com.ohdodok.catchytape.core.domain.usecase.LoginUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -9,24 +10,39 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( - private val loginUseCase: LoginUseCase + private val loginUseCase: LoginUseCase, + private val automaticallyLoginUseCase: AutomaticallyLoginUseCase ) : ViewModel() { private val _events = MutableSharedFlow() val events = _events.asSharedFlow() - fun login(token: String) { + var isAutoLoginFinished: Boolean = false + private set + + fun login(token: String, isAutoLogin: Boolean = false) { loginUseCase(token) .catch { - _events.emit(LoginEvent.NavigateToNickName(token)) + if (isAutoLogin.not()) { + _events.emit(LoginEvent.NavigateToNickName(token)) + } }.onEach { _events.emit(LoginEvent.NavigateToHome) }.launchIn(viewModelScope) } + + fun automaticallyLogin() { + viewModelScope.launch { + val isLoggedIn = automaticallyLoginUseCase() + if (isLoggedIn) _events.emit(LoginEvent.NavigateToHome) + isAutoLoginFinished = true + } + } } sealed interface LoginEvent { diff --git a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/NicknameFragment.kt b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/NicknameFragment.kt index ba5652a..bfc54ab 100644 --- a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/NicknameFragment.kt +++ b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/NicknameFragment.kt @@ -1,9 +1,14 @@ package com.ohdodok.catchytape.feature.login +import android.content.ComponentName +import android.content.Intent import android.os.Bundle import android.view.View +import android.widget.TextView +import androidx.databinding.BindingAdapter import androidx.fragment.app.viewModels import androidx.navigation.fragment.navArgs +import com.ohdodok.catchytape.core.domain.usecase.signup.NicknameValidationResult import com.ohdodok.catchytape.core.ui.BaseFragment import com.ohdodok.catchytape.feature.login.databinding.FragmentNicknameBinding import dagger.hilt.android.AndroidEntryPoint @@ -38,10 +43,26 @@ class NicknameFragment : BaseFragment(R.layout.fragment viewModel.events.collect { event -> when (event) { is NicknameEvent.NavigateToHome -> { + val intent = Intent() + intent.component = ComponentName("com.ohdodok.catchytape", "com.ohdodok.catchytape.MainActivity") + startActivity(intent) activity?.finish() } } } } } +} + +@BindingAdapter("nicknameValidationState") +fun TextView.bindNicknameValidationState(state: NicknameValidationResult) { + val messageId = when(state) { + NicknameValidationResult.EMPTY -> R.string.empty_nickname + NicknameValidationResult.VALID -> R.string.valid_nickname + NicknameValidationResult.DUPLICATED -> R.string.duplicated_nickname + NicknameValidationResult.INVALID_LENGTH -> R.string.invalid_length + NicknameValidationResult.INVALID_CHARACTER -> R.string.invalid_character + } + + this.text = resources.getString(messageId) } \ No newline at end of file diff --git a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/NicknameViewModel.kt b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/NicknameViewModel.kt index 1aa1734..4e84c90 100644 --- a/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/NicknameViewModel.kt +++ b/android/feature/login/src/main/java/com/ohdodok/catchytape/feature/login/NicknameViewModel.kt @@ -2,18 +2,24 @@ package com.ohdodok.catchytape.feature.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.ohdodok.catchytape.core.domain.usecase.signup.NicknameValidationResult +import com.ohdodok.catchytape.core.domain.usecase.signup.ValidateNicknameUseCase import com.ohdodok.catchytape.core.domain.usecase.SignUpUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class NicknameViewModel @Inject constructor( - private val signUpUseCase: SignUpUseCase + private val signUpUseCase: SignUpUseCase, + private val validateNicknameUseCase: ValidateNicknameUseCase, ) : ViewModel() { private val _events = MutableSharedFlow() @@ -21,8 +27,18 @@ class NicknameViewModel @Inject constructor( val nickname = MutableStateFlow("") + val nicknameValidationState: StateFlow = + validateNicknameUseCase(nickname) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + NicknameValidationResult.EMPTY + ) + fun signUp(googleToken: String) { - // TODO : 중복 검사 및 유효성 검사 + + if (nicknameValidationState.value != NicknameValidationResult.VALID) return + signUpUseCase(googleToken = googleToken, nickname = nickname.value) .onEach { _events.emit(NicknameEvent.NavigateToHome) diff --git a/android/feature/login/src/main/res/layout/fragment_nickname.xml b/android/feature/login/src/main/res/layout/fragment_nickname.xml index 9f04ea1..089dfe8 100644 --- a/android/feature/login/src/main/res/layout/fragment_nickname.xml +++ b/android/feature/login/src/main/res/layout/fragment_nickname.xml @@ -5,6 +5,8 @@ + + @@ -43,15 +45,30 @@ android:layout_marginTop="@dimen/medium" android:hint="@string/nickname" android:text="@={viewModel.nickname}" + android:maxLength="10" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tv_nickname_title" /> + + 사용하실 닉네임을\n입력해주세요 :) 시작하기 + + 사용 가능한 닉네임이에요. + 이미 사용중인 닉네임이에요. + 한글, 영어, 특수문자(-, _, .)만 입력 가능해요. + 닉네임은 2~10글자까지 가능해요. \ No newline at end of file diff --git a/android/feature/player/.gitignore b/android/feature/player/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/android/feature/player/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/feature/player/build.gradle.kts b/android/feature/player/build.gradle.kts new file mode 100644 index 0000000..cead45b --- /dev/null +++ b/android/feature/player/build.gradle.kts @@ -0,0 +1,30 @@ +@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed +plugins { + id("catchytape.android.feature") +} + +android { + namespace = "com.ohdodok.catchytape.feature.player" + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + +} \ No newline at end of file diff --git a/android/feature/player/consumer-rules.pro b/android/feature/player/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/android/feature/player/proguard-rules.pro b/android/feature/player/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/android/feature/player/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/feature/player/src/main/AndroidManifest.xml b/android/feature/player/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/android/feature/player/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerFragment.kt b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerFragment.kt new file mode 100644 index 0000000..7f7d9e2 --- /dev/null +++ b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerFragment.kt @@ -0,0 +1,18 @@ +package com.ohdodok.catchytape.feature.player + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import com.ohdodok.catchytape.core.ui.BaseFragment +import com.ohdodok.catchytape.feature.player.databinding.FragmentPlayerBinding + +class PlayerFragment : BaseFragment(R.layout.fragment_player) { + private val viewModel: PlayerViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.viewModel = viewModel + + setupBackStack(binding.tbPlayer) + } +} \ No newline at end of file diff --git a/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerViewModel.kt b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerViewModel.kt new file mode 100644 index 0000000..f251eb8 --- /dev/null +++ b/android/feature/player/src/main/java/com/ohdodok/catchytape/feature/player/PlayerViewModel.kt @@ -0,0 +1,12 @@ +package com.ohdodok.catchytape.feature.player + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class PlayerViewModel @Inject constructor( + +) : ViewModel() { + +} \ No newline at end of file diff --git a/android/feature/player/src/main/res/layout/fragment_player.xml b/android/feature/player/src/main/res/layout/fragment_player.xml new file mode 100644 index 0000000..42eb729 --- /dev/null +++ b/android/feature/player/src/main/res/layout/fragment_player.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/feature/player/src/main/res/navigation/player_navigation.xml b/android/feature/player/src/main/res/navigation/player_navigation.xml new file mode 100644 index 0000000..9a9af31 --- /dev/null +++ b/android/feature/player/src/main/res/navigation/player_navigation.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/BindingAdapter.kt b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/BindingAdapter.kt new file mode 100644 index 0000000..8489ec3 --- /dev/null +++ b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/BindingAdapter.kt @@ -0,0 +1,12 @@ +package com.ohdodok.catchytape.feature.upload + +import android.R +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import androidx.databinding.BindingAdapter + +@BindingAdapter("list") +fun AutoCompleteTextView.setAdapter(list: List) { + val adapter = ArrayAdapter(this.context, R.layout.simple_list_item_1, list) + setAdapter(adapter) +} \ No newline at end of file diff --git a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt index 75ae687..2045229 100644 --- a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt +++ b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadFragment.kt @@ -1,16 +1,20 @@ package com.ohdodok.catchytape.feature.upload +import android.net.Uri import android.os.Bundle +import android.provider.OpenableColumns import android.view.View import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia -import androidx.core.net.toFile import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController import com.ohdodok.catchytape.catchytape.upload.R import com.ohdodok.catchytape.catchytape.upload.databinding.FragmentUploadBinding import com.ohdodok.catchytape.core.ui.BaseFragment import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import java.io.FileOutputStream @AndroidEntryPoint class UploadFragment : BaseFragment(R.layout.fragment_upload) { @@ -18,23 +22,50 @@ class UploadFragment : BaseFragment(R.layout.fragment_upl private val imagePickerLauncher = registerForActivityResult(PickVisualMedia()) { uri -> if (uri == null) return@registerForActivityResult - - viewModel.uploadImage(uri.toFile()) + uri.toPath()?.let { path -> viewModel.uploadImage(File(path)) } } private val filePickerLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri == null) return@registerForActivityResult - - viewModel.uploadAudio(uri.toFile()) + uri.toPath()?.let { path -> viewModel.uploadAudio(File(path)) } + binding.btnFile.text = getFileName(uri) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.viewModel = viewModel - setupBackStack(binding.tbUploadAppbar) + observeEvents() + + setUpFileBtn() + setUpCompleteBtn() setupSelectThumbnailImage() + setupBackStack(binding.tbUpload) + } + + private fun observeEvents() { + repeatOnStarted { + viewModel.events.collect { event -> + when (event) { + is UploadEvent.NavigateToBack -> { + findNavController().popBackStack() + } + } + } + } + } + + private fun setUpFileBtn() { + binding.btnFile.setOnClickListener { + filePickerLauncher.launch("audio/*") + } + } + + private fun setUpCompleteBtn() { + binding.btnComplete.setOnClickListener { + viewModel.uploadMusic() + } } private fun setupSelectThumbnailImage() { @@ -42,4 +73,26 @@ class UploadFragment : BaseFragment(R.layout.fragment_upl imagePickerLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) } } -} \ No newline at end of file + + private fun getFileName(uri: Uri): String? { + val contentResolver = requireContext().contentResolver + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + return cursor.getString(nameIndex) + } + return null + } + + private fun Uri.toPath(): String? { + val file = getFileName(this)?.let { File(requireContext().filesDir, it) } + val inputStream = requireContext().contentResolver.openInputStream(this) + val outputStream = FileOutputStream(file) + inputStream.use { input -> + outputStream.use { output -> + input?.copyTo(output) + } + } + return file?.absolutePath + } +} diff --git a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt index 2e703b5..207b068 100644 --- a/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt +++ b/android/feature/upload/src/main/java/com/ohdodok/catchytape/feature/upload/UploadViewModel.kt @@ -5,40 +5,117 @@ import dagger.hilt.android.lifecycle.HiltViewModel import java.io.File import javax.inject.Inject import androidx.lifecycle.viewModelScope +import com.ohdodok.catchytape.core.domain.usecase.UploadFileUseCase +import com.ohdodok.catchytape.core.domain.usecase.GetMusicGenresUseCase +import com.ohdodok.catchytape.core.domain.usecase.UploadMusicUseCase +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @HiltViewModel class UploadViewModel @Inject constructor( - + private val getMusicGenresUseCase: GetMusicGenresUseCase, + private val uploadFileUseCase: UploadFileUseCase, + private val uploadMusicUseCase: UploadMusicUseCase ) : ViewModel() { - private var uploadedImage: String? = null + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + val musicTitle = MutableStateFlow("") + val musicGenre = MutableStateFlow("") + + private val _imageState: MutableStateFlow = + MutableStateFlow(UploadedFileState()) + val imageState = _imageState.asStateFlow() - val uploadedMusicTitle = MutableStateFlow("") + private val _audioState: MutableStateFlow = + MutableStateFlow(UploadedFileState()) + val audioState = _audioState.asStateFlow() - private val _uploadedMusicGenrePosition = MutableStateFlow(0) - val uploadedMusicGenrePosition = _uploadedMusicGenrePosition.asStateFlow() + val isLoading: StateFlow = combine(imageState, audioState) { imageState, audioState -> + imageState.isLoading || audioState.isLoading + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false + ) val isUploadEnable: StateFlow = - combine(uploadedMusicTitle, uploadedMusicGenrePosition) { title, genrePosition -> - title.isNotBlank() && genrePosition != 0 + combine( + musicTitle, musicGenre, imageState, audioState + ) { title, genre, imageState, audioState -> + title.isNotBlank() + && genre.isNotBlank() + && imageState.url.isNotBlank() + && audioState.url.isNotBlank() }.stateIn(viewModelScope, SharingStarted.Eagerly, false) - val onChangePosition: (Int) -> Unit = { position: Int -> _uploadedMusicGenrePosition.value = position } + private val _musicGenres: MutableStateFlow> = MutableStateFlow(emptyList()) + val musicGenres = _musicGenres.asStateFlow() + + init { + fetchGenres() + } + + private fun fetchGenres() { + getMusicGenresUseCase().onEach { + _musicGenres.value = it + }.launchIn(viewModelScope) + } fun uploadImage(imageFile: File) { - // todo : image 파일을 업로드 한다. - // todo : 반환 값을 uploadedImage에 저장한다. + uploadFileUseCase.getImgUrl(imageFile).onStart { + _imageState.value = imageState.value.copy(isLoading = true) + }.onEach { url -> + _imageState.value = imageState.value.copy(url = url) + }.onCompletion { + _imageState.value = imageState.value.copy(isLoading = false) + }.launchIn(viewModelScope) } fun uploadAudio(audioFile: File) { - // todo : audio 파일을 업로드 한다. + uploadFileUseCase.getAudioUrl(audioFile).onStart { + _audioState.value = audioState.value.copy(isLoading = true) + }.onEach { url -> + _audioState.value = audioState.value.copy(url = url) + }.onCompletion { + _audioState.value = audioState.value.copy(isLoading = false) + }.launchIn(viewModelScope) + } + + fun uploadMusic() { + if (isUploadEnable.value) { + uploadMusicUseCase( + imageUrl = imageState.value.url, + audioUrl = audioState.value.url, + title = musicTitle.value, + genre = musicGenre.value + ).onEach { + _events.emit(UploadEvent.NavigateToBack) + }.catch { + // TODO : 업로드 실패 + }.launchIn(viewModelScope) + } } -} \ No newline at end of file +} + +data class UploadedFileState( + val isLoading: Boolean = false, + val url: String = "" +) + +sealed interface UploadEvent { + data object NavigateToBack : UploadEvent +} + diff --git a/android/feature/upload/src/main/res/layout/fragment_upload.xml b/android/feature/upload/src/main/res/layout/fragment_upload.xml index 93b4ae6..04ee2f0 100644 --- a/android/feature/upload/src/main/res/layout/fragment_upload.xml +++ b/android/feature/upload/src/main/res/layout/fragment_upload.xml @@ -4,6 +4,10 @@ + + @@ -15,7 +19,7 @@ android:background="@color/surface"> - + + + app:layout_constraintTop_toBottomOf="@id/tb_upload"> + android:importantForAccessibility="no" + app:imgUrl="@{viewModel.imageState.url}" /> + app:visibility="@{viewModel.imageState.url.empty ? view.VISIBLE : view.GONE}" /> + + + app:layout_constraintTop_toBottomOf="@id/btn_file"> + android:text="@={viewModel.musicTitle}" /> @@ -95,21 +124,21 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/margin_horizontal" - android:layout_marginTop="@dimen/medium" + android:layout_marginTop="@dimen/large" android:hint="@string/genre" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/til_title"> + android:text="@={viewModel.musicGenre}" + app:list="@{viewModel.musicGenres}" /> - \ No newline at end of file diff --git a/android/feature/upload/src/main/res/values/strings.xml b/android/feature/upload/src/main/res/values/strings.xml new file mode 100644 index 0000000..00bf9a5 --- /dev/null +++ b/android/feature/upload/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 파일 업로드 + \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a7c3f4a..1452828 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -14,6 +14,8 @@ javaInject = "1" gms = "20.7.0" junit = "4.13.2" +kotest = "5.8.0" +mockk = "1.13.8" retrofit = "2.9.0" okhttp = "4.11.0" @@ -21,6 +23,7 @@ kotlinx-serialization = "1.6.0" kotlinx-serialization-converter = "1.0.0" coroutines = "1.3.5" +glide = "4.15.0" timber = "5.0.1" [libraries] @@ -41,14 +44,19 @@ inject = { group = "javax.inject", name = "javax.inject", version.ref = "javaInj google-play-services = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "gms" } +kotest-runner = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest"} +kotest-property = { group = "io.kotest", name = "kotest-property", version.ref = "kotest"} +kotest-extentions-junitxml = { group = "io.kotest", name = "kotest-extensions-junitxml", version.ref = "kotest"} junit = { group = "junit", name = "junit", version.ref = "junit" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk"} retrofit = { module = "com.squareup.retrofit2:retrofit", name = "retrofit", version.ref = "retrofit" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", name = "okhttp", version.ref = "okhttp" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "kotlinx-serialization-converter" } -coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } [plugins] diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 6a5389d..44d82a7 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -22,3 +22,4 @@ include(":core:ui") include(":core:domain") include(":core:data") include(":feature:upload") +include(":feature:player") diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..56b80bd --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,4 @@ +.git +Dockerfile +node_modules +dist \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..92b58b0 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20 + +WORKDIR /catchy-tape + +COPY . . + +RUN npm install +RUN npm run build + +EXPOSE 3000 +CMD [ "npm", "run", "start:prod" ] \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index d5e2dad..0daa11e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -40,6 +40,7 @@ "@types/passport": "^1.0.15", "@types/passport-google-oauth20": "^2.0.14", "@types/supertest": "^2.0.12", + "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "eslint": "^8.42.0", @@ -2233,6 +2234,12 @@ "@types/superagent": "*" } }, + "node_modules/@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", + "dev": true + }, "node_modules/@types/validator": { "version": "13.11.6", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.6.tgz", diff --git a/server/package.json b/server/package.json index 8d0f7dc..a738267 100644 --- a/server/package.json +++ b/server/package.json @@ -51,6 +51,7 @@ "@types/passport": "^1.0.15", "@types/passport-google-oauth20": "^2.0.14", "@types/supertest": "^2.0.12", + "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "eslint": "^8.42.0", @@ -81,6 +82,9 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "moduleNameMapper": { + "^src/(.*)": "/$1" + } } } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 83de3ce..d6b78c8 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -7,6 +7,8 @@ import { TypeOrmConfigService } from 'src/config/typeorm.config'; import { ConfigModule } from '@nestjs/config'; import { UploadModule } from './upload/upload.module'; import { MusicModule } from './music/music.module'; +import { PlaylistController } from './playlist/playlist.controller'; +import { PlaylistModule } from './playlist/playlist.module'; @Module({ imports: [ @@ -18,6 +20,7 @@ import { MusicModule } from './music/music.module'; UserModule, UploadModule, MusicModule, + PlaylistModule, ], controllers: [AppController], providers: [AppService], diff --git a/server/src/auth/auth.controller.spec.ts b/server/src/auth/auth.controller.spec.ts index 27a31e6..0c0504e 100644 --- a/server/src/auth/auth.controller.spec.ts +++ b/server/src/auth/auth.controller.spec.ts @@ -1,15 +1,51 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from 'src/entity/user.entity'; +import { Repository } from 'typeorm'; +import { JwtModule, JwtService } from '@nestjs/jwt'; +import { PlaylistService } from 'src/playlist/playlist.service'; +import { Playlist } from 'src/entity/playlist.entity'; +import { Music } from 'src/entity/music.entity'; +import { Music_Playlist } from 'src/entity/music_playlist.entity'; describe('AuthController', () => { let controller: AuthController; + let service: AuthService; + let jwtModule: JwtModule; + let userRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [JwtModule], controllers: [AuthController], + providers: [ + AuthService, + { + provide: getRepositoryToken(User), + useClass: Repository, + }, + { + provide: getRepositoryToken(Playlist), + useClass: Repository, + }, + { + provide: getRepositoryToken(Music), + useClass: Repository, + }, + { + provide: getRepositoryToken(Music_Playlist), + useClass: Repository, + }, + PlaylistService, + ], }).compile(); controller = module.get(AuthController); + service = module.get(AuthService); + jwtModule = module.get(JwtModule); + userRepository = module.get(getRepositoryToken(User)); }); it('should be defined', () => { diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index c50840c..a8a3519 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -1,14 +1,20 @@ import { Body, Controller, + Delete, + Get, HttpCode, Post, + Req, + UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import { AuthService } from './auth.service'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; import { UserCreateDto } from 'src/dto/userCreate.dto'; +import { AuthGuard } from '@nestjs/passport'; +import { User } from 'src/entity/user.entity'; @Controller('users') export class AuthController { @@ -31,4 +37,20 @@ export class AuthController { ): Promise<{ accessToken: string }> { return this.authService.signup(userCreateDto); } + + @Get('verify') + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + verifyToken(@Req() req): { userId: string } { + const user: User = req.user; + return { userId: user.user_id }; + } + + @Delete() + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async deleteUser(@Req() req): Promise<{userId: string}> { + const user: User = req.user; + return await this.authService.deleteUser(user); + } } diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts index 37a18b2..1159b2c 100644 --- a/server/src/auth/auth.module.ts +++ b/server/src/auth/auth.module.ts @@ -7,6 +7,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from 'src/entity/user.entity'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { PlaylistService } from 'src/playlist/playlist.service'; +import { Playlist } from 'src/entity/playlist.entity'; +import { Music } from 'src/entity/music.entity'; +import { Music_Playlist } from 'src/entity/music_playlist.entity'; @Module({ imports: [ @@ -19,9 +23,9 @@ import { AuthService } from './auth.service'; }), inject: [ConfigService], }), - TypeOrmModule.forFeature([User]), + TypeOrmModule.forFeature([User, Playlist, Music, Music_Playlist]), ], - providers: [JwtStrategy, AuthService], + providers: [JwtStrategy, AuthService, PlaylistService], exports: [JwtStrategy, PassportModule], controllers: [AuthController], }) diff --git a/server/src/auth/auth.service.spec.ts b/server/src/auth/auth.service.spec.ts index 800ab66..26cc20e 100644 --- a/server/src/auth/auth.service.spec.ts +++ b/server/src/auth/auth.service.spec.ts @@ -1,15 +1,49 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from './auth.service'; +import { Repository } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from 'src/entity/user.entity'; +import { JwtModule, JwtService } from '@nestjs/jwt'; +import { PlaylistService } from 'src/playlist/playlist.service'; +import { Playlist } from 'src/entity/playlist.entity'; +import { Music } from 'src/entity/music.entity'; +import { Music_Playlist } from 'src/entity/music_playlist.entity'; describe('AuthService', () => { let service: AuthService; + let jwtModule: JwtModule; + let userRepository: Repository; + let playlistService: PlaylistService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [AuthService], + imports: [JwtModule], + providers: [ + AuthService, + { + provide: getRepositoryToken(User), + useClass: Repository, + }, + { + provide: getRepositoryToken(Playlist), + useClass: Repository, + }, + { + provide: getRepositoryToken(Music), + useClass: Repository, + }, + { + provide: getRepositoryToken(Music_Playlist), + useClass: Repository, + }, + PlaylistService, + ], }).compile(); service = module.get(AuthService); + jwtModule = module.get(JwtModule); + userRepository = module.get(getRepositoryToken(User)); + playlistService = module.get(PlaylistService); }); it('should be defined', () => { diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 9b1e870..1d58561 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -1,9 +1,13 @@ -import { HttpException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; +import { CatchyException } from 'src/config/catchyException'; +import { ERROR_CODE } from 'src/config/errorCode.enum'; +import { RECENT_PLAYLIST_NAME } from 'src/constants'; import { UserCreateDto } from 'src/dto/userCreate.dto'; import { User } from 'src/entity/user.entity'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; +import { PlaylistService } from 'src/playlist/playlist.service'; import { Repository } from 'typeorm'; import { v4 as uuid } from 'uuid'; @@ -12,6 +16,7 @@ export class AuthService { constructor( @InjectRepository(User) private userRepository: Repository, private jwtService: JwtService, + private readonly playlistService: PlaylistService, ) {} async login(email: string): Promise<{ accessToken: string }> { @@ -25,9 +30,10 @@ export class AuthService { return { accessToken }; } else { - throw new HttpException( + throw new CatchyException( 'NOT_EXIST_USER', HTTP_STATUS_CODE['WRONG_TOKEN'], + ERROR_CODE.NOT_EXIST_USER, ); } } @@ -37,7 +43,11 @@ export class AuthService { const email: string = await this.getGoogleEmail(idToken); if (await this.isExistEmail(email)) { - throw new HttpException('EXIST_EMAIL', HTTP_STATUS_CODE.BAD_REQUEST); + throw new CatchyException( + 'ALREADY_EXIST_EMAIL', + HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.ALREADY_EXIST_EMAIL, + ); } if (email) { const newUser: User = this.userRepository.create({ @@ -49,9 +59,16 @@ export class AuthService { }); await this.userRepository.save(newUser); + this.playlistService.createPlaylist(newUser.user_id, { + title: RECENT_PLAYLIST_NAME, + }); return this.login(email); } - throw new HttpException('WRONG_TOKEN', HTTP_STATUS_CODE.WRONG_TOKEN); + throw new CatchyException( + 'WRONG_TOKEN', + HTTP_STATUS_CODE.WRONG_TOKEN, + ERROR_CODE.WRONG_TOKEN, + ); } async getGoogleEmail(googleIdToken: string): Promise { @@ -62,7 +79,11 @@ export class AuthService { }).then((res) => res.json()); if (!userInfo.email) { - throw new HttpException('EXPIRED_TOKEN', HTTP_STATUS_CODE.WRONG_TOKEN); + throw new CatchyException( + 'EXPIRED_TOKEN', + HTTP_STATUS_CODE.WRONG_TOKEN, + ERROR_CODE.EXPIRED_TOKEN, + ); } return userInfo.email; } @@ -78,4 +99,9 @@ export class AuthService { return true; } } + + async deleteUser(user: User): Promise<{ userId: string }> { + await this.userRepository.delete(user.user_id); + return { userId: user.user_id }; + } } diff --git a/server/src/auth/jwt.strategy.ts b/server/src/auth/jwt.strategy.ts index 6b8c7e2..1dda317 100644 --- a/server/src/auth/jwt.strategy.ts +++ b/server/src/auth/jwt.strategy.ts @@ -1,8 +1,10 @@ -import { HttpException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; import { Strategy, ExtractJwt } from 'passport-jwt'; +import { CatchyException } from 'src/config/catchyException'; +import { ERROR_CODE } from 'src/config/errorCode.enum'; import { User } from 'src/entity/user.entity'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; import { Repository } from 'typeorm'; @@ -20,16 +22,17 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload): Promise { - const { userId } = payload; + const { user_Id } = payload; const user: User = await this.userRepository.findOne({ - where: { user_id: userId }, + where: { user_id: user_Id }, }); - if (!user) { - throw new HttpException( - 'Not Exist User', + if (!user || !user_Id) { + throw new CatchyException( + 'NOT_EXIST_USER', HTTP_STATUS_CODE['WRONG_TOKEN'], + ERROR_CODE.NOT_EXIST_USER, ); } diff --git a/server/src/config/catchyException.ts b/server/src/config/catchyException.ts new file mode 100644 index 0000000..40a665f --- /dev/null +++ b/server/src/config/catchyException.ts @@ -0,0 +1,9 @@ +import { HttpException } from '@nestjs/common'; +import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; +import { ERROR_CODE } from './errorCode.enum'; + +export class CatchyException extends HttpException { + constructor(message: string, statuscode: HTTP_STATUS_CODE, errorCode: ERROR_CODE) { + super({message, errorCode}, statuscode); + } +} diff --git a/server/src/config/errorCode.enum.ts b/server/src/config/errorCode.enum.ts new file mode 100644 index 0000000..e6cf932 --- /dev/null +++ b/server/src/config/errorCode.enum.ts @@ -0,0 +1,16 @@ +export enum ERROR_CODE { + 'NOT_DUPLICATED_NICKNAME' = 1000, + 'DUPLICATED_NICKNAME' = 1001, + 'SERVER_ERROR' = 5000, + 'SERVICE_ERROR' = 5001, + 'NOT_EXIST_PLAYLIST_ON_USER' = 4001, + 'NOT_EXIST_MUSIC' = 4002, + 'ALREADY_ADDED' = 4003, + 'INVALID_INPUT_VALUE' = 4004, + 'NOT_EXIST_USER' = 4005, + 'ALREADY_EXIST_EMAIL' = 4006, + 'NOT_EXIST_GENRE' = 4007, + + 'WRONG_TOKEN' = 4100, + 'EXPIRED_TOKEN' = 4101, +} diff --git a/server/src/config/ncloud.config.ts b/server/src/config/ncloud.config.ts index 09d2038..47cf405 100644 --- a/server/src/config/ncloud.config.ts +++ b/server/src/config/ncloud.config.ts @@ -14,6 +14,7 @@ export class NcloudConfigService { accessKeyId: this.configService.get('ACCESS_ID'), secretAccessKey: this.configService.get('SECRET_ACCESS_KEY'), }, + signatureVersion: 'v4', }); } } diff --git a/server/src/constants.ts b/server/src/constants.ts index a994751..acded7d 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -1,6 +1,6 @@ export const fileSize: Record = { - MUSIC_FILE_LIMIT_SIZE: 1024 * 1024 * 50, - IMAGE_FILE_LIMIT_SIZE: 1024 * 1024 * 5, + MUSIC_SIZE: 1024 * 1024 * 50, + IMAGE_SIZE: 1024 * 1024 * 5, }; export enum Genres { @@ -13,3 +13,14 @@ export enum Genres { 'dance' = 'dance', 'etc' = 'etc', } + +export const RECENT_PLAYLIST_NAME = '최근 재생 목록'; +export const keyFlags = ['user', 'music', 'cover']; + +export const keyHandler: { + [key: string]: (uuid: string) => string; +} = { + user: (uuid) => `image/user/${uuid}/user.png`, + music: (uuid) => `music/${uuid}/music.mp3`, + cover: (uuid) => `image/cover/${uuid}/cover.png`, +}; diff --git a/server/src/dto/playlistCreate.dto.ts b/server/src/dto/playlistCreate.dto.ts new file mode 100644 index 0000000..c3ddf7f --- /dev/null +++ b/server/src/dto/playlistCreate.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsString, Matches, MaxLength } from "class-validator"; + +export class PlaylistCreateDto { + @IsNotEmpty() + @IsString() + @MaxLength(20, { message: '글자 수가 50을 넘어갔습니다.' }) + @Matches(/^[가-힣a-zA-Z ]+$/) + title: string; + } \ No newline at end of file diff --git a/server/src/entity/music.entity.ts b/server/src/entity/music.entity.ts index 9486049..b48f261 100644 --- a/server/src/entity/music.entity.ts +++ b/server/src/entity/music.entity.ts @@ -6,14 +6,16 @@ import { JoinColumn, PrimaryGeneratedColumn, ManyToOne, + OneToMany, } from 'typeorm'; import { User } from './user.entity'; import { Genres } from 'src/constants'; +import { Music_Playlist } from './music_playlist.entity'; @Entity({ name: 'music' }) export class Music extends BaseEntity { @PrimaryGeneratedColumn() - musicId: string; + musicId: number; @Column() title: string; @@ -33,7 +35,63 @@ export class Music extends BaseEntity { @CreateDateColumn() created_at: Date; - @ManyToOne(() => User) + @ManyToOne(() => User, (user) => user.musics) @JoinColumn({ name: 'user_id' }) - user_id: string; + user: User; + + @OneToMany(() => Music_Playlist, (music_playlist) => music_playlist.music) + music_playlist: Music_Playlist[]; + + static async getMusicListByUserId( + userId: string, + count: number, + ): Promise { + return this.find({ + relations: { + user: true, + }, + where: { + user: { user_id: userId }, + }, + select: { + musicId: true, + title: true, + lyrics: true, + cover: true, + musicFile: true, + genre: true, + created_at: true, + user: { user_id: true, nickname: true }, + }, + order: { + created_at: 'DESC', + }, + take: count, + }); + } + + static async getRecentMusic(): Promise { + return this.find({ + relations: { + user: true, + }, + select: { + musicId: true, + title: true, + lyrics: true, + cover: true, + musicFile: true, + genre: true, + created_at: true, + user: { + user_id: true, + nickname: true, + }, + }, + order: { + created_at: 'DESC', + }, + take: 10, + }); + } } diff --git a/server/src/entity/music_playlist.entity.ts b/server/src/entity/music_playlist.entity.ts new file mode 100644 index 0000000..d4155a4 --- /dev/null +++ b/server/src/entity/music_playlist.entity.ts @@ -0,0 +1,80 @@ +import { + BaseEntity, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Music } from './music.entity'; +import { Playlist } from './playlist.entity'; + +@Entity({ name: 'music_playlist' }) +export class Music_Playlist extends BaseEntity { + @PrimaryGeneratedColumn() + music_playlist_id: number; + + @ManyToOne(() => Music, (music) => music.music_playlist) + @JoinColumn({ name: 'music_id' }) + music: Music; + + @ManyToOne(() => Playlist, (playlist) => playlist.music_playlist) + @JoinColumn({ name: 'playlist_id' }) + playlist: Playlist; + + static async getMusicListByPlaylistId(playlistId: number): Promise { + return this.find({ + relations: { + music: { user: true }, + }, + where: { + playlist: { playlist_Id: playlistId }, + }, + select: { + music: { + musicId: true, + title: true, + cover: true, + musicFile: true, + genre: true, + user: { user_id: true, nickname: true }, + }, + music_playlist_id: false, + }, + order: { + music_playlist_id: 'DESC', + }, + }).then((a: Music_Playlist[]) => a.map((b) => b.music)); + } + + static async getRecentPlayedMusicByUserId(userId: string): Promise { + return await this.find({ + relations: { + music: true, + }, + where: { + playlist: { + playlist_title: '최근 재생 목록', + }, + music: { + user: { + user_id: userId, + }, + }, + }, + select: { + music_playlist_id: false, + music: { + musicId: true, + title: true, + musicFile: true, + cover: true, + genre: true, + }, + }, + order: { + music_playlist_id: 'DESC', + }, + take: 10, + }).then((a: Music_Playlist[]) => a.map((b) => b.music)); + } +} diff --git a/server/src/entity/playlist.entity.ts b/server/src/entity/playlist.entity.ts new file mode 100644 index 0000000..d5d9391 --- /dev/null +++ b/server/src/entity/playlist.entity.ts @@ -0,0 +1,46 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from './user.entity'; +import { Music_Playlist } from './music_playlist.entity'; + +@Entity({ name: 'playlist' }) +export class Playlist extends BaseEntity { + @PrimaryGeneratedColumn() + playlist_Id: number; + + @Column() + playlist_title: string; + + @CreateDateColumn() + created_at: Date; + + @Column() + updated_at: Date; + + @ManyToOne(() => User, (user) => user.playlists) + @JoinColumn({ name: 'user_id' }) + user: User; + + @OneToMany(() => Music_Playlist, (music_playlist) => music_playlist.playlist) + music_playlist: Music_Playlist[]; + + static async getPlaylistsByUserId(userId: string): Promise { + return this.find({ + select: { playlist_Id: true, playlist_title: true }, + where: { + user: { user_id: userId }, + }, + order: { + updated_at: 'DESC', + }, + }); + } +} diff --git a/server/src/entity/user.entity.ts b/server/src/entity/user.entity.ts index accdb4e..e0d5ae6 100644 --- a/server/src/entity/user.entity.ts +++ b/server/src/entity/user.entity.ts @@ -4,7 +4,10 @@ import { CreateDateColumn, BaseEntity, PrimaryColumn, + OneToMany, } from 'typeorm'; +import { Playlist } from './playlist.entity'; +import { Music } from './music.entity'; @Entity({ name: 'user' }) export class User extends BaseEntity { @@ -22,4 +25,10 @@ export class User extends BaseEntity { @CreateDateColumn() created_at: Date; + + @OneToMany(() => Music, (music) => music.user) + musics: Music[]; + + @OneToMany(() => Playlist, (playlist) => playlist.user) + playlists: Playlist[]; } diff --git a/server/src/music/music.controller.ts b/server/src/music/music.controller.ts index 96ad3c4..412674a 100644 --- a/server/src/music/music.controller.ts +++ b/server/src/music/music.controller.ts @@ -3,12 +3,12 @@ import { Controller, Req, HttpCode, - HttpException, Post, Get, UseGuards, UsePipes, ValidationPipe, + Query, } from '@nestjs/common'; import { MusicService } from './music.service'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; @@ -28,27 +28,21 @@ export class MusicController { async upload( @Body() musicCreateDto: MusicCreateDto, @Req() req, - ): Promise<{ userId: string }> { - try { - const userId = req.user.user_id; - - this.musicService.createMusic(musicCreateDto, userId); - - return { userId }; - } catch (err) { - if (err instanceof HttpException) { - throw err; - } - throw new HttpException('WRONG TOKEN', HTTP_STATUS_CODE['WRONG_TOKEN']); - } + ): Promise<{ musicId: number }> { + const userId = req.user.user_id; + const savedMusicId: number = await this.musicService.createMusic( + musicCreateDto, + userId, + ); + return { musicId: savedMusicId }; } @Get('recent-uploads') @HttpCode(HTTP_STATUS_CODE.SUCCESS) - async getRecentMusics(): Promise<{ musics: Music[] }> { - const musics = await this.musicService.getRecentMusic(); + async getRecentMusics(): Promise { + const musics = this.musicService.getRecentMusic(); - return { musics }; + return musics; } @Get('genres') @@ -58,4 +52,15 @@ export class MusicController { return { genres: genreName }; } + + @Get('my-uploads') + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async getMyUploads( + @Req() req, + @Query('count') count: number, + ): Promise { + const userId: string = req.user.user_id; + return this.musicService.getMyUploads(userId, count); + } } diff --git a/server/src/music/music.service.ts b/server/src/music/music.service.ts index 1cfb3d5..c8dc255 100644 --- a/server/src/music/music.service.ts +++ b/server/src/music/music.service.ts @@ -1,14 +1,15 @@ -import { HttpException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { MusicCreateDto } from 'src/dto/musicCreate.dto'; import { Music } from 'src/entity/music.entity'; import { Genres } from 'src/constants'; +import { CatchyException } from 'src/config/catchyException'; +import { ERROR_CODE } from 'src/config/errorCode.enum'; @Injectable() export class MusicService { - //TODO: custom repository로 변경하기 constructor( @InjectRepository(Music) private musicRepository: Repository, ) {} @@ -21,14 +22,18 @@ export class MusicService { return false; } - createMusic(musicCreateDto: MusicCreateDto, user_id: string): void { + async createMusic( + musicCreateDto: MusicCreateDto, + user_id: string, + ): Promise { try { const { title, cover, file: musicFile, genre } = musicCreateDto; if (!this.isValidGenre(genre)) { - throw new HttpException( + throw new CatchyException( 'NOT_EXIST_GENRE', HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.NOT_EXIST_GENRE, ); } @@ -38,31 +43,45 @@ export class MusicService { musicFile, created_at: new Date(), genre, - user_id, + user: { user_id: user_id }, }); - this.musicRepository.save(newMusic); + const savedMusic: Music = await this.musicRepository.save(newMusic); + return savedMusic.musicId; } catch (err) { - if (err instanceof HttpException) { + if (err instanceof CatchyException) { throw err; } - throw new HttpException('SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR); + throw new CatchyException( + 'SERVER ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); } } async getRecentMusic(): Promise { try { - const musics = await this.musicRepository.find({ - order: { - created_at: 'DESC', - }, - take: 10, - }); + return Music.getRecentMusic(); + } catch { + throw new CatchyException( + 'SERVER ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } - return musics; + async getMyUploads(userId: string, count: number): Promise { + try { + return Music.getMusicListByUserId(userId, count); } catch { - throw new HttpException('SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR); + throw new CatchyException( + 'SERVER_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); } } } diff --git a/server/src/playlist/playlist.controller.ts b/server/src/playlist/playlist.controller.ts new file mode 100644 index 0000000..6c12600 --- /dev/null +++ b/server/src/playlist/playlist.controller.ts @@ -0,0 +1,81 @@ +import { + Body, + Controller, + Get, + HttpCode, + Param, + Patch, + Post, + Req, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { PlaylistService } from './playlist.service'; +import { AuthGuard } from '@nestjs/passport'; +import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; +import { PlaylistCreateDto } from 'src/dto/playlistCreate.dto'; +import { Playlist } from 'src/entity/playlist.entity'; +import { Music } from 'src/entity/music.entity'; +import { CatchyException } from 'src/config/catchyException'; +import { ERROR_CODE } from 'src/config/errorCode.enum'; + +@Controller('playlists') +export class PlaylistController { + constructor(private playlistService: PlaylistService) {} + + @Post() + @UseGuards(AuthGuard()) + @UsePipes(ValidationPipe) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async createPlaylist( + @Req() req, + @Body() playlistCreateDto: PlaylistCreateDto, + ): Promise<{ playlist_id: number }> { + const userId: string = req.user.user_id; + const playlistId: number = await this.playlistService.createPlaylist( + userId, + playlistCreateDto, + ); + return { playlist_id: playlistId }; + } + + @Post(':playlistId') + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async addMusicToPlaylist( + @Req() req, + @Param('playlistId') playlistId: number, + @Body('musicId') musicId: number, + ): Promise<{ music_playlist_id: number }> { + const userId: string = req.user.user_id; + const music_playlist_id: number = + await this.playlistService.addMusicToPlaylist( + userId, + playlistId, + musicId, + ); + return { music_playlist_id: music_playlist_id }; + } + + @Get() + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async getUserPlaylists(@Req() req): Promise { + const userId: string = req.user.user_id; + const playlists: Playlist[] = + await this.playlistService.getUserPlaylists(userId); + return playlists; + } + + @Get(':playlistId') + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async getPlaylistMusics( + @Req() req, + @Param('playlistId') playlistId: number, + ): Promise { + const userId: string = req.user.user_id; + return await this.playlistService.getPlaylistMusics(userId, playlistId); + } +} diff --git a/server/src/playlist/playlist.module.ts b/server/src/playlist/playlist.module.ts new file mode 100644 index 0000000..b458076 --- /dev/null +++ b/server/src/playlist/playlist.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { PlaylistService } from './playlist.service'; +import { PlaylistController } from './playlist.controller'; +import { Playlist } from 'src/entity/playlist.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from 'src/auth/auth.module'; +import { Music_Playlist } from 'src/entity/music_playlist.entity'; +import { Music } from 'src/entity/music.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Playlist, Music_Playlist, Music]), + AuthModule, + ], + controllers: [PlaylistController], + providers: [PlaylistService], + exports: [PlaylistService], +}) +export class PlaylistModule {} diff --git a/server/src/playlist/playlist.service.ts b/server/src/playlist/playlist.service.ts new file mode 100644 index 0000000..f383f62 --- /dev/null +++ b/server/src/playlist/playlist.service.ts @@ -0,0 +1,212 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CatchyException } from 'src/config/catchyException'; +import { ERROR_CODE } from 'src/config/errorCode.enum'; +import { PlaylistCreateDto } from 'src/dto/playlistCreate.dto'; +import { Music } from 'src/entity/music.entity'; +import { Music_Playlist } from 'src/entity/music_playlist.entity'; +import { Playlist } from 'src/entity/playlist.entity'; +import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; +import { Repository } from 'typeorm'; + +@Injectable() +export class PlaylistService { + constructor( + @InjectRepository(Playlist) + private playlistRepository: Repository, + @InjectRepository(Music_Playlist) + private music_playlistRepository: Repository, + @InjectRepository(Music) + private MusicRepository: Repository, + ) {} + + async createPlaylist( + userId: string, + playlistCreateDto: PlaylistCreateDto, + ): Promise { + try { + const title: string = playlistCreateDto.title; + const newPlaylist: Playlist = this.playlistRepository.create({ + playlist_title: title, + created_at: new Date(), + updated_at: new Date(), + user: { user_id: userId }, + }); + + const result: Playlist = await this.playlistRepository.save(newPlaylist); + const playlistId: number = result.playlist_Id; + return playlistId; + } catch { + throw new CatchyException( + 'SERVER_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } + + async addMusicToPlaylist( + userId: string, + playlistId: number, + musicId: number, + ): Promise { + // 사용자 플리가 있는지 확인 + if (!(await this.isExistPlaylistOnUser(playlistId, userId))) { + throw new CatchyException( + 'NOT_EXIST_PLAYLIST_ON_USER', + HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.NOT_EXIST_PLAYLIST_ON_USER, + ); + } + // 음악 있는지 확인 + if (!(await this.isExistMusic(musicId))) { + throw new CatchyException( + 'NOT_EXIST_MUSIC', + HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.NOT_EXIST_MUSIC, + ); + } + + // 이미 추가된 음악인지 확인 + if (await this.isAlreadyAdded(playlistId, musicId)) { + throw new CatchyException( + 'ALREADY_ADDED', + HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.ALREADY_ADDED, + ); + } + + // 관계테이블에 추가 + try { + const new_music_playlist: Music_Playlist = + this.music_playlistRepository.create({ + music: { musicId: musicId }, + playlist: { playlist_Id: playlistId }, + }); + + const result: Music_Playlist = + await this.music_playlistRepository.save(new_music_playlist); + this.setUpdatedAtNow(playlistId); + return result.music_playlist_id; + } catch { + throw new CatchyException( + 'SERVER_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } + + async isAlreadyAdded(playlistId: number, musicId: number): Promise { + try { + const count: number = await this.music_playlistRepository.countBy({ + music: { musicId: musicId }, + playlist: { playlist_Id: playlistId }, + }); + return count !== 0; + } catch { + throw new CatchyException( + 'SERVER_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } + + async isExistPlaylistOnUser( + playlistId: number, + userId: string, + ): Promise { + try { + const playlistCount: number = await this.playlistRepository.countBy({ + playlist_Id: playlistId, + user: { user_id: userId }, + }); + return playlistCount !== 0; + } catch { + throw new CatchyException( + 'SERVER_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } + + async isExistMusic(musicId: number): Promise { + try { + const musicCount: number = await this.MusicRepository.countBy({ + musicId: musicId, + }); + + return musicCount !== 0; + } catch { + throw new CatchyException( + 'SERVER_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } + + async setUpdatedAtNow(playlistId: number): Promise { + try { + const targetPlaylist: Playlist = await this.playlistRepository.findOne({ + where: { playlist_Id: playlistId }, + }); + targetPlaylist.updated_at = new Date(); + this.playlistRepository.save(targetPlaylist); + } catch { + throw new CatchyException( + 'SERVER_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } + + async getUserPlaylists(userId: string): Promise { + try { + return Playlist.getPlaylistsByUserId(userId); + } catch { + throw new CatchyException( + 'SERVER_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } + + async getPlaylistMusics( + userId: string, + playlistId: number, + ): Promise { + if (!(await this.isExistPlaylistOnUser(playlistId, userId))) { + throw new CatchyException( + 'NOT_EXIST_PLAYLIST_ON_USER', + HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.NOT_EXIST_PLAYLIST_ON_USER, + ); + } + try { + return Music_Playlist.getMusicListByPlaylistId(playlistId); + } catch { + throw new CatchyException( + 'SERVER_ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } + + async getRecentMusicsByUserId(userId: string) { + try { + return Music_Playlist.getRecentPlayedMusicByUserId(userId); + } catch { + throw new CatchyException( + 'SERVER ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVER_ERROR, + ); + } + } +} diff --git a/server/src/upload/upload.controller.ts b/server/src/upload/upload.controller.ts index 86f1357..7dd3f39 100644 --- a/server/src/upload/upload.controller.ts +++ b/server/src/upload/upload.controller.ts @@ -1,34 +1,77 @@ import { - Controller, Post, - UploadedFile, + Controller, + Get, + Req, + Query, + HttpCode, + UseGuards, UseInterceptors, + UploadedFile, ParseFilePipe, - MaxFileSizeValidator, FileTypeValidator, } from '@nestjs/common'; import { UploadService } from './upload.service'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; +import { AuthGuard } from '@nestjs/passport'; +import { v4 } from 'uuid'; import { FileInterceptor } from '@nestjs/platform-express'; -import { fileSize } from 'src/constants'; +import { CatchyException } from 'src/config/catchyException'; +import { ERROR_CODE } from 'src/config/errorCode.enum'; @Controller('upload') export class UploadController { constructor(private uploadService: UploadService) {} + @Get('uuid') + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + getMusicUUID(): { uuid: string } { + try { + return { uuid: v4() }; + } catch (err) { + console.log(err); + throw new CatchyException( + 'SERVER ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVER_ERROR, + ); + } + } + + @Get() + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async getSignedURL( + @Query('type') type: string, + @Query('uuid') uuid: string, + @Req() req, + ): Promise<{ signedUrl: string }> { + try { + const userId = req.user.user_id; + const id = type === 'user' ? userId : uuid; + + const signedUrl = await this.uploadService.getSignedURL(type, id); + + return { signedUrl }; + } catch (error) { + throw error; + } + } + @Post('/music') @UseInterceptors(FileInterceptor('file')) async uploadMusic( @UploadedFile( new ParseFilePipe({ validators: [ - new MaxFileSizeValidator({ maxSize: fileSize.MUSIC_FILE_LIMIT_SIZE }), + // new MaxFileSizeValidator({ maxSize: fileSize.MUSIC_FILE_LIMIT_SIZE }), new FileTypeValidator({ fileType: 'audio/mpeg' }), ], }), ) file: Express.Multer.File, - ): Promise<{ url: string }> { + ) { const { url } = await this.uploadService.uploadMusic(file); return { url }; } @@ -39,13 +82,13 @@ export class UploadController { @UploadedFile( new ParseFilePipe({ validators: [ - new MaxFileSizeValidator({ maxSize: fileSize.IMAGE_FILE_LIMIT_SIZE }), - new FileTypeValidator({ fileType: 'image/jpeg' }), + // new MaxFileSizeValidator({ maxSize: fileSize.IMAGE_FILE_LIMIT_SIZE }), + new FileTypeValidator({ fileType: 'image/png' }), ], }), ) file: Express.Multer.File, - ): Promise<{ url: string }> { + ) { const { url } = await this.uploadService.uploadImage(file); return { url }; } diff --git a/server/src/upload/upload.module.ts b/server/src/upload/upload.module.ts index 6fc1701..2fed54e 100644 --- a/server/src/upload/upload.module.ts +++ b/server/src/upload/upload.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { UploadController } from './upload.controller'; import { UploadService } from './upload.service'; import { NcloudConfigService } from 'src/config/ncloud.config'; +import { AuthModule } from 'src/auth/auth.module'; @Module({ + imports: [AuthModule], controllers: [UploadController], providers: [UploadService, NcloudConfigService], }) diff --git a/server/src/upload/upload.service.ts b/server/src/upload/upload.service.ts index 4b2edd1..1f5be0f 100644 --- a/server/src/upload/upload.service.ts +++ b/server/src/upload/upload.service.ts @@ -1,30 +1,80 @@ -import { HttpException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; import { NcloudConfigService } from './../config/ncloud.config'; import { S3 } from 'aws-sdk'; +import { keyFlags, keyHandler } from './../constants'; import { Readable } from 'stream'; +import { CatchyException } from 'src/config/catchyException'; +import { ERROR_CODE } from 'src/config/errorCode.enum'; @Injectable() export class UploadService { - private readonly objectStorage: S3; - + private objectStorage: S3; constructor(private readonly nCloudConfigService: NcloudConfigService) { this.objectStorage = nCloudConfigService.createObjectStorageOption(); } + private isValidFlag(flag: string): boolean { + if (keyFlags.includes(flag)) return true; + + return false; + } + + private isValidUUIDPattern(uuid: string): boolean { + const uuidPattern = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/; + + if (uuidPattern.test(uuid)) return true; + + return false; + } + + async getSignedURL(type: string, uuid: string): Promise { + try { + if (!this.isValidUUIDPattern(uuid) || !this.isValidFlag(type)) + throw new CatchyException( + 'INVALID_INPUT_VALUE', + HTTP_STATUS_CODE.BAD_REQUEST, + ERROR_CODE.INVALID_INPUT_VALUE, + ); + + const keyPath = keyHandler[type](uuid); + + return await this.objectStorage.getSignedUrlPromise('putObject', { + Bucket: 'catchy-tape-bucket2', + Key: `${keyPath}`, + Expires: 600, + ACL: 'public-read', + }); + } catch (error) { + if (error instanceof CatchyException) throw error; + + throw new CatchyException( + 'SERVER ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); + } + } + async uploadMusic(file: Express.Multer.File): Promise<{ url: string }> { try { const uploadResult = await this.objectStorage .upload({ Bucket: 'catchy-tape-bucket2', - Key: `music/original/${file.originalname}`, + Key: `music/example/${file.originalname}`, Body: Readable.from(file.buffer), + ACL: 'public-read', }) .promise(); return { url: uploadResult.Location }; } catch { - throw new HttpException('SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR); + throw new CatchyException( + 'SERVER ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); } } @@ -33,14 +83,19 @@ export class UploadService { const uploadResult = await this.objectStorage .upload({ Bucket: 'catchy-tape-bucket2', - Key: `image/${file.originalname}`, + Key: `image/example/${file.originalname}`, Body: Readable.from(file.buffer), + ACL: 'public-read', }) .promise(); return { url: uploadResult.Location }; } catch { - throw new HttpException('SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR); + throw new CatchyException( + 'SERVER ERROR', + HTTP_STATUS_CODE.SERVER_ERROR, + ERROR_CODE.SERVICE_ERROR, + ); } } } diff --git a/server/src/user/user.controller.spec.ts b/server/src/user/user.controller.spec.ts index 7057a1a..275f626 100644 --- a/server/src/user/user.controller.spec.ts +++ b/server/src/user/user.controller.spec.ts @@ -1,15 +1,49 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UserController } from './user.controller'; +import { UserService } from './user.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from 'src/entity/user.entity'; +import { Repository } from 'typeorm'; +import { PlaylistService } from 'src/playlist/playlist.service'; +import { Music } from 'src/entity/music.entity'; +import { Music_Playlist } from 'src/entity/music_playlist.entity'; +import { Playlist } from 'src/entity/playlist.entity'; describe('UserController', () => { let controller: UserController; + let userService: UserService; + let playlistService: PlaylistService; + let userRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [UserController], + providers: [ + UserService, + PlaylistService, + { + provide: getRepositoryToken(User), + useClass: Repository, + }, + { + provide: getRepositoryToken(Playlist), + useClass: Repository, + }, + { + provide: getRepositoryToken(Music), + useClass: Repository, + }, + { + provide: getRepositoryToken(Music_Playlist), + useClass: Repository, + }, + ], }).compile(); controller = module.get(UserController); + userService = module.get(UserService); + playlistService = module.get(PlaylistService); + userRepository = module.get(getRepositoryToken(User)); }); it('should be defined', () => { diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index f44b993..13f95b9 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -1,12 +1,17 @@ import { Controller, Get, + Req, HttpCode, - HttpException, Param, + UseGuards, } from '@nestjs/common'; import { UserService } from './user.service'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; +import { AuthGuard } from '@nestjs/passport'; +import { Music } from 'src/entity/music.entity'; +import { CatchyException } from 'src/config/catchyException'; +import { ERROR_CODE } from 'src/config/errorCode.enum'; @Controller('users') export class UserController { @@ -18,12 +23,24 @@ export class UserController { @Param('name') name: string, ): Promise<{ nickname: string }> { if (await this.userService.isDuplicatedUserEmail(name)) { - throw new HttpException( + throw new CatchyException( 'DUPLICATED_NICKNAME', HTTP_STATUS_CODE.DUPLICATED_NICKNAME, + ERROR_CODE.DUPLICATED_NICKNAME ); } return { nickname: name }; } + + @Get('recent-played') + @UseGuards(AuthGuard()) + @HttpCode(HTTP_STATUS_CODE.SUCCESS) + async getUserRecentPlayedMusics(@Req() req): Promise { + const userId = req.user.userId; + const userMusicData = + await this.userService.getRecentPlayedMusicByUserId(userId); + + return userMusicData; + } } diff --git a/server/src/user/user.module.ts b/server/src/user/user.module.ts index 6570616..3e27e0e 100644 --- a/server/src/user/user.module.ts +++ b/server/src/user/user.module.ts @@ -4,10 +4,17 @@ import { UserService } from './user.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from 'src/entity/user.entity'; import { AuthModule } from 'src/auth/auth.module'; +import { PlaylistService } from 'src/playlist/playlist.service'; +import { Playlist } from 'src/entity/playlist.entity'; +import { Music_Playlist } from 'src/entity/music_playlist.entity'; +import { Music } from 'src/entity/music.entity'; @Module({ - imports: [TypeOrmModule.forFeature([User]), AuthModule], + imports: [ + TypeOrmModule.forFeature([User, Playlist, Music_Playlist, Music]), + AuthModule, + ], controllers: [UserController], - providers: [UserService], + providers: [UserService, PlaylistService], }) export class UserModule {} diff --git a/server/src/user/user.service.spec.ts b/server/src/user/user.service.spec.ts index 873de8a..93b2135 100644 --- a/server/src/user/user.service.spec.ts +++ b/server/src/user/user.service.spec.ts @@ -1,15 +1,45 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UserService } from './user.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../entity/user.entity'; +import { Playlist } from 'src/entity/playlist.entity'; +import { Music } from 'src/entity/music.entity'; +import { Music_Playlist } from 'src/entity/music_playlist.entity'; +import { PlaylistService } from 'src/playlist/playlist.service'; describe('UserService', () => { let service: UserService; + let playlistService: PlaylistService; + let userRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [UserService], + providers: [ + UserService, + PlaylistService, + { + provide: getRepositoryToken(User), + useClass: Repository, + }, + { + provide: getRepositoryToken(Playlist), + useClass: Repository, + }, + { + provide: getRepositoryToken(Music), + useClass: Repository, + }, + { + provide: getRepositoryToken(Music_Playlist), + useClass: Repository, + }, + ], }).compile(); service = module.get(UserService); + playlistService = module.get(PlaylistService); + userRepository = module.get(getRepositoryToken(User)); }); it('should be defined', () => { diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index badb6ee..1dc6504 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,14 +1,18 @@ -import { HttpException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { HTTP_STATUS_CODE } from 'src/httpStatusCode.enum'; import { User } from 'src/entity/user.entity'; +import { Music } from 'src/entity/music.entity'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; +import { PlaylistService } from 'src/playlist/playlist.service'; +import { CatchyException } from 'src/config/catchyException'; +import { ERROR_CODE } from 'src/config/errorCode.enum'; @Injectable() export class UserService { - //TODO: custom repository로 변경하기 constructor( @InjectRepository(User) private userRepository: Repository, + private playlistService: PlaylistService, ) {} async isDuplicatedUserEmail(userNickname: string): Promise { @@ -23,7 +27,11 @@ export class UserService { return false; } catch { - throw new HttpException('SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR); + throw new CatchyException('SERVER ERROR', HTTP_STATUS_CODE.SERVER_ERROR, ERROR_CODE.SERVICE_ERROR); } } + + async getRecentPlayedMusicByUserId(userId: string): Promise { + return await this.playlistService.getRecentMusicsByUserId(userId); + } }