-
Notifications
You must be signed in to change notification settings - Fork 0
서버 404 에러로 인한 무한 삭제 요청 문제를 해결한다 #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,6 @@ import android.content.Context | |
| import android.net.Uri | ||
| import android.util.Base64.decode | ||
| import android.util.Log | ||
| import androidx.core.net.toUri | ||
| import androidx.work.WorkInfo | ||
| import androidx.work.WorkManager | ||
| import com.example.metasearch.core.common.utils.toFile | ||
|
|
@@ -21,8 +20,11 @@ import com.example.metasearch.core.room.api.dao.AnalyzedImageDao | |
| import com.example.metasearch.core.room.api.entity.AnalyzedImageEntity | ||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.async | ||
| import kotlinx.coroutines.awaitAll | ||
| import kotlinx.coroutines.flow.Flow | ||
| import kotlinx.coroutines.flow.map | ||
| import kotlinx.coroutines.supervisorScope | ||
| import kotlinx.coroutines.withContext | ||
| import okhttp3.MediaType.Companion.toMediaTypeOrNull | ||
| import okhttp3.MultipartBody | ||
|
|
@@ -73,17 +75,15 @@ class ImageAnalysisRepositoryImpl @Inject constructor( | |
| Log.d(tag, "7. 추가할 이미지 수: ${addUris.size}") | ||
|
|
||
| if (addUris.isNotEmpty()) { | ||
| val allSuccessfulPaths = mutableListOf<String>() | ||
| val allSuccessfulData = mutableListOf<Pair<String, String>>() | ||
|
|
||
| addUris.chunked(chunkSize).forEachIndexed { index, chunk -> | ||
| val successfulInChunk = uploadOnlyImageChunk(context, index, chunk, dbName) | ||
| allSuccessfulPaths.addAll(successfulInChunk) | ||
|
|
||
| if (successfulInChunk.size != chunk.size) return@forEachIndexed | ||
| addUris.chunked(chunkSize).forEach { chunk -> | ||
| val successfulInChunk = uploadOnlyImageChunk(context, chunk, dbName) | ||
| allSuccessfulData.addAll(successfulInChunk) | ||
| } | ||
|
|
||
| if (allSuccessfulPaths.isNotEmpty()) { | ||
| processAnalysisFinish(allSuccessfulPaths, dbName) | ||
| if (allSuccessfulData.isNotEmpty()) { | ||
| processAnalysisFinish(allSuccessfulData, dbName) | ||
| } | ||
| } else { | ||
| Log.d(tag, "8. 추가할 이미지가 없어 종료함") | ||
|
|
@@ -98,65 +98,52 @@ class ImageAnalysisRepositoryImpl @Inject constructor( | |
| deletePaths.forEachIndexed { index, pathString -> | ||
| Log.d(tag, "이미지 삭제 중 (${index + 1}/${deletePaths.size}): $pathString") | ||
|
|
||
| val uri = pathString.toUri() | ||
| val originalFileName = galleryRepository.getFileName(uri) | ||
| Log.d(tag, "Web 서버로 보낼 파일명: $originalFileName") | ||
|
|
||
| val fileName = pathString.substringAfterLast('/') | ||
| Log.d(tag, "AI 서버로 보낼 파일명: $fileName") | ||
| val savedFileName = analyzedImageDao.getFileNameByPath(pathString) | ||
| Log.d(tag, "서버로 보낼 파일명: $savedFileName") | ||
|
|
||
| val finalFileName = if (originalFileName != null) { | ||
| "$originalFileName.jpg" | ||
| } else { | ||
| "$fileName.jpg" | ||
| val webResult = runCatching { | ||
| webService.uploadWebDeleteImage(DeleteImageRequest(dbName, savedFileName ?: "unknown.jpg")) | ||
| } | ||
|
|
||
| val webResponse = runCatching { webService.uploadWebDeleteImage(DeleteImageRequest(dbName, finalFileName)) } | ||
| webResponse.onFailure { | ||
| Log.e(tag, "Web 삭제 실패 원인: ${it.message}") | ||
| val aiPart = MultipartBody.Part.createFormData("deleteImage", savedFileName, "".toRequestBody()) | ||
| val aiResult = runCatching { | ||
| aiService.uploadDeleteImage(aiPart, dbName.toRequestBody()) | ||
| } | ||
|
|
||
| val aiPart = MultipartBody.Part.createFormData("deleteImage", fileName, "".toRequestBody()) | ||
| val aiSuccess = runCatching { aiService.uploadDeleteImage(aiPart, dbName.toRequestBody()) }.isSuccess | ||
|
|
||
| if (webResponse.isSuccess && aiSuccess) { | ||
| if (webResult.isSuccess && aiResult.isSuccess) { | ||
| analyzedImageDao.deletePath(pathString) | ||
| Log.d(tag, "삭제 성공: $pathString") | ||
| } else { | ||
| Log.e(tag, "삭제 실패 (Web: $webResponse, AI: $aiSuccess): $pathString") | ||
| Log.e(tag, "삭제 실패 (Web: $webResult, AI: $aiResult): $pathString") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private suspend fun uploadOnlyImageChunk(context: Context, index: Int, chunk: List<Uri>, dbName: String): List<String> { | ||
| Log.d(tag, "청크 전송 중: ${index + 1}번째 (${chunk.size}개)") | ||
| val successfulPaths = mutableListOf<String>() | ||
|
|
||
| for (uri in chunk) { | ||
| if (uploadSingleImage(context, uri, dbName)) { | ||
| successfulPaths.add(uri.toString()) | ||
| } else { | ||
| Log.e(tag, "업로드 실패: $uri") | ||
| break | ||
| private suspend fun uploadOnlyImageChunk( | ||
| context: Context, | ||
| chunk: List<Uri>, | ||
| dbName: String, | ||
| ): List<Pair<String, String>> = supervisorScope { | ||
| chunk.map { uri -> | ||
| async { | ||
| val fileName = galleryRepository.getFileName(uri) ?: "unknown.jpg" | ||
| if (uploadSingleImage(context, uri, dbName, fileName)) uri.toString() to fileName else null | ||
| } | ||
| } | ||
| return successfulPaths | ||
| }.awaitAll().filterNotNull() | ||
| } | ||
|
|
||
| private suspend fun uploadSingleImage(context: Context, uri: Uri, dbName: String): Boolean { | ||
| private suspend fun uploadSingleImage(context: Context, uri: Uri, dbName: String, fileName: String): Boolean { | ||
| val tempFile = uri.toFile(context) | ||
|
|
||
| return try { | ||
| val originalFileName = galleryRepository.getFileName(uri) ?: "unknown.jpg" | ||
|
|
||
| val webImagePart = createMultipartBodyPartFromUri(context, "image", uri, originalFileName) | ||
| val aiImagePart = createMultipartBodyPartFromUri(context, "addImage", uri, originalFileName) | ||
| val webImagePart = createMultipartBodyPartFromUri(context, "image", uri, fileName) | ||
| val aiImagePart = createMultipartBodyPartFromUri(context, "addImage", uri, fileName) | ||
| val dbNameBody = dbName.toRequestBody("text/plain".toMediaTypeOrNull()) | ||
|
|
||
| webService.uploadWebAddImage(webImagePart, dbName) | ||
| aiService.uploadAddImage(aiImagePart, dbNameBody) | ||
|
|
||
| Log.d(tag, "서버 전송 성공 (파일명: $originalFileName): $uri") | ||
| Log.d(tag, "서버 전송 성공 (파일명: $fileName): $uri") | ||
| true | ||
| } catch (e: Exception) { | ||
| Log.e(tag, "서버 전송 실패 원인: ${e.message}", e) | ||
|
|
@@ -166,7 +153,7 @@ class ImageAnalysisRepositoryImpl @Inject constructor( | |
| } | ||
| } | ||
|
|
||
| private suspend fun processAnalysisFinish(successfulPaths: List<String>, dbName: String) { | ||
| private suspend fun processAnalysisFinish(successfulPaths: List<Pair<String, String>>, dbName: String) { | ||
| val lastIndex = personIndexDataSource.getLastPersonIndex() | ||
|
|
||
| Log.d(tag, lastIndex.toString()) | ||
|
|
@@ -182,31 +169,38 @@ class ImageAnalysisRepositoryImpl @Inject constructor( | |
| .mapNotNull { it.imageName?.filter { c -> c.isDigit() }?.toIntOrNull() } | ||
| .maxOrNull() ?: lastIndex | ||
|
|
||
| if (newMax > lastIndex) { | ||
| personIndexDataSource.setLastPersonIndex(newMax) | ||
| } | ||
|
|
||
| response.images.forEach { person -> | ||
| runCatching { | ||
| if (person.isFaceExit && person.imageName != null && person.imageBytes != null) { | ||
| val decodedBytes = decode(person.imageBytes, android.util.Base64.DEFAULT) | ||
|
|
||
| val existingPersonId = personRepository.getPersonIdByImageName(person.imageName!!) | ||
|
|
||
| if (existingPersonId != null) { | ||
| Log.d(tag, "기존 인물 매핑 성공: ${person.imageName} -> ID:$existingPersonId") | ||
| personRepository.addFaceToExistingPerson(existingPersonId, person.imageName!!, decodedBytes) | ||
| analyzedImageDao.runInTransaction { | ||
| if (newMax > lastIndex) { | ||
| personIndexDataSource.setLastPersonIndex(newMax) | ||
| } | ||
|
|
||
| response.images.forEach { person -> | ||
| runCatching { | ||
| if (person.isFaceExit && person.imageName != null && person.imageBytes != null) { | ||
| val decodedBytes = decode(person.imageBytes, android.util.Base64.DEFAULT) | ||
|
|
||
| val existingPersonId = personRepository.getPersonIdByImageName(person.imageName!!) | ||
|
|
||
| if (existingPersonId != null) { | ||
| Log.d(tag, "기존 인물 매핑 성공: ${person.imageName} -> ID:$existingPersonId") | ||
| personRepository.addFaceToExistingPerson(existingPersonId, person.imageName!!, decodedBytes) | ||
| } else { | ||
| Log.d(tag, "새로운 인물 생성: ${person.imageName}") | ||
| personRepository.addAnalyzedPerson(person.imageName!!, decodedBytes) | ||
| } | ||
| } else { | ||
| Log.d(tag, "새로운 인물 생성: ${person.imageName}") | ||
| personRepository.addAnalyzedPerson(person.imageName!!, decodedBytes) | ||
| Log.w(tag, "저장 스킵됨: 얼굴없음(${!person.isFaceExit}) 또는 데이터가 null임") | ||
| } | ||
| } else { | ||
| Log.w(tag, "저장 스킵됨: 얼굴없음(${!person.isFaceExit}) 또는 데이터가 null임") | ||
| } | ||
| }.onFailure { e -> Log.e(tag, "인물 개별 저장 실패: ${person.imageName}", e) } | ||
| }.onFailure { e -> Log.e(tag, "인물 개별 저장 실패: ${person.imageName}", e) } | ||
| } | ||
|
|
||
| val entries = successfulPaths.map { (path, fileName) -> | ||
| AnalyzedImageEntity(imagePath = path, fileName = fileName) | ||
| } | ||
| analyzedImageDao.insertAllPaths(entries) | ||
| } | ||
|
Comment on lines
+172
to
201
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Transaction will not work as implemented. The
This issue is addressed in the review comment for 🤖 Prompt for AI Agents |
||
| successfulPaths.forEach { analyzedImageDao.insertPath(AnalyzedImageEntity(imagePath = it)) } | ||
| Log.d(tag, "전체 분석 및 DB 반영 완료") | ||
|
|
||
| Log.d(tag, "트랜잭션 완료: 인물 및 경로(${successfulPaths.size})개 저장 성공") | ||
| } | ||
| .onFailure { Log.e(tag, "최종 finish 실패: ${it.message}") } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,16 +4,28 @@ import androidx.room.Dao | |
| import androidx.room.Insert | ||
| import androidx.room.OnConflictStrategy | ||
| import androidx.room.Query | ||
| import androidx.room.Transaction | ||
| import com.example.metasearch.core.room.api.entity.AnalyzedImageEntity | ||
|
|
||
| @Dao | ||
| interface AnalyzedImageDao { | ||
| @Insert(onConflict = OnConflictStrategy.REPLACE) | ||
| suspend fun insertPath(path: AnalyzedImageEntity): Long | ||
|
|
||
| @Insert(onConflict = OnConflictStrategy.REPLACE) | ||
| suspend fun insertAllPaths(entities: List<AnalyzedImageEntity>) | ||
|
|
||
| @Query("DELETE FROM analyzed_images WHERE image_path = :path") | ||
| suspend fun deletePath(path: String): Int | ||
|
|
||
| @Query("SELECT image_path FROM analyzed_images") | ||
| suspend fun getAllAnalyzedPaths(): List<String> | ||
|
|
||
| @Query("SELECT file_name FROM analyzed_images WHERE image_path = :path LIMIT 1") | ||
| suspend fun getFileNameByPath(path: String): String? | ||
|
|
||
| @Transaction | ||
| suspend fun runInTransaction(action: suspend () -> Unit) { | ||
| action() | ||
| } | ||
|
Comment on lines
+27
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Room @transaction does not wrap arbitrary lambda blocks. The To execute a transaction with multiple operations, use 🔎 Recommended fix using RoomDatabase.withTransactionRemove this method from the DAO: - @Transaction
- suspend fun runInTransaction(action: suspend () -> Unit) {
- action()
- }In the repository (ImageAnalysisRepositoryImpl.kt), inject the AppDatabase instance and use: // Add to constructor
private val database: AppDatabase
// In processAnalysisFinish
database.withTransaction {
// existing transaction logic
}Or define a proper
🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,7 +25,9 @@ object DatabaseModule { | |
| context, | ||
| AppDatabase::class.java, | ||
| DATABASE_NAME, | ||
| ).build() | ||
| ) | ||
| .addMigrations(AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_3_4) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Search for MIGRATION_2_3 and migration definitions
rg -n "MIGRATION_2_3|MIGRATION_[0-9]_[0-9]" --type kotlin -A 2Repository: komodgn/meta-android Length of output: 1473 🏁 Script executed: # Find AppDatabase file to check all migration definitions
fd -e "kt" "AppDatabase" --exec grep -l "MIGRATION" {} \;Repository: komodgn/meta-android Length of output: 156 🏁 Script executed: # Get the DatabaseModule.kt file context around line 29
fd -e "kt" "DatabaseModule" --exec cat -n {} \;Repository: komodgn/meta-android Length of output: 1722 🏁 Script executed: # Get AppDatabase class definition to see version annotation
cat -n core/room/impl/src/main/java/com/example/metasearch/core/room/impl/database/AppDatabase.kt | head -40Repository: komodgn/meta-android Length of output: 1748 🏁 Script executed: # Search for any reference to database version or migration fallback strategies
rg -n "version|Schema|fallbackToDestructiveMigration" --type kotlin core/room/impl/src/main/java/com/example/metasearch/core/room/impl/database/Repository: komodgn/meta-android Length of output: 295 Add missing MIGRATION_2_3 to complete the migration chain. The database schema version is 4, but the migration path is broken: MIGRATION_1_2 (1→2) and MIGRATION_3_4 (3→4) are defined, but MIGRATION_2_3 is missing. Any production database at version 2 will fail to migrate to version 4. Define MIGRATION_2_3 or document why the gap exists and that no databases are at version 2. 🤖 Prompt for AI Agents |
||
| .build() | ||
| } | ||
|
|
||
| @Provides | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: komodgn/meta-android
Length of output: 46
🏁 Script executed:
Repository: komodgn/meta-android
Length of output: 90
🏁 Script executed:
Repository: komodgn/meta-android
Length of output: 90
🏁 Script executed:
Repository: komodgn/meta-android
Length of output: 3032
🏁 Script executed:
Repository: komodgn/meta-android
Length of output: 7521
🏁 Script executed:
Repository: komodgn/meta-android
Length of output: 2537
getFileNamecan legitimately return null—ensure null uploads don't accumulate.The implementation queries
MediaStore.Images.Media.DISPLAY_NAME, which can return null if the query fails or the URI yields no results (e.g., invalid URI, deleted file, missing display name, or permission issues). The fallback to"unknown.jpg"is a reasonable defensive mechanism, and this pattern is consistently applied elsewhere in the code (e.g., line 105 in the delete operation). However, if null returns occur frequently in normal operation with valid URIs, files would be uploaded without proper names, defeating the purpose of the fix. Monitor logs to ensure this fallback is rare and investigate if it occurs unexpectedly.🤖 Prompt for AI Agents