From 136b9f3b68730091e4fa60616cade9a4ecf50222 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 21 Aug 2026 21:32:33 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20Room=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=EC=99=80=20=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=BA=90=EC=8B=9C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=B6=94=EA=B0=80=20#581=20#583?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 오프라인 캐시의 저장층입니다. 아직 아무도 이 DB를 주입받지 않습니다. - 모든 테이블에 memberId 컬럼을 두고 조회에 계정 조건을 겁니다 - 목록과 상세는 테이블을 나눴습니다. 한 테이블에 담으면 목록 응답을 저장할 때 detail과 location이, 상세를 저장할 때 date와 thumbnailUrls가 서로 덮여 사라집니다 - 목록의 date는 조회할 때 서버에 넘긴 날짜입니다. startedAt에서 파생시키면 자정을 넘긴 기록이 서버가 묶어준 날과 어긋납니다 - sortOrder는 서버 응답 순서입니다. SQL이 순서를 보장하지 않습니다 - 날짜는 ISO 문자열로 담아 사전순 비교가 시간순 비교가 되게 했고, 사진 목록은 JSON 컬럼입니다 --- data/build.gradle.kts | 5 + .../1.json | 196 ++++++++++++++++++ .../project200/data/local/UndabangDatabase.kt | 36 ++++ .../data/local/UndabangTypeConverters.kt | 55 +++++ .../data/local/dao/ExerciseCountDao.kt | 36 ++++ .../data/local/dao/ExerciseRecordDao.kt | 53 +++++ .../data/local/entity/CachedPicture.kt | 17 ++ .../data/local/entity/ExerciseCountEntity.kt | 21 ++ .../local/entity/ExerciseListItemEntity.kt | 32 +++ .../entity/ExerciseRecordDetailEntity.kt | 28 +++ 10 files changed, 479 insertions(+) create mode 100644 data/schemas/com.project200.data.local.UndabangDatabase/1.json create mode 100644 data/src/main/java/com/project200/data/local/UndabangDatabase.kt create mode 100644 data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt create mode 100644 data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt create mode 100644 data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt create mode 100644 data/src/main/java/com/project200/data/local/entity/CachedPicture.kt create mode 100644 data/src/main/java/com/project200/data/local/entity/ExerciseCountEntity.kt create mode 100644 data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt create mode 100644 data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 7634034f..56f8abef 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -20,6 +20,11 @@ android { } } +// Room 스키마를 파일로 남겨 마이그레이션 검증에 씁니다 +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + dependencies { implementation(projects.domain) implementation(projects.common) diff --git a/data/schemas/com.project200.data.local.UndabangDatabase/1.json b/data/schemas/com.project200.data.local.UndabangDatabase/1.json new file mode 100644 index 00000000..3c3248cb --- /dev/null +++ b/data/schemas/com.project200.data.local.UndabangDatabase/1.json @@ -0,0 +1,196 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "953d4eadbefa6d56d23af6cd5623fb2d", + "entities": [ + { + "tableName": "exercise_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `date` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`memberId`, `date`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "date" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exercise_list_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `recordId` INTEGER NOT NULL, `date` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `title` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `thumbnailUrls` TEXT, PRIMARY KEY(`memberId`, `recordId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "personalType", + "columnName": "personalType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrls", + "columnName": "thumbnailUrls", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "recordId" + ] + }, + "indices": [ + { + "name": "index_exercise_list_item_memberId_date", + "unique": false, + "columnNames": [ + "memberId", + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_list_item_memberId_date` ON `${TABLE_NAME}` (`memberId`, `date`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exercise_record_detail", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `recordId` INTEGER NOT NULL, `title` TEXT NOT NULL, `detail` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `location` TEXT NOT NULL, `pictures` TEXT, PRIMARY KEY(`memberId`, `recordId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "detail", + "columnName": "detail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "personalType", + "columnName": "personalType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pictures", + "columnName": "pictures", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "recordId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '953d4eadbefa6d56d23af6cd5623fb2d')" + ] + } +} \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/local/UndabangDatabase.kt b/data/src/main/java/com/project200/data/local/UndabangDatabase.kt new file mode 100644 index 00000000..c701526f --- /dev/null +++ b/data/src/main/java/com/project200/data/local/UndabangDatabase.kt @@ -0,0 +1,36 @@ +package com.project200.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.project200.data.local.dao.ExerciseCountDao +import com.project200.data.local.dao.ExerciseRecordDao +import com.project200.data.local.entity.ExerciseCountEntity +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.ExerciseRecordDetailEntity + +/** + * 오프라인 캐시용 로컬 데이터베이스입니다. + * + * 모든 테이블은 memberId 컬럼을 두고 조회에도 계정 조건을 겁니다 + * 계정이 바뀌면 이전 사용자의 행이 보이면 안 되기 때문입니다 + */ +@Database( + entities = [ + ExerciseCountEntity::class, + ExerciseListItemEntity::class, + ExerciseRecordDetailEntity::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters(UndabangTypeConverters::class) +abstract class UndabangDatabase : RoomDatabase() { + abstract fun exerciseCountDao(): ExerciseCountDao + + abstract fun exerciseRecordDao(): ExerciseRecordDao + + companion object { + const val DATABASE_NAME = "undabang.db" + } +} diff --git a/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt b/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt new file mode 100644 index 00000000..d1246423 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt @@ -0,0 +1,55 @@ +package com.project200.data.local + +import androidx.room.ProvidedTypeConverter +import androidx.room.TypeConverter +import com.project200.data.local.entity.CachedPicture +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * SQLite가 아는 타입은 INTEGER, REAL, TEXT, BLOB 넷뿐이라 나머지는 여기서 문자열로 바꿉니다. + * + * 날짜는 ISO 문자열로 담습니다. 사전순 비교가 곧 시간순 비교라서 + * BETWEEN과 ORDER BY를 컬럼에 그대로 걸 수 있습니다 + * + * 사진 목록은 JSON 문자열입니다. 사진을 따로 조회할 일이 없고 항상 기록과 함께 읽히며, + * 리스트 순서가 그대로 보존됩니다 + */ +@ProvidedTypeConverter +class UndabangTypeConverters(moshi: Moshi) { + private val stringListAdapter = + moshi.adapter>( + Types.newParameterizedType(List::class.java, String::class.java), + ) + + private val pictureListAdapter = + moshi.adapter>( + Types.newParameterizedType(List::class.java, CachedPicture::class.java), + ) + + @TypeConverter + fun fromLocalDate(value: LocalDate?): String? = value?.toString() + + @TypeConverter + fun toLocalDate(value: String?): LocalDate? = value?.let(LocalDate::parse) + + @TypeConverter + fun fromLocalDateTime(value: LocalDateTime?): String? = value?.toString() + + @TypeConverter + fun toLocalDateTime(value: String?): LocalDateTime? = value?.let(LocalDateTime::parse) + + @TypeConverter + fun fromStringList(value: List?): String? = value?.let(stringListAdapter::toJson) + + @TypeConverter + fun toStringList(value: String?): List? = value?.let(stringListAdapter::fromJson) + + @TypeConverter + fun fromPictureList(value: List?): String? = value?.let(pictureListAdapter::toJson) + + @TypeConverter + fun toPictureList(value: String?): List? = value?.let(pictureListAdapter::fromJson) +} diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt new file mode 100644 index 00000000..8b051432 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt @@ -0,0 +1,36 @@ +package com.project200.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.project200.data.local.entity.ExerciseCountEntity +import kotlinx.coroutines.flow.Flow +import java.time.LocalDate + +@Dao +interface ExerciseCountDao { + @Query( + "SELECT * FROM exercise_count " + + "WHERE memberId = :memberId AND date BETWEEN :startDate AND :endDate " + + "ORDER BY date", + ) + fun observeRange( + memberId: String, + startDate: LocalDate, + endDate: LocalDate, + ): Flow> + + @Upsert + suspend fun upsertAll(counts: List) + + /** 서버 응답에는 운동한 날만 들어 있어서, 0건이 된 날을 지우려면 구간을 먼저 비워야 합니다 */ + @Query( + "DELETE FROM exercise_count " + + "WHERE memberId = :memberId AND date BETWEEN :startDate AND :endDate", + ) + suspend fun deleteRange( + memberId: String, + startDate: LocalDate, + endDate: LocalDate, + ) +} diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt new file mode 100644 index 00000000..5ed6fdf4 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt @@ -0,0 +1,53 @@ +package com.project200.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.ExerciseRecordDetailEntity +import kotlinx.coroutines.flow.Flow +import java.time.LocalDate + +@Dao +interface ExerciseRecordDao { + @Query( + "SELECT * FROM exercise_list_item " + + "WHERE memberId = :memberId AND date = :date " + + "ORDER BY sortOrder", + ) + fun observeListByDate( + memberId: String, + date: LocalDate, + ): Flow> + + @Upsert + suspend fun upsertListItems(items: List) + + /** 서버에서 지워진 기록이 남지 않도록 그날 목록을 갈아끼울 때 씁니다 */ + @Query("DELETE FROM exercise_list_item WHERE memberId = :memberId AND date = :date") + suspend fun deleteListByDate( + memberId: String, + date: LocalDate, + ) + + @Query("SELECT * FROM exercise_record_detail WHERE memberId = :memberId AND recordId = :recordId") + fun observeDetail( + memberId: String, + recordId: Long, + ): Flow + + @Upsert + suspend fun upsertDetail(detail: ExerciseRecordDetailEntity) + + @Query("DELETE FROM exercise_record_detail WHERE memberId = :memberId AND recordId = :recordId") + suspend fun deleteDetail( + memberId: String, + recordId: Long, + ) + + @Query("DELETE FROM exercise_list_item WHERE memberId = :memberId AND recordId = :recordId") + suspend fun deleteListItem( + memberId: String, + recordId: Long, + ) +} diff --git a/data/src/main/java/com/project200/data/local/entity/CachedPicture.kt b/data/src/main/java/com/project200/data/local/entity/CachedPicture.kt new file mode 100644 index 00000000..84330782 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/CachedPicture.kt @@ -0,0 +1,17 @@ +package com.project200.data.local.entity + +import com.squareup.moshi.JsonClass + +/** + * 상세 화면에 뿌릴 사진 한 장입니다. + * + * 사진 파일이 아니라 주소만 담습니다. 오프라인에서 사진이 실제로 뜨는 건 + * Coil과 Glide의 디스크 캐시가 맡습니다 + * + * id는 로컬 조회 키가 아니라 이미지 삭제 요청에 서버로 되돌려 보낼 값입니다 + */ +@JsonClass(generateAdapter = true) +data class CachedPicture( + val id: Long, + val url: String, +) diff --git a/data/src/main/java/com/project200/data/local/entity/ExerciseCountEntity.kt b/data/src/main/java/com/project200/data/local/entity/ExerciseCountEntity.kt new file mode 100644 index 00000000..646d78ad --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/ExerciseCountEntity.kt @@ -0,0 +1,21 @@ +package com.project200.data.local.entity + +import androidx.room.Entity +import java.time.LocalDate + +/** + * 캘린더에 찍히는 날짜별 운동 횟수입니다. + * + * 로컬 기록 행을 세지 않고 서버 응답을 그대로 담습니다 + * 캘린더 API는 개수만 주고 기록 자체를 주지 않아서, 세는 방식이면 + * 캘린더만 열어본 달이 오프라인에서 빈 달로 보입니다 + */ +@Entity( + tableName = "exercise_count", + primaryKeys = ["memberId", "date"], +) +data class ExerciseCountEntity( + val memberId: String, + val date: LocalDate, + val count: Int, +) diff --git a/data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt b/data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt new file mode 100644 index 00000000..6d081224 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt @@ -0,0 +1,32 @@ +package com.project200.data.local.entity + +import androidx.room.Entity +import androidx.room.Index +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * 하루 운동 기록 목록의 한 줄입니다. + * + * date는 목록을 받아올 때 서버에 넘긴 조회 날짜입니다. 응답에는 들어 있지 않지만 + * startedAt에서 파생시키면 자정을 넘긴 기록이 서버가 묶어준 날과 어긋나서 컬럼으로 둡니다 + * + * sortOrder는 서버가 준 응답 순서입니다. SQL은 순서를 보장하지 않아서 + * 이걸로 정렬해야 목록이 서버와 같은 차례로 보입니다 + */ +@Entity( + tableName = "exercise_list_item", + primaryKeys = ["memberId", "recordId"], + indices = [Index(value = ["memberId", "date"])], +) +data class ExerciseListItemEntity( + val memberId: String, + val recordId: Long, + val date: LocalDate, + val sortOrder: Int, + val title: String, + val personalType: String, + val startedAt: LocalDateTime, + val endedAt: LocalDateTime, + val thumbnailUrls: List?, +) diff --git a/data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt b/data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt new file mode 100644 index 00000000..be55e49d --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt @@ -0,0 +1,28 @@ +package com.project200.data.local.entity + +import androidx.room.Entity +import java.time.LocalDateTime + +/** + * 운동 기록 상세입니다. + * + * 목록과 컬럼이 겹치지만 테이블을 나눕니다. 한 테이블에 담으면 목록 응답을 저장할 때 + * detail과 location이, 상세를 저장할 때 date와 thumbnailUrls가 서로 덮여 사라집니다 + * + * 상세 응답에는 기록 ID가 없어서 recordId는 조회할 때 넘긴 값으로 채웁니다 + */ +@Entity( + tableName = "exercise_record_detail", + primaryKeys = ["memberId", "recordId"], +) +data class ExerciseRecordDetailEntity( + val memberId: String, + val recordId: Long, + val title: String, + val detail: String, + val personalType: String, + val startedAt: LocalDateTime, + val endedAt: LocalDateTime, + val location: String, + val pictures: List?, +) From d6a7f6bc44d0b469ad8130bacd34a2c8a19d1bea Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 21 Aug 2026 21:32:42 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20=EB=A1=9C=EC=BB=AC=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=97=B0=EA=B2=B0=20#581?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계정 경계를 테이블보다 먼저 잠급니다. 로그아웃이나 계정 전환 때 이전 사용자의 데이터가 남으면 안 됩니다. - 세션을 지우는 경로가 MainViewModel.transitionToLogin()과 SettingFragment.clearLocalSession() 둘인데 모두 ClearSessionUseCase를 거치므로 삭제를 여기 한 곳에 연결합니다 - domain은 Room을 모르게 합니다. FcmTokenSyncScheduler와 같은 패턴으로 domain에 SessionDataCleaner 인터페이스를 두고 data 구현이 지웁니다 --- .../com/project200/data/di/DatabaseModule.kt | 61 +++++++++++++++++++ .../data/impl/RoomSessionDataCleaner.kt | 22 +++++++ .../domain/manager/SessionDataCleaner.kt | 10 +++ .../domain/usecase/ClearSessionUseCase.kt | 6 +- .../domain/usecase/ClearSessionUseCaseTest.kt | 22 ++++++- 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 data/src/main/java/com/project200/data/di/DatabaseModule.kt create mode 100644 data/src/main/java/com/project200/data/impl/RoomSessionDataCleaner.kt create mode 100644 domain/src/main/java/com/project200/domain/manager/SessionDataCleaner.kt diff --git a/data/src/main/java/com/project200/data/di/DatabaseModule.kt b/data/src/main/java/com/project200/data/di/DatabaseModule.kt new file mode 100644 index 00000000..2150d0fd --- /dev/null +++ b/data/src/main/java/com/project200/data/di/DatabaseModule.kt @@ -0,0 +1,61 @@ +package com.project200.data.di + +import android.content.Context +import androidx.room.Room +import com.project200.data.impl.RoomSessionDataCleaner +import com.project200.data.local.UndabangDatabase +import com.project200.data.local.UndabangTypeConverters +import com.project200.data.local.dao.ExerciseCountDao +import com.project200.data.local.dao.ExerciseRecordDao +import com.project200.domain.manager.SessionDataCleaner +import com.squareup.moshi.Moshi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DatabaseModule { + @Binds + @Singleton + abstract fun bindSessionDataCleaner(roomSessionDataCleaner: RoomSessionDataCleaner): SessionDataCleaner + + companion object { + @Provides + @Singleton + fun provideUndabangTypeConverters(moshi: Moshi): UndabangTypeConverters { + return UndabangTypeConverters(moshi) + } + + @Provides + @Singleton + fun provideUndabangDatabase( + @ApplicationContext context: Context, + typeConverters: UndabangTypeConverters, + ): UndabangDatabase { + return Room.databaseBuilder( + context, + UndabangDatabase::class.java, + UndabangDatabase.DATABASE_NAME, + ) + .addTypeConverter(typeConverters) + .build() + } + + @Provides + @Singleton + fun provideExerciseCountDao(database: UndabangDatabase): ExerciseCountDao { + return database.exerciseCountDao() + } + + @Provides + @Singleton + fun provideExerciseRecordDao(database: UndabangDatabase): ExerciseRecordDao { + return database.exerciseRecordDao() + } + } +} diff --git a/data/src/main/java/com/project200/data/impl/RoomSessionDataCleaner.kt b/data/src/main/java/com/project200/data/impl/RoomSessionDataCleaner.kt new file mode 100644 index 00000000..a97245fb --- /dev/null +++ b/data/src/main/java/com/project200/data/impl/RoomSessionDataCleaner.kt @@ -0,0 +1,22 @@ +package com.project200.data.impl + +import com.project200.common.di.IoDispatcher +import com.project200.data.local.UndabangDatabase +import com.project200.domain.manager.SessionDataCleaner +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RoomSessionDataCleaner + @Inject + constructor( + private val database: UndabangDatabase, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + ) : SessionDataCleaner { + override suspend fun clearAll() = + withContext(ioDispatcher) { + database.clearAllTables() + } + } diff --git a/domain/src/main/java/com/project200/domain/manager/SessionDataCleaner.kt b/domain/src/main/java/com/project200/domain/manager/SessionDataCleaner.kt new file mode 100644 index 00000000..2928b522 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/manager/SessionDataCleaner.kt @@ -0,0 +1,10 @@ +package com.project200.domain.manager + +/** + * 계정 경계를 지키기 위해 로컬에 쌓인 캐시를 지웁니다. + * 로그아웃이나 계정 전환 때 이전 사용자의 데이터가 남으면 안 됩니다. + * 구현은 Room을 쓰는 data 모듈에 있습니다. + */ +interface SessionDataCleaner { + suspend fun clearAll() +} diff --git a/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt index c8a34dd1..3c629f05 100644 --- a/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt @@ -1,6 +1,7 @@ package com.project200.domain.usecase import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.manager.SessionDataCleaner import com.project200.domain.repository.AuthRepository import javax.inject.Inject @@ -9,10 +10,13 @@ class ClearSessionUseCase constructor( private val authRepository: AuthRepository, private val fcmTokenSyncScheduler: FcmTokenSyncScheduler, + private val sessionDataCleaner: SessionDataCleaner, ) { suspend operator fun invoke() { authRepository.clearSession() - // 회원ID가 지워져 실행돼도 SKIPPED로 끝나지만 예약을 남겨둘 이유가 없다 + // 회원ID가 지워져 실행돼도 SKIPPED로 끝나지만 예약을 남겨둘 이유가 없습니다 fcmTokenSyncScheduler.cancel() + // 다음 사용자가 이전 사용자의 캐시를 보면 안 됩니다 + sessionDataCleaner.clearAll() } } diff --git a/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt index 43a0e3d4..17a4043a 100644 --- a/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt @@ -1,6 +1,7 @@ package com.project200.domain.usecase import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.manager.SessionDataCleaner import com.project200.domain.repository.AuthRepository import io.mockk.Runs import io.mockk.coEvery @@ -28,11 +29,14 @@ class ClearSessionUseCaseTest { @MockK private lateinit var mockFcmTokenSyncScheduler: FcmTokenSyncScheduler + @MockK + private lateinit var mockSessionDataCleaner: SessionDataCleaner + private lateinit var useCase: ClearSessionUseCase @Before fun setUp() { - useCase = ClearSessionUseCase(mockRepository, mockFcmTokenSyncScheduler) + useCase = ClearSessionUseCase(mockRepository, mockFcmTokenSyncScheduler, mockSessionDataCleaner) } @Test @@ -40,6 +44,7 @@ class ClearSessionUseCaseTest { // Given coEvery { mockRepository.clearSession() } just Runs every { mockFcmTokenSyncScheduler.cancel() } just Runs + coEvery { mockSessionDataCleaner.clearAll() } just Runs // When useCase() @@ -53,6 +58,7 @@ class ClearSessionUseCaseTest { // Given coEvery { mockRepository.clearSession() } just Runs every { mockFcmTokenSyncScheduler.cancel() } just Runs + coEvery { mockSessionDataCleaner.clearAll() } just Runs // When useCase() @@ -60,4 +66,18 @@ class ClearSessionUseCaseTest { // Then verify(exactly = 1) { mockFcmTokenSyncScheduler.cancel() } } + + @Test + fun `invoke 호출 시 로컬 캐시를 지운다`() = runTest { + // Given + coEvery { mockRepository.clearSession() } just Runs + every { mockFcmTokenSyncScheduler.cancel() } just Runs + coEvery { mockSessionDataCleaner.clearAll() } just Runs + + // When + useCase() + + // Then + coVerify(exactly = 1) { mockSessionDataCleaner.clearAll() } + } } From 0d349ad042d1757cc4cd5f0a32221c65ff29afe5 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 21 Aug 2026 21:32:50 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=EC=9A=B4=EB=8F=99=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20DataSource=20=EB=B6=84=EB=A6=AC=20#583?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 API 호출을 RemoteDataSource로 옮기고 Room 접근을 LocalDataSource로 분리합니다. apiCallBuilder 계약과 화면 동작은 그대로입니다. - LocalDataSource가 memberId를 자기 안에서만 읽습니다. 호출부가 넘길 수 없으니 계정 조건을 빠뜨린 쿼리를 쓸 자리가 없습니다 - 캘린더와 목록은 구간을 비우고 다시 채웁니다. 서버 응답에는 운동한 날과 남아 있는 기록만 오니까 upsert만 하면 0건이 된 날과 삭제된 기록이 캐시에 계속 남습니다 - LocalDataSource는 아직 호출부가 없습니다. 읽기를 Flow로 바꾸는 건 #584입니다 --- .../ExerciseRecordLocalDataSource.kt | 106 ++++++++++++++++++ .../ExerciseRecordRemoteDataSource.kt | 51 +++++++++ .../data/impl/ExerciseRecordRepositoryImpl.kt | 20 ++-- .../data/mapper/ExerciseCacheMapper.kt | 84 ++++++++++++++ 4 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt create mode 100644 data/src/main/java/com/project200/data/datasource/ExerciseRecordRemoteDataSource.kt create mode 100644 data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt diff --git a/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt b/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt new file mode 100644 index 00000000..b1a98305 --- /dev/null +++ b/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt @@ -0,0 +1,106 @@ +package com.project200.data.datasource + +import androidx.room.withTransaction +import com.project200.data.local.PreferenceManager +import com.project200.data.local.UndabangDatabase +import com.project200.data.local.dao.ExerciseCountDao +import com.project200.data.local.dao.ExerciseRecordDao +import com.project200.data.mapper.toEntity +import com.project200.data.mapper.toModel +import com.project200.domain.model.ExerciseCount +import com.project200.domain.model.ExerciseListItem +import com.project200.domain.model.ExerciseRecord +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import timber.log.Timber +import java.time.LocalDate +import javax.inject.Inject + +/** + * 운동 기록 캐시를 읽고 씁니다. + * + * 계정 조건을 여기서 한 번에 겁니다. 호출부가 memberId를 넘기지 않게 해서 + * 조건을 빠뜨린 쿼리가 나올 자리를 없앱니다 + * + * 로그아웃 상태에서는 읽기가 빈 값을, 쓰기가 아무 일도 하지 않습니다 + */ +class ExerciseRecordLocalDataSource + @Inject + constructor( + private val database: UndabangDatabase, + private val exerciseCountDao: ExerciseCountDao, + private val exerciseRecordDao: ExerciseRecordDao, + private val preferenceManager: PreferenceManager, + ) { + fun observeCountsByRange( + startDate: LocalDate, + endDate: LocalDate, + ): Flow> { + val memberId = currentMemberId() ?: return flowOf(emptyList()) + return exerciseCountDao.observeRange(memberId, startDate, endDate) + .map { entities -> entities.map { it.toModel() } } + } + + /** 서버 응답에는 운동한 날만 들어 있어서 구간을 비우고 다시 채웁니다 */ + suspend fun replaceCountsByRange( + startDate: LocalDate, + endDate: LocalDate, + counts: List, + ) { + val memberId = currentMemberId() ?: return + database.withTransaction { + exerciseCountDao.deleteRange(memberId, startDate, endDate) + exerciseCountDao.upsertAll(counts.map { it.toEntity(memberId) }) + } + } + + fun observeListByDate(date: LocalDate): Flow> { + val memberId = currentMemberId() ?: return flowOf(emptyList()) + return exerciseRecordDao.observeListByDate(memberId, date) + .map { entities -> entities.map { it.toModel() } } + } + + /** 서버에서 지워진 기록이 남지 않도록 그날 목록을 통째로 갈아끼웁니다 */ + suspend fun replaceListByDate( + date: LocalDate, + items: List, + ) { + val memberId = currentMemberId() ?: return + database.withTransaction { + exerciseRecordDao.deleteListByDate(memberId, date) + exerciseRecordDao.upsertListItems( + items.mapIndexed { index, item -> item.toEntity(memberId, date, index) }, + ) + } + } + + fun observeDetail(recordId: Long): Flow { + val memberId = currentMemberId() ?: return flowOf(null) + return exerciseRecordDao.observeDetail(memberId, recordId) + .map { entity -> entity?.toModel() } + } + + suspend fun saveDetail( + recordId: Long, + record: ExerciseRecord, + ) { + val memberId = currentMemberId() ?: return + exerciseRecordDao.upsertDetail(record.toEntity(memberId, recordId)) + } + + /** 기록이 삭제되면 목록과 상세를 함께 지웁니다 */ + suspend fun deleteRecord(recordId: Long) { + val memberId = currentMemberId() ?: return + database.withTransaction { + exerciseRecordDao.deleteListItem(memberId, recordId) + exerciseRecordDao.deleteDetail(memberId, recordId) + } + } + + private fun currentMemberId(): String? { + return preferenceManager.getMemberId().also { + if (it == null) Timber.w("회원ID가 없어 운동 기록 캐시를 건너뜁니다") + } + } + } diff --git a/data/src/main/java/com/project200/data/datasource/ExerciseRecordRemoteDataSource.kt b/data/src/main/java/com/project200/data/datasource/ExerciseRecordRemoteDataSource.kt new file mode 100644 index 00000000..1a642258 --- /dev/null +++ b/data/src/main/java/com/project200/data/datasource/ExerciseRecordRemoteDataSource.kt @@ -0,0 +1,51 @@ +package com.project200.data.datasource + +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.ExerciseIdDto +import com.project200.data.dto.GetExerciseCountByRangeDTO +import com.project200.data.dto.GetExerciseRecordData +import com.project200.data.dto.GetExerciseRecordListDto +import com.project200.data.dto.PatchExerciseRequestDto +import com.project200.data.dto.PostExerciseRequestDto +import com.project200.data.dto.PostExerciseResponseDTO +import okhttp3.MultipartBody +import java.time.LocalDate +import javax.inject.Inject + +/** 운동 기록의 서버 호출을 모읍니다. 응답 래핑과 오류 변환은 호출부의 apiCallBuilder가 맡습니다 */ +class ExerciseRecordRemoteDataSource + @Inject + constructor( + private val apiService: ApiService, + ) { + suspend fun getExerciseCountsByRange( + startDate: LocalDate, + endDate: LocalDate, + ): BaseResponse> = apiService.getExerciseCountsByRange(startDate, endDate) + + suspend fun getExerciseRecordDetail(recordId: Long): BaseResponse = + apiService.getExerciseRecordDetail(recordId) + + suspend fun getExerciseList(date: LocalDate): BaseResponse> = apiService.getExerciseList(date) + + suspend fun postExerciseRecord(request: PostExerciseRequestDto): BaseResponse = + apiService.postExerciseRecord(request) + + suspend fun patchExerciseRecord( + recordId: Long, + request: PatchExerciseRequestDto, + ): BaseResponse = apiService.patchExerciseRecord(recordId, request) + + suspend fun postExerciseImages( + recordId: Long, + pictures: List, + ): BaseResponse = apiService.postExerciseImages(recordId, pictures) + + suspend fun deleteExerciseImages( + recordId: Long, + pictureIds: List, + ): BaseResponse = apiService.deleteExerciseImages(recordId, pictureIds) + + suspend fun deleteExerciseRecord(recordId: Long): BaseResponse = apiService.deleteExerciseRecord(recordId) + } diff --git a/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt index d1c48479..cbb06f67 100644 --- a/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt @@ -3,7 +3,7 @@ package com.project200.data.impl import android.content.Context import androidx.core.net.toUri import com.project200.common.di.IoDispatcher -import com.project200.data.api.ApiService +import com.project200.data.datasource.ExerciseRecordRemoteDataSource import com.project200.data.dto.ExerciseIdDto import com.project200.data.dto.GetExerciseCountByRangeDTO import com.project200.data.dto.GetExerciseRecordData @@ -30,7 +30,7 @@ import kotlin.coroutines.cancellation.CancellationException class ExerciseRecordRepositoryImpl @Inject constructor( - private val apiService: ApiService, + private val remoteDataSource: ExerciseRecordRemoteDataSource, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @ApplicationContext private val context: Context, ) : ExerciseRecordRepository { @@ -40,7 +40,7 @@ class ExerciseRecordRepositoryImpl ): BaseResult> { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.getExerciseCountsByRange(startDate, endDate) }, + apiCall = { remoteDataSource.getExerciseCountsByRange(startDate, endDate) }, mapper = { dtoList: List? -> dtoList?.map { it.toModel() } ?: throw NoSuchElementException("구간별 운동 횟수 조회 응답 데이터가 없습니다.") }, @@ -50,7 +50,7 @@ class ExerciseRecordRepositoryImpl override suspend fun getExerciseDetail(recordId: Long): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.getExerciseRecordDetail(recordId) }, + apiCall = { remoteDataSource.getExerciseRecordDetail(recordId) }, mapper = { dto: GetExerciseRecordData? -> dto?.toModel() ?: throw NoSuchElementException("운동 상세 정보 응답 데이터가 없습니다.") }, @@ -60,7 +60,7 @@ class ExerciseRecordRepositoryImpl override suspend fun getExerciseRecordList(date: LocalDate): BaseResult> { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.getExerciseList(date) }, + apiCall = { remoteDataSource.getExerciseList(date) }, mapper = { dtoList: List? -> dtoList?.map { it.toModel() } ?: emptyList() }, @@ -70,7 +70,7 @@ class ExerciseRecordRepositoryImpl override suspend fun createExerciseRecord(record: ExerciseRecord): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.postExerciseRecord(record.toPostExerciseDTO()) }, + apiCall = { remoteDataSource.postExerciseRecord(record.toPostExerciseDTO()) }, mapper = { dto: PostExerciseResponseDTO? -> dto?.toModel() ?: throw NoSuchElementException("운동 기록 생성 응답 데이터가 없습니다.") }, @@ -83,7 +83,7 @@ class ExerciseRecordRepositoryImpl ): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.patchExerciseRecord(recordId, record.toPatchExerciseDTO()) }, + apiCall = { remoteDataSource.patchExerciseRecord(recordId, record.toPatchExerciseDTO()) }, mapper = { exerciseIdDto: ExerciseIdDto? -> exerciseIdDto?.exerciseId ?: throw NoSuchElementException("운동 기록 수정 응답 데이터가 없습니다.") }, @@ -118,7 +118,7 @@ class ExerciseRecordRepositoryImpl return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.postExerciseImages(recordId, imageParts) }, + apiCall = { remoteDataSource.postExerciseImages(recordId, imageParts) }, mapper = { exerciseIdDto: ExerciseIdDto? -> exerciseIdDto?.exerciseId ?: throw NoSuchElementException("이미지 업로드 응답 데이터가 없습니다.") }, @@ -131,7 +131,7 @@ class ExerciseRecordRepositoryImpl ): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.deleteExerciseImages(recordId, imageIds) }, + apiCall = { remoteDataSource.deleteExerciseImages(recordId, imageIds) }, mapper = { Unit }, ) } @@ -139,7 +139,7 @@ class ExerciseRecordRepositoryImpl override suspend fun deleteExerciseRecord(recordId: Long): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.deleteExerciseRecord(recordId) }, + apiCall = { remoteDataSource.deleteExerciseRecord(recordId) }, mapper = { Unit }, ) } diff --git a/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt b/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt new file mode 100644 index 00000000..e4db292c --- /dev/null +++ b/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt @@ -0,0 +1,84 @@ +package com.project200.data.mapper + +import com.project200.data.local.entity.CachedPicture +import com.project200.data.local.entity.ExerciseCountEntity +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.ExerciseRecordDetailEntity +import com.project200.domain.model.ExerciseCount +import com.project200.domain.model.ExerciseListItem +import com.project200.domain.model.ExerciseRecord +import com.project200.domain.model.ExerciseRecordPicture +import java.time.LocalDate + +fun ExerciseCount.toEntity(memberId: String): ExerciseCountEntity { + return ExerciseCountEntity( + memberId = memberId, + date = date, + count = count, + ) +} + +fun ExerciseCountEntity.toModel(): ExerciseCount { + return ExerciseCount( + date = date, + count = count, + ) +} + +fun ExerciseListItem.toEntity( + memberId: String, + date: LocalDate, + sortOrder: Int, +): ExerciseListItemEntity { + return ExerciseListItemEntity( + memberId = memberId, + recordId = recordId, + date = date, + sortOrder = sortOrder, + title = title, + personalType = type, + startedAt = startTime, + endedAt = endTime, + thumbnailUrls = imageUrl, + ) +} + +fun ExerciseListItemEntity.toModel(): ExerciseListItem { + return ExerciseListItem( + recordId = recordId, + title = title, + type = personalType, + startTime = startedAt, + endTime = endedAt, + imageUrl = thumbnailUrls, + ) +} + +fun ExerciseRecord.toEntity( + memberId: String, + recordId: Long, +): ExerciseRecordDetailEntity { + return ExerciseRecordDetailEntity( + memberId = memberId, + recordId = recordId, + title = title, + detail = detail, + personalType = personalType, + startedAt = startedAt, + endedAt = endedAt, + location = location, + pictures = pictures?.map { CachedPicture(it.id, it.url) }, + ) +} + +fun ExerciseRecordDetailEntity.toModel(): ExerciseRecord { + return ExerciseRecord( + title = title, + detail = detail, + personalType = personalType, + startedAt = startedAt, + endedAt = endedAt, + location = location, + pictures = pictures?.map { ExerciseRecordPicture(it.id, it.url) }, + ) +} From 11be5ced59bba9daadc43194d67c948fec992865 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Wed, 26 Aug 2026 15:17:52 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=EC=9A=B4=EB=8F=99=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=BA=90=EC=8B=9C=EB=A5=BC=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?ID=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20#583?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디자인 문서 최종본의 원본 데이터 위치와 설계 규칙(D2, D4, D6)에 맞춥니다. - 행을 식별하는 값을 localId(UUID)로 바꾸고 serverId를 nullable로 뒀습니다. 오프라인에서 만든 기록은 서버 ID를 받기 전이라 서버 ID를 PK로 쓰면 들어갈 자리가 없습니다 - syncState 컬럼을 추가했습니다. 연산을 쌓지 않고 행마다 현재 상태만 둡니다 - 서버 목록 반영이 SYNCED 행만 교체합니다. 아직 못 올린 로컬 변경이 서버 목록 갱신으로 사라지면 안 됩니다 - 이미 캐시에 있던 기록은 localId를 이어 씁니다. 목록을 새로 받아도 그 기록을 가리키던 화면과 이미지가 같은 행을 계속 가리킵니다 - 읽기를 Flow 구독에서 1회 조회로 바꿨습니다. 화면은 진입할 때 한 번 읽고 그린 값이 저절로 바뀌지 않습니다 목록 상세 진입은 아직 서버 ID를 씁니다. 화면이 로컬 ID로 기록을 식별하게 바꾸는 건 #584입니다. --- .../1.json | 67 +++++++++++++--- .../ExerciseRecordLocalDataSource.kt | 78 ++++++++++++------- .../data/local/UndabangTypeConverters.kt | 7 ++ .../data/local/dao/ExerciseCountDao.kt | 6 +- .../data/local/dao/ExerciseRecordDao.kt | 74 ++++++++++++++---- .../local/entity/ExerciseListItemEntity.kt | 14 +++- .../entity/ExerciseRecordDetailEntity.kt | 11 ++- .../project200/data/local/entity/SyncState.kt | 14 ++++ .../data/mapper/ExerciseCacheMapper.kt | 26 +++++-- 9 files changed, 230 insertions(+), 67 deletions(-) create mode 100644 data/src/main/java/com/project200/data/local/entity/SyncState.kt diff --git a/data/schemas/com.project200.data.local.UndabangDatabase/1.json b/data/schemas/com.project200.data.local.UndabangDatabase/1.json index 3c3248cb..396d9f8f 100644 --- a/data/schemas/com.project200.data.local.UndabangDatabase/1.json +++ b/data/schemas/com.project200.data.local.UndabangDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "953d4eadbefa6d56d23af6cd5623fb2d", + "identityHash": "a1f028ec29bd0b4dee3f66b17c0b1740", "entities": [ { "tableName": "exercise_count", @@ -39,7 +39,7 @@ }, { "tableName": "exercise_list_item", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `recordId` INTEGER NOT NULL, `date` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `title` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `thumbnailUrls` TEXT, PRIMARY KEY(`memberId`, `recordId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `localId` TEXT NOT NULL, `serverId` INTEGER, `syncState` TEXT NOT NULL, `date` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `title` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `thumbnailUrls` TEXT, PRIMARY KEY(`memberId`, `localId`))", "fields": [ { "fieldPath": "memberId", @@ -48,9 +48,21 @@ "notNull": true }, { - "fieldPath": "recordId", - "columnName": "recordId", + "fieldPath": "localId", + "columnName": "localId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", "notNull": true }, { @@ -100,7 +112,7 @@ "autoGenerate": false, "columnNames": [ "memberId", - "recordId" + "localId" ] }, "indices": [ @@ -113,13 +125,23 @@ ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_list_item_memberId_date` ON `${TABLE_NAME}` (`memberId`, `date`)" + }, + { + "name": "index_exercise_list_item_memberId_serverId", + "unique": false, + "columnNames": [ + "memberId", + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_list_item_memberId_serverId` ON `${TABLE_NAME}` (`memberId`, `serverId`)" } ], "foreignKeys": [] }, { "tableName": "exercise_record_detail", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `recordId` INTEGER NOT NULL, `title` TEXT NOT NULL, `detail` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `location` TEXT NOT NULL, `pictures` TEXT, PRIMARY KEY(`memberId`, `recordId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `localId` TEXT NOT NULL, `serverId` INTEGER, `syncState` TEXT NOT NULL, `title` TEXT NOT NULL, `detail` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `location` TEXT NOT NULL, `pictures` TEXT, PRIMARY KEY(`memberId`, `localId`))", "fields": [ { "fieldPath": "memberId", @@ -128,9 +150,21 @@ "notNull": true }, { - "fieldPath": "recordId", - "columnName": "recordId", + "fieldPath": "localId", + "columnName": "localId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", "notNull": true }, { @@ -180,17 +214,28 @@ "autoGenerate": false, "columnNames": [ "memberId", - "recordId" + "localId" ] }, - "indices": [], + "indices": [ + { + "name": "index_exercise_record_detail_memberId_serverId", + "unique": false, + "columnNames": [ + "memberId", + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_record_detail_memberId_serverId` ON `${TABLE_NAME}` (`memberId`, `serverId`)" + } + ], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '953d4eadbefa6d56d23af6cd5623fb2d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a1f028ec29bd0b4dee3f66b17c0b1740')" ] } } \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt b/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt index b1a98305..f8a7a268 100644 --- a/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt +++ b/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt @@ -7,19 +7,21 @@ import com.project200.data.local.dao.ExerciseCountDao import com.project200.data.local.dao.ExerciseRecordDao import com.project200.data.mapper.toEntity import com.project200.data.mapper.toModel +import com.project200.data.mapper.toSyncedEntity import com.project200.domain.model.ExerciseCount import com.project200.domain.model.ExerciseListItem import com.project200.domain.model.ExerciseRecord -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import timber.log.Timber import java.time.LocalDate +import java.util.UUID import javax.inject.Inject /** * 운동 기록 캐시를 읽고 씁니다. * + * 읽기는 한 번에 끝납니다. 화면이 지속 구독을 하지 않아서 한 번 그린 값이 + * 저절로 바뀌지 않습니다 + * * 계정 조건을 여기서 한 번에 겁니다. 호출부가 memberId를 넘기지 않게 해서 * 조건을 빠뜨린 쿼리가 나올 자리를 없앱니다 * @@ -33,13 +35,12 @@ class ExerciseRecordLocalDataSource private val exerciseRecordDao: ExerciseRecordDao, private val preferenceManager: PreferenceManager, ) { - fun observeCountsByRange( + suspend fun getCountsByRange( startDate: LocalDate, endDate: LocalDate, - ): Flow> { - val memberId = currentMemberId() ?: return flowOf(emptyList()) - return exerciseCountDao.observeRange(memberId, startDate, endDate) - .map { entities -> entities.map { it.toModel() } } + ): List { + val memberId = currentMemberId() ?: return emptyList() + return exerciseCountDao.getRange(memberId, startDate, endDate).map { it.toModel() } } /** 서버 응답에는 운동한 날만 들어 있어서 구간을 비우고 다시 채웁니다 */ @@ -55,49 +56,74 @@ class ExerciseRecordLocalDataSource } } - fun observeListByDate(date: LocalDate): Flow> { - val memberId = currentMemberId() ?: return flowOf(emptyList()) - return exerciseRecordDao.observeListByDate(memberId, date) - .map { entities -> entities.map { it.toModel() } } + suspend fun getListByDate(date: LocalDate): List { + val memberId = currentMemberId() ?: return emptyList() + return exerciseRecordDao.getListByDate(memberId, date).map { it.toModel() } } - /** 서버에서 지워진 기록이 남지 않도록 그날 목록을 통째로 갈아끼웁니다 */ - suspend fun replaceListByDate( + /** + * 서버에서 지워진 기록이 남지 않도록 그날 목록을 갈아끼웁니다. + * + * 아직 못 올린 로컬 변경이 사라지면 안 되므로 전송 대기 행은 두고 갑니다. + * 이미 캐시에 있던 기록은 localId를 이어 써서, 목록을 새로 받아도 + * 그 기록을 가리키던 화면과 이미지가 같은 행을 계속 가리킵니다 + */ + suspend fun replaceSyncedListByDate( date: LocalDate, items: List, ) { val memberId = currentMemberId() ?: return database.withTransaction { - exerciseRecordDao.deleteListByDate(memberId, date) + val knownLocalIds = + exerciseRecordDao.getServerIdToLocalId(memberId, date) + .associate { it.serverId to it.localId } + + exerciseRecordDao.deleteSyncedListByDate(memberId, date) exerciseRecordDao.upsertListItems( - items.mapIndexed { index, item -> item.toEntity(memberId, date, index) }, + items.mapIndexed { index, item -> + val localId = knownLocalIds[item.recordId] ?: newLocalId() + item.toSyncedEntity(memberId, localId, date, index) + }, ) } } - fun observeDetail(recordId: Long): Flow { - val memberId = currentMemberId() ?: return flowOf(null) - return exerciseRecordDao.observeDetail(memberId, recordId) - .map { entity -> entity?.toModel() } + suspend fun getDetailByServerId(serverId: Long): ExerciseRecord? { + val memberId = currentMemberId() ?: return null + return exerciseRecordDao.getDetailByServerId(memberId, serverId)?.toModel() + } + + suspend fun getDetailByLocalId(localId: String): ExerciseRecord? { + val memberId = currentMemberId() ?: return null + return exerciseRecordDao.getDetailByLocalId(memberId, localId)?.toModel() } - suspend fun saveDetail( - recordId: Long, + /** 상세를 받아온 기록이 목록에도 있으면 그 localId를 씁니다 */ + suspend fun saveSyncedDetail( + serverId: Long, record: ExerciseRecord, ) { val memberId = currentMemberId() ?: return - exerciseRecordDao.upsertDetail(record.toEntity(memberId, recordId)) + database.withTransaction { + val localId = + exerciseRecordDao.findDetailLocalId(memberId, serverId) + ?: exerciseRecordDao.findListItemLocalId(memberId, serverId) + ?: newLocalId() + exerciseRecordDao.upsertDetail(record.toSyncedEntity(memberId, localId, serverId)) + } } /** 기록이 삭제되면 목록과 상세를 함께 지웁니다 */ - suspend fun deleteRecord(recordId: Long) { + suspend fun deleteRecord(localId: String) { val memberId = currentMemberId() ?: return database.withTransaction { - exerciseRecordDao.deleteListItem(memberId, recordId) - exerciseRecordDao.deleteDetail(memberId, recordId) + exerciseRecordDao.deleteListItem(memberId, localId) + exerciseRecordDao.deleteDetail(memberId, localId) } } + private fun newLocalId(): String = UUID.randomUUID().toString() + private fun currentMemberId(): String? { return preferenceManager.getMemberId().also { if (it == null) Timber.w("회원ID가 없어 운동 기록 캐시를 건너뜁니다") diff --git a/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt b/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt index d1246423..56c32b90 100644 --- a/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt +++ b/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt @@ -3,6 +3,7 @@ package com.project200.data.local import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter import com.project200.data.local.entity.CachedPicture +import com.project200.data.local.entity.SyncState import com.squareup.moshi.Moshi import com.squareup.moshi.Types import java.time.LocalDate @@ -29,6 +30,12 @@ class UndabangTypeConverters(moshi: Moshi) { Types.newParameterizedType(List::class.java, CachedPicture::class.java), ) + @TypeConverter + fun fromSyncState(value: SyncState?): String? = value?.name + + @TypeConverter + fun toSyncState(value: String?): SyncState? = value?.let(SyncState::valueOf) + @TypeConverter fun fromLocalDate(value: LocalDate?): String? = value?.toString() diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt index 8b051432..58ce7895 100644 --- a/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt @@ -4,21 +4,21 @@ import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert import com.project200.data.local.entity.ExerciseCountEntity -import kotlinx.coroutines.flow.Flow import java.time.LocalDate @Dao interface ExerciseCountDao { + /** 화면은 진입할 때 한 번 읽습니다. 지속 구독을 두지 않아 그린 뒤 저절로 바뀌지 않습니다 */ @Query( "SELECT * FROM exercise_count " + "WHERE memberId = :memberId AND date BETWEEN :startDate AND :endDate " + "ORDER BY date", ) - fun observeRange( + suspend fun getRange( memberId: String, startDate: LocalDate, endDate: LocalDate, - ): Flow> + ): List @Upsert suspend fun upsertAll(counts: List) diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt index 5ed6fdf4..c94e56d3 100644 --- a/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt @@ -5,49 +5,93 @@ import androidx.room.Query import androidx.room.Upsert import com.project200.data.local.entity.ExerciseListItemEntity import com.project200.data.local.entity.ExerciseRecordDetailEntity -import kotlinx.coroutines.flow.Flow +import com.project200.data.local.entity.SyncState import java.time.LocalDate @Dao interface ExerciseRecordDao { + /** 화면은 진입할 때 한 번 읽습니다. 지속 구독을 두지 않아 그린 뒤 저절로 바뀌지 않습니다 */ @Query( "SELECT * FROM exercise_list_item " + - "WHERE memberId = :memberId AND date = :date " + + "WHERE memberId = :memberId AND date = :date AND syncState != :excluded " + "ORDER BY sortOrder", ) - fun observeListByDate( + suspend fun getListByDate( memberId: String, date: LocalDate, - ): Flow> + excluded: SyncState = SyncState.DELETE_PENDING, + ): List + + /** 서버 목록을 반영할 때 이미 있는 행의 localId를 이어 쓰기 위해 씁니다 */ + @Query( + "SELECT serverId, localId FROM exercise_list_item " + + "WHERE memberId = :memberId AND date = :date AND serverId IS NOT NULL", + ) + suspend fun getServerIdToLocalId( + memberId: String, + date: LocalDate, + ): List @Upsert suspend fun upsertListItems(items: List) - /** 서버에서 지워진 기록이 남지 않도록 그날 목록을 갈아끼울 때 씁니다 */ - @Query("DELETE FROM exercise_list_item WHERE memberId = :memberId AND date = :date") - suspend fun deleteListByDate( + /** + * 서버에서 지워진 기록이 남지 않도록 그날 목록을 갈아끼울 때 씁니다. + * 아직 못 올린 로컬 변경이 사라지면 안 되므로 전송 대기 행은 건드리지 않습니다 + */ + @Query( + "DELETE FROM exercise_list_item " + + "WHERE memberId = :memberId AND date = :date AND syncState = :syncState", + ) + suspend fun deleteSyncedListByDate( memberId: String, date: LocalDate, + syncState: SyncState = SyncState.SYNCED, ) - @Query("SELECT * FROM exercise_record_detail WHERE memberId = :memberId AND recordId = :recordId") - fun observeDetail( + @Query("SELECT * FROM exercise_record_detail WHERE memberId = :memberId AND localId = :localId") + suspend fun getDetailByLocalId( memberId: String, - recordId: Long, - ): Flow + localId: String, + ): ExerciseRecordDetailEntity? + + /** 화면이 아직 서버 ID로 상세에 들어옵니다. localId 기준 진입은 #584에서 바뀝니다 */ + @Query("SELECT * FROM exercise_record_detail WHERE memberId = :memberId AND serverId = :serverId") + suspend fun getDetailByServerId( + memberId: String, + serverId: Long, + ): ExerciseRecordDetailEntity? + + @Query("SELECT localId FROM exercise_record_detail WHERE memberId = :memberId AND serverId = :serverId") + suspend fun findDetailLocalId( + memberId: String, + serverId: Long, + ): String? + + @Query("SELECT localId FROM exercise_list_item WHERE memberId = :memberId AND serverId = :serverId") + suspend fun findListItemLocalId( + memberId: String, + serverId: Long, + ): String? @Upsert suspend fun upsertDetail(detail: ExerciseRecordDetailEntity) - @Query("DELETE FROM exercise_record_detail WHERE memberId = :memberId AND recordId = :recordId") + @Query("DELETE FROM exercise_record_detail WHERE memberId = :memberId AND localId = :localId") suspend fun deleteDetail( memberId: String, - recordId: Long, + localId: String, ) - @Query("DELETE FROM exercise_list_item WHERE memberId = :memberId AND recordId = :recordId") + @Query("DELETE FROM exercise_list_item WHERE memberId = :memberId AND localId = :localId") suspend fun deleteListItem( memberId: String, - recordId: Long, + localId: String, ) } + +/** 서버 ID와 로컬 ID의 짝입니다 */ +data class ServerIdLocalId( + val serverId: Long, + val localId: String, +) diff --git a/data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt b/data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt index 6d081224..5122e2b2 100644 --- a/data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt +++ b/data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt @@ -8,6 +8,9 @@ import java.time.LocalDateTime /** * 하루 운동 기록 목록의 한 줄입니다. * + * 행을 식별하는 값은 localId입니다. 오프라인에서 만든 기록은 서버 ID를 받기 전이라 + * serverId가 비어 있습니다 + * * date는 목록을 받아올 때 서버에 넘긴 조회 날짜입니다. 응답에는 들어 있지 않지만 * startedAt에서 파생시키면 자정을 넘긴 기록이 서버가 묶어준 날과 어긋나서 컬럼으로 둡니다 * @@ -16,12 +19,17 @@ import java.time.LocalDateTime */ @Entity( tableName = "exercise_list_item", - primaryKeys = ["memberId", "recordId"], - indices = [Index(value = ["memberId", "date"])], + primaryKeys = ["memberId", "localId"], + indices = [ + Index(value = ["memberId", "date"]), + Index(value = ["memberId", "serverId"]), + ], ) data class ExerciseListItemEntity( val memberId: String, - val recordId: Long, + val localId: String, + val serverId: Long?, + val syncState: SyncState, val date: LocalDate, val sortOrder: Int, val title: String, diff --git a/data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt b/data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt index be55e49d..f9b60dd7 100644 --- a/data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt +++ b/data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt @@ -1,6 +1,7 @@ package com.project200.data.local.entity import androidx.room.Entity +import androidx.room.Index import java.time.LocalDateTime /** @@ -9,15 +10,19 @@ import java.time.LocalDateTime * 목록과 컬럼이 겹치지만 테이블을 나눕니다. 한 테이블에 담으면 목록 응답을 저장할 때 * detail과 location이, 상세를 저장할 때 date와 thumbnailUrls가 서로 덮여 사라집니다 * - * 상세 응답에는 기록 ID가 없어서 recordId는 조회할 때 넘긴 값으로 채웁니다 + * 목록과 같은 기록이면 localId를 공유합니다. 상세 응답에는 기록 ID가 없어서 + * serverId는 조회할 때 넘긴 값으로 채웁니다 */ @Entity( tableName = "exercise_record_detail", - primaryKeys = ["memberId", "recordId"], + primaryKeys = ["memberId", "localId"], + indices = [Index(value = ["memberId", "serverId"])], ) data class ExerciseRecordDetailEntity( val memberId: String, - val recordId: Long, + val localId: String, + val serverId: Long?, + val syncState: SyncState, val title: String, val detail: String, val personalType: String, diff --git a/data/src/main/java/com/project200/data/local/entity/SyncState.kt b/data/src/main/java/com/project200/data/local/entity/SyncState.kt new file mode 100644 index 00000000..9cfb7e52 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/SyncState.kt @@ -0,0 +1,14 @@ +package com.project200.data.local.entity + +/** + * 행 하나의 전송 상태입니다. + * + * 연산을 쌓지 않고 행마다 현재 상태만 둡니다. 오프라인에서 여러 번 고쳐도 + * 전송은 마지막 상태 한 번입니다 + */ +enum class SyncState { + SYNCED, + CREATE_PENDING, + UPDATE_PENDING, + DELETE_PENDING, +} diff --git a/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt b/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt index e4db292c..175f703e 100644 --- a/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt +++ b/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt @@ -4,6 +4,7 @@ import com.project200.data.local.entity.CachedPicture import com.project200.data.local.entity.ExerciseCountEntity import com.project200.data.local.entity.ExerciseListItemEntity import com.project200.data.local.entity.ExerciseRecordDetailEntity +import com.project200.data.local.entity.SyncState import com.project200.domain.model.ExerciseCount import com.project200.domain.model.ExerciseListItem import com.project200.domain.model.ExerciseRecord @@ -25,14 +26,18 @@ fun ExerciseCountEntity.toModel(): ExerciseCount { ) } -fun ExerciseListItem.toEntity( +/** 서버에서 받은 목록 한 줄입니다. 이미 캐시에 있던 기록이면 localId를 이어 씁니다 */ +fun ExerciseListItem.toSyncedEntity( memberId: String, + localId: String, date: LocalDate, sortOrder: Int, ): ExerciseListItemEntity { return ExerciseListItemEntity( memberId = memberId, - recordId = recordId, + localId = localId, + serverId = recordId, + syncState = SyncState.SYNCED, date = date, sortOrder = sortOrder, title = title, @@ -45,7 +50,7 @@ fun ExerciseListItem.toEntity( fun ExerciseListItemEntity.toModel(): ExerciseListItem { return ExerciseListItem( - recordId = recordId, + recordId = serverId ?: NO_SERVER_ID, title = title, type = personalType, startTime = startedAt, @@ -54,13 +59,16 @@ fun ExerciseListItemEntity.toModel(): ExerciseListItem { ) } -fun ExerciseRecord.toEntity( +fun ExerciseRecord.toSyncedEntity( memberId: String, - recordId: Long, + localId: String, + serverId: Long, ): ExerciseRecordDetailEntity { return ExerciseRecordDetailEntity( memberId = memberId, - recordId = recordId, + localId = localId, + serverId = serverId, + syncState = SyncState.SYNCED, title = title, detail = detail, personalType = personalType, @@ -82,3 +90,9 @@ fun ExerciseRecordDetailEntity.toModel(): ExerciseRecord { pictures = pictures?.map { ExerciseRecordPicture(it.id, it.url) }, ) } + +/** + * 아직 서버 ID가 없는 기록을 도메인 모델로 옮길 때 쓰는 값입니다. + * 화면이 로컬 ID로 기록을 식별하게 바뀌면 사라집니다 (#584) + */ +private const val NO_SERVER_ID = -1L From ac8dc34fc5dc56217fc8f2998a7eede77f6b0f5f Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Thu, 27 Aug 2026 19:44:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EC=84=9C=EB=B2=84=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=98=EC=98=81=EC=9D=B4=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=ED=96=89=EC=9D=84=20=EB=8D=AE=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#583?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계 규칙 6은 대기 행을 남기는 것과 서버 값으로 대체하지 않는 것을 둘 다 요구하는데 뒤쪽을 지키지 못하고 있었습니다. - SYNCED 행만 지우고 대기 행을 남겨도, 뒤이은 upsert가 그 localId를 그대로 써서 같은 행에 SYNCED와 서버 값이 들어갔습니다. 행을 지우지 않았을 뿐 결과가 같습니다. 이제 대기 행이 걸린 기록은 서버 항목 자체를 반영 대상에서 뺍니다 - 서버에서 기록의 날짜가 바뀌면 같은 serverId가 두 날짜에 남아 상세 조회가 어느 행을 집을지 정해지지 않았습니다. localId 조회에서 날짜를 가리지 않게 하고 옛 날짜 행을 정리합니다 - 서버 목록에서 사라진 기록의 상세가 남아 계속 읽혔습니다. 목록을 갈아끼울 때 함께 지웁니다 - 서버 ID가 없는 행을 -1로 내보내던 센티널을 없앴습니다. -1이 Long으로 멀쩡해 보여 그대로 서버 요청에 실릴 수 있었습니다 replaceSyncedListByDate는 삭제 조건과 localId 재사용, upsert가 한 트랜잭션에서 맞물려서 테스트를 붙였습니다. DAO를 대역으로 세워 선택 로직만 봅니다. --- .../ExerciseRecordLocalDataSource.kt | 49 ++++-- .../data/local/dao/ExerciseRecordDao.kt | 64 +++++++- .../data/mapper/ExerciseCacheMapper.kt | 11 +- .../ExerciseRecordLocalDataSourceTest.kt | 153 ++++++++++++++++++ 4 files changed, 256 insertions(+), 21 deletions(-) create mode 100644 data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt diff --git a/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt b/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt index f8a7a268..712368d5 100644 --- a/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt +++ b/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt @@ -56,16 +56,23 @@ class ExerciseRecordLocalDataSource } } + /** + * 서버 ID가 없는 행은 아직 화면이 다룰 수 없어 건너뜁니다. + * 화면이 로컬 ID로 기록을 식별하게 바뀌면 이 필터가 사라집니다 (#584) + */ suspend fun getListByDate(date: LocalDate): List { val memberId = currentMemberId() ?: return emptyList() - return exerciseRecordDao.getListByDate(memberId, date).map { it.toModel() } + return exerciseRecordDao.getListByDate(memberId, date).mapNotNull { it.toModel() } } /** * 서버에서 지워진 기록이 남지 않도록 그날 목록을 갈아끼웁니다. * - * 아직 못 올린 로컬 변경이 사라지면 안 되므로 전송 대기 행은 두고 갑니다. - * 이미 캐시에 있던 기록은 localId를 이어 써서, 목록을 새로 받아도 + * 아직 못 올린 로컬 변경은 서버 값으로 덮지 않습니다. 대기 행을 남기는 것만으로는 + * 부족하고, 그 기록에 해당하는 서버 항목 자체를 반영 대상에서 빼야 합니다. + * 같은 localId에 SYNCED와 서버 값을 쓰면 행을 지우지 않았을 뿐 결과가 같습니다 + * + * 이미 캐시에 있던 기록은 localId를 이어 씁니다. 목록을 새로 받아도 * 그 기록을 가리키던 화면과 이미지가 같은 행을 계속 가리킵니다 */ suspend fun replaceSyncedListByDate( @@ -74,15 +81,39 @@ class ExerciseRecordLocalDataSource ) { val memberId = currentMemberId() ?: return database.withTransaction { - val knownLocalIds = - exerciseRecordDao.getServerIdToLocalId(memberId, date) - .associate { it.serverId to it.localId } + val pendingServerIds = + exerciseRecordDao.getPendingServerIds(memberId, date).toSet() + + // 대기 행이 걸린 기록은 로컬이 우선이라 서버 값을 반영하지 않습니다 + val incoming = items.withIndex().filterNot { it.value.recordId in pendingServerIds } + val incomingServerIds = incoming.map { it.value.recordId } + + val reusableLocalIds = + if (incomingServerIds.isEmpty()) { + emptyMap() + } else { + exerciseRecordDao.getSyncedByServerIds(memberId, incomingServerIds) + .associate { it.serverId to it.localId } + } + + // 서버 목록에서 빠진 기록은 상세도 함께 정리합니다 + val removedLocalIds = + exerciseRecordDao.getSyncedByDate(memberId, date) + .filterNot { it.serverId in incomingServerIds } + .map { it.localId } exerciseRecordDao.deleteSyncedListByDate(memberId, date) + if (removedLocalIds.isNotEmpty()) { + exerciseRecordDao.deleteDetailsByLocalIds(memberId, removedLocalIds) + } + if (incomingServerIds.isNotEmpty()) { + exerciseRecordDao.deleteSyncedOnOtherDates(memberId, date, incomingServerIds) + } + exerciseRecordDao.upsertListItems( - items.mapIndexed { index, item -> - val localId = knownLocalIds[item.recordId] ?: newLocalId() - item.toSyncedEntity(memberId, localId, date, index) + incoming.map { (sortOrder, item) -> + val localId = reusableLocalIds[item.recordId] ?: newLocalId() + item.toSyncedEntity(memberId, localId, date, sortOrder) }, ) } diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt index c94e56d3..38b95473 100644 --- a/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt @@ -22,16 +22,47 @@ interface ExerciseRecordDao { excluded: SyncState = SyncState.DELETE_PENDING, ): List - /** 서버 목록을 반영할 때 이미 있는 행의 localId를 이어 쓰기 위해 씁니다 */ + /** + * 그날 캐시돼 있는 서버 기록입니다. 서버 목록에서 사라진 기록을 골라내는 데 씁니다. + * 대기 행은 서버가 모르는 로컬 변경이라 빼고 봅니다 + */ @Query( "SELECT serverId, localId FROM exercise_list_item " + - "WHERE memberId = :memberId AND date = :date AND serverId IS NOT NULL", + "WHERE memberId = :memberId AND date = :date " + + "AND serverId IS NOT NULL AND syncState = :syncState", ) - suspend fun getServerIdToLocalId( + suspend fun getSyncedByDate( memberId: String, date: LocalDate, + syncState: SyncState = SyncState.SYNCED, + ): List + + /** + * 서버 목록에 있는 기록 중 이미 캐시된 것의 localId입니다. + * 날짜를 가리지 않아서 서버에서 기록의 날짜가 바뀌어도 localId를 이어 쓸 수 있습니다 + */ + @Query( + "SELECT serverId, localId FROM exercise_list_item " + + "WHERE memberId = :memberId AND serverId IN (:serverIds) AND syncState = :syncState", + ) + suspend fun getSyncedByServerIds( + memberId: String, + serverIds: List, + syncState: SyncState = SyncState.SYNCED, ): List + /** 아직 못 올린 로컬 변경이 걸려 있는 기록입니다. 서버 값으로 덮으면 안 됩니다 */ + @Query( + "SELECT serverId FROM exercise_list_item " + + "WHERE memberId = :memberId AND date = :date " + + "AND serverId IS NOT NULL AND syncState != :syncState", + ) + suspend fun getPendingServerIds( + memberId: String, + date: LocalDate, + syncState: SyncState = SyncState.SYNCED, + ): List + @Upsert suspend fun upsertListItems(items: List) @@ -68,12 +99,37 @@ interface ExerciseRecordDao { serverId: Long, ): String? - @Query("SELECT localId FROM exercise_list_item WHERE memberId = :memberId AND serverId = :serverId") + /** 같은 serverId가 여러 날짜에 남아 있어도 한 행만 돌려주도록 최신 날짜를 집습니다 */ + @Query( + "SELECT localId FROM exercise_list_item " + + "WHERE memberId = :memberId AND serverId = :serverId " + + "ORDER BY date DESC LIMIT 1", + ) suspend fun findListItemLocalId( memberId: String, serverId: Long, ): String? + /** 서버에서 기록의 날짜가 바뀌면 옛 날짜에 남은 행을 지웁니다. 같은 serverId가 두 행이 되는 걸 막습니다 */ + @Query( + "DELETE FROM exercise_list_item " + + "WHERE memberId = :memberId AND date != :date " + + "AND serverId IN (:serverIds) AND syncState = :syncState", + ) + suspend fun deleteSyncedOnOtherDates( + memberId: String, + date: LocalDate, + serverIds: List, + syncState: SyncState = SyncState.SYNCED, + ) + + /** 서버 목록에서 사라진 기록의 상세를 함께 정리합니다 */ + @Query("DELETE FROM exercise_record_detail WHERE memberId = :memberId AND localId IN (:localIds)") + suspend fun deleteDetailsByLocalIds( + memberId: String, + localIds: List, + ) + @Upsert suspend fun upsertDetail(detail: ExerciseRecordDetailEntity) diff --git a/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt b/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt index 175f703e..d9539e7b 100644 --- a/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt +++ b/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt @@ -48,9 +48,10 @@ fun ExerciseListItem.toSyncedEntity( ) } -fun ExerciseListItemEntity.toModel(): ExerciseListItem { +/** 서버 ID가 아직 없는 행은 화면이 다룰 수 없어 null을 돌려줍니다 */ +fun ExerciseListItemEntity.toModel(): ExerciseListItem? { return ExerciseListItem( - recordId = serverId ?: NO_SERVER_ID, + recordId = serverId ?: return null, title = title, type = personalType, startTime = startedAt, @@ -90,9 +91,3 @@ fun ExerciseRecordDetailEntity.toModel(): ExerciseRecord { pictures = pictures?.map { ExerciseRecordPicture(it.id, it.url) }, ) } - -/** - * 아직 서버 ID가 없는 기록을 도메인 모델로 옮길 때 쓰는 값입니다. - * 화면이 로컬 ID로 기록을 식별하게 바뀌면 사라집니다 (#584) - */ -private const val NO_SERVER_ID = -1L diff --git a/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt b/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt new file mode 100644 index 00000000..102c2b56 --- /dev/null +++ b/data/src/test/java/com/project200/data/datasource/ExerciseRecordLocalDataSourceTest.kt @@ -0,0 +1,153 @@ +package com.project200.data.datasource + +import androidx.room.withTransaction +import com.google.common.truth.Truth.assertThat +import com.project200.data.local.PreferenceManager +import com.project200.data.local.UndabangDatabase +import com.project200.data.local.dao.ExerciseCountDao +import com.project200.data.local.dao.ExerciseRecordDao +import com.project200.data.local.dao.ServerIdLocalId +import com.project200.data.local.entity.SyncState +import com.project200.domain.model.ExerciseListItem +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.just +import io.mockk.mockkStatic +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime + +@ExperimentalCoroutinesApi +class ExerciseRecordLocalDataSourceTest { + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + private lateinit var database: UndabangDatabase + + @MockK + private lateinit var exerciseCountDao: ExerciseCountDao + + @MockK(relaxUnitFun = true) + private lateinit var exerciseRecordDao: ExerciseRecordDao + + @MockK + private lateinit var preferenceManager: PreferenceManager + + private lateinit var dataSource: ExerciseRecordLocalDataSource + + @Before + fun setUp() { + // withTransaction은 RoomDatabase 확장 함수라 블록을 그대로 실행하도록 바꿉니다 + mockkStatic("androidx.room.RoomDatabaseKt") + val transaction = slot Any>() + coEvery { database.withTransaction(capture(transaction)) } coAnswers { transaction.captured.invoke() } + every { preferenceManager.getMemberId() } returns MEMBER_ID + + coEvery { exerciseRecordDao.getPendingServerIds(any(), any(), any()) } returns emptyList() + coEvery { exerciseRecordDao.getSyncedByServerIds(any(), any(), any()) } returns emptyList() + coEvery { exerciseRecordDao.getSyncedByDate(any(), any(), any()) } returns emptyList() + + dataSource = + ExerciseRecordLocalDataSource(database, exerciseCountDao, exerciseRecordDao, preferenceManager) + } + + @Test + fun `전송 대기 행이 걸린 기록은 서버 값으로 덮이지 않는다`() = + runTest { + // Given: 기록 2는 아직 못 올린 로컬 변경이 걸려 있다 + coEvery { exerciseRecordDao.getPendingServerIds(MEMBER_ID, DATE, SyncState.SYNCED) } returns listOf(2L) + val captured = slot>() + coEvery { exerciseRecordDao.upsertListItems(capture(captured)) } just Runs + + // When + dataSource.replaceSyncedListByDate(DATE, listOf(itemOf(1L), itemOf(2L), itemOf(3L))) + + // Then: 서버가 준 기록 2는 반영 대상에서 빠진다 + assertThat(captured.captured.map { it.serverId }).containsExactly(1L, 3L) + } + + @Test + fun `이미 캐시된 기록은 localId를 이어 쓴다`() = + runTest { + // Given + coEvery { exerciseRecordDao.getSyncedByServerIds(MEMBER_ID, listOf(1L), SyncState.SYNCED) } returns + listOf(ServerIdLocalId(serverId = 1L, localId = "keep-me")) + val captured = slot>() + coEvery { exerciseRecordDao.upsertListItems(capture(captured)) } just Runs + + // When + dataSource.replaceSyncedListByDate(DATE, listOf(itemOf(1L))) + + // Then + assertThat(captured.captured.single().localId).isEqualTo("keep-me") + } + + @Test + fun `처음 보는 기록은 새 localId를 받는다`() = + runTest { + // Given + val captured = slot>() + coEvery { exerciseRecordDao.upsertListItems(capture(captured)) } just Runs + + // When + dataSource.replaceSyncedListByDate(DATE, listOf(itemOf(9L))) + + // Then + assertThat(captured.captured.single().localId).isNotEmpty() + assertThat(captured.captured.single().syncState).isEqualTo(SyncState.SYNCED) + } + + @Test + fun `서버 목록에서 빠진 기록은 상세도 지운다`() = + runTest { + // Given: 캐시에는 1, 2가 있는데 서버는 1만 준다 + coEvery { exerciseRecordDao.getSyncedByDate(MEMBER_ID, DATE, SyncState.SYNCED) } returns + listOf(ServerIdLocalId(1L, "local-1"), ServerIdLocalId(2L, "local-2")) + coEvery { exerciseRecordDao.upsertListItems(any()) } just Runs + + // When + dataSource.replaceSyncedListByDate(DATE, listOf(itemOf(1L))) + + // Then + coVerify(exactly = 1) { exerciseRecordDao.deleteDetailsByLocalIds(MEMBER_ID, listOf("local-2")) } + } + + @Test + fun `회원ID가 없으면 아무것도 쓰지 않는다`() = + runTest { + // Given + every { preferenceManager.getMemberId() } returns null + + // When + dataSource.replaceSyncedListByDate(DATE, listOf(itemOf(1L))) + + // Then + coVerify(exactly = 0) { exerciseRecordDao.upsertListItems(any()) } + coVerify(exactly = 0) { exerciseRecordDao.deleteSyncedListByDate(any(), any(), any()) } + } + + private fun itemOf(recordId: Long) = + ExerciseListItem( + recordId = recordId, + title = "달리기", + type = "PERSONAL", + startTime = LocalDateTime.parse("2026-08-17T10:00:00"), + endTime = LocalDateTime.parse("2026-08-17T11:00:00"), + imageUrl = null, + ) + + companion object { + private const val MEMBER_ID = "member-a" + private val DATE: LocalDate = LocalDate.parse("2026-08-17") + } +}