新增: 抽取 Memoir、Profile、Payment REST 端口

按功能划分的 API 端口接口将仓库和 ViewModel 与 ApiService 解耦。
适配器在底层委托给 ApiService;所有依赖由 AppContainer 统一装配。
This commit is contained in:
Kevin
2026-03-12 10:36:12 +08:00
parent 493dedda47
commit cfccaf3a9d
10 changed files with 240 additions and 56 deletions

View File

@@ -1,6 +1,6 @@
package com.huaga.life_echo.data.repository
import com.huaga.life_echo.network.ApiService
import com.huaga.life_echo.feature.payment.ports.PaymentApiPort
import com.huaga.life_echo.network.models.CreatePaymentOrderResponse
import com.huaga.life_echo.network.models.CurrentPlanDto
import com.huaga.life_echo.network.models.OrderDto
@@ -10,62 +10,48 @@ import com.huaga.life_echo.network.models.QuotaCheckDto
import com.huaga.life_echo.network.models.TestSubscriptionResponse
class PaymentRepository(
private val apiService: ApiService
private val paymentApi: PaymentApiPort
) {
suspend fun getPlans(): Result<List<PlanDto>> {
return apiService.getPlans()
return paymentApi.getPlans()
}
suspend fun getCurrentPlan(): Result<CurrentPlanDto> {
return apiService.getCurrentPlan()
return paymentApi.getCurrentPlan()
}
suspend fun checkQuota(): Result<QuotaCheckDto> {
return apiService.checkQuota()
return paymentApi.checkQuota()
}
suspend fun createOrder(planId: String): Result<OrderDto> {
return apiService.createOrder(planId)
return paymentApi.createOrder(planId)
}
suspend fun getOrders(): Result<List<OrderDto>> {
return apiService.getOrders()
return paymentApi.getOrders()
}
suspend fun getOrderById(orderId: String): Result<OrderDto> {
return apiService.getOrderById(orderId)
return paymentApi.getOrderById(orderId)
}
// ==================== 支付订单(微信/支付宝) ====================
/**
* 创建支付订单(获取支付参数)
*/
suspend fun createPaymentOrder(
planId: String,
paymentMethod: String
): Result<CreatePaymentOrderResponse> {
return apiService.createPaymentOrder(planId, paymentMethod)
return paymentApi.createPaymentOrder(planId, paymentMethod)
}
/**
* 查询支付订单状态
*/
suspend fun getPaymentOrderStatus(orderId: String): Result<PaymentOrderStatusResponse> {
return apiService.getPaymentOrderStatus(orderId)
return paymentApi.getPaymentOrderStatus(orderId)
}
/**
* 获取支付订单列表
*/
suspend fun getPaymentOrders(): Result<List<PaymentOrderStatusResponse>> {
return apiService.getPaymentOrders()
return paymentApi.getPaymentOrders()
}
/**
* 测试订阅开关(仅当服务端开启时可用)
*/
suspend fun setTestSubscription(action: String, planId: String = "pro"): Result<TestSubscriptionResponse> {
return apiService.setTestSubscription(action, planId)
return paymentApi.setTestSubscription(action, planId)
}
}

View File

@@ -1,21 +1,21 @@
package com.huaga.life_echo.data.repository
import com.huaga.life_echo.network.ApiService
import com.huaga.life_echo.feature.profile.ports.ProfileApiPort
import com.huaga.life_echo.network.models.FAQDto
import com.huaga.life_echo.network.models.UserProfileDto
class ProfileRepository(
private val apiService: ApiService
private val profileApi: ProfileApiPort
) {
suspend fun getUserProfile(): Result<UserProfileDto> {
return apiService.getUserProfile()
return profileApi.getUserProfile()
}
suspend fun getFAQs(): Result<List<FAQDto>> {
return apiService.getFAQs()
return profileApi.getFAQs()
}
suspend fun submitFeedback(content: String, contact: String?): Result<Unit> {
return apiService.submitFeedback(content, contact)
return profileApi.submitFeedback(content, contact)
}
}

View File

@@ -0,0 +1,24 @@
package com.huaga.life_echo.feature.memoir.adapters
import com.huaga.life_echo.feature.memoir.ports.MemoirApiPort
import com.huaga.life_echo.network.ApiService
import com.huaga.life_echo.network.models.BookDto
import com.huaga.life_echo.network.models.ChapterContentDto
import com.huaga.life_echo.network.models.ChapterDto
import com.huaga.life_echo.network.models.MemoirStateDto
import com.huaga.life_echo.network.models.TasksStatusDto
class MemoirApiAdapter(
private val apiService: ApiService,
) : MemoirApiPort {
override suspend fun getBookInfo(): Result<BookDto> = apiService.getBookInfo()
override suspend fun getChapters(): Result<List<ChapterDto>> = apiService.getChapters()
override suspend fun getChapterById(id: String): Result<ChapterDto> = apiService.getChapterById(id)
override suspend fun getChapterContent(id: String): Result<ChapterContentDto> = apiService.getChapterContent(id)
override suspend fun updateBookTitle(bookId: String, title: String, subtitle: String?): Result<BookDto> =
apiService.updateBookTitle(bookId, title, subtitle)
override suspend fun disableChapter(chapterId: String): Result<Unit> = apiService.disableChapter(chapterId)
override suspend fun exportPdf(bookId: String): Result<ByteArray> = apiService.exportPdf(bookId)
override suspend fun getMemoirState(): Result<MemoirStateDto> = apiService.getMemoirState()
override suspend fun getTasksStatus(): Result<TasksStatusDto> = apiService.getTasksStatus()
}

View File

@@ -0,0 +1,19 @@
package com.huaga.life_echo.feature.memoir.ports
import com.huaga.life_echo.network.models.BookDto
import com.huaga.life_echo.network.models.ChapterContentDto
import com.huaga.life_echo.network.models.ChapterDto
import com.huaga.life_echo.network.models.MemoirStateDto
import com.huaga.life_echo.network.models.TasksStatusDto
interface MemoirApiPort {
suspend fun getBookInfo(): Result<BookDto>
suspend fun getChapters(): Result<List<ChapterDto>>
suspend fun getChapterById(id: String): Result<ChapterDto>
suspend fun getChapterContent(id: String): Result<ChapterContentDto>
suspend fun updateBookTitle(bookId: String, title: String, subtitle: String? = null): Result<BookDto>
suspend fun disableChapter(chapterId: String): Result<Unit>
suspend fun exportPdf(bookId: String): Result<ByteArray>
suspend fun getMemoirState(): Result<MemoirStateDto>
suspend fun getTasksStatus(): Result<TasksStatusDto>
}

View File

@@ -0,0 +1,29 @@
package com.huaga.life_echo.feature.payment.adapters
import com.huaga.life_echo.feature.payment.ports.PaymentApiPort
import com.huaga.life_echo.network.ApiService
import com.huaga.life_echo.network.models.CreatePaymentOrderResponse
import com.huaga.life_echo.network.models.CurrentPlanDto
import com.huaga.life_echo.network.models.OrderDto
import com.huaga.life_echo.network.models.PaymentOrderStatusResponse
import com.huaga.life_echo.network.models.PlanDto
import com.huaga.life_echo.network.models.QuotaCheckDto
import com.huaga.life_echo.network.models.TestSubscriptionResponse
class PaymentApiAdapter(
private val apiService: ApiService,
) : PaymentApiPort {
override suspend fun getPlans(): Result<List<PlanDto>> = apiService.getPlans()
override suspend fun getCurrentPlan(): Result<CurrentPlanDto> = apiService.getCurrentPlan()
override suspend fun checkQuota(): Result<QuotaCheckDto> = apiService.checkQuota()
override suspend fun createOrder(planId: String): Result<OrderDto> = apiService.createOrder(planId)
override suspend fun getOrders(): Result<List<OrderDto>> = apiService.getOrders()
override suspend fun getOrderById(orderId: String): Result<OrderDto> = apiService.getOrderById(orderId)
override suspend fun createPaymentOrder(planId: String, paymentMethod: String): Result<CreatePaymentOrderResponse> =
apiService.createPaymentOrder(planId, paymentMethod)
override suspend fun getPaymentOrderStatus(orderId: String): Result<PaymentOrderStatusResponse> =
apiService.getPaymentOrderStatus(orderId)
override suspend fun getPaymentOrders(): Result<List<PaymentOrderStatusResponse>> = apiService.getPaymentOrders()
override suspend fun setTestSubscription(action: String, planId: String): Result<TestSubscriptionResponse> =
apiService.setTestSubscription(action, planId)
}

View File

@@ -0,0 +1,22 @@
package com.huaga.life_echo.feature.payment.ports
import com.huaga.life_echo.network.models.CreatePaymentOrderResponse
import com.huaga.life_echo.network.models.CurrentPlanDto
import com.huaga.life_echo.network.models.OrderDto
import com.huaga.life_echo.network.models.PaymentOrderStatusResponse
import com.huaga.life_echo.network.models.PlanDto
import com.huaga.life_echo.network.models.QuotaCheckDto
import com.huaga.life_echo.network.models.TestSubscriptionResponse
interface PaymentApiPort {
suspend fun getPlans(): Result<List<PlanDto>>
suspend fun getCurrentPlan(): Result<CurrentPlanDto>
suspend fun checkQuota(): Result<QuotaCheckDto>
suspend fun createOrder(planId: String): Result<OrderDto>
suspend fun getOrders(): Result<List<OrderDto>>
suspend fun getOrderById(orderId: String): Result<OrderDto>
suspend fun createPaymentOrder(planId: String, paymentMethod: String): Result<CreatePaymentOrderResponse>
suspend fun getPaymentOrderStatus(orderId: String): Result<PaymentOrderStatusResponse>
suspend fun getPaymentOrders(): Result<List<PaymentOrderStatusResponse>>
suspend fun setTestSubscription(action: String, planId: String = "pro"): Result<TestSubscriptionResponse>
}

View File

@@ -0,0 +1,15 @@
package com.huaga.life_echo.feature.profile.adapters
import com.huaga.life_echo.feature.profile.ports.ProfileApiPort
import com.huaga.life_echo.network.ApiService
import com.huaga.life_echo.network.models.FAQDto
import com.huaga.life_echo.network.models.UserProfileDto
class ProfileApiAdapter(
private val apiService: ApiService,
) : ProfileApiPort {
override suspend fun getUserProfile(): Result<UserProfileDto> = apiService.getUserProfile()
override suspend fun getFAQs(): Result<List<FAQDto>> = apiService.getFAQs()
override suspend fun submitFeedback(content: String, contact: String?): Result<Unit> =
apiService.submitFeedback(content, contact)
}

View File

@@ -0,0 +1,10 @@
package com.huaga.life_echo.feature.profile.ports
import com.huaga.life_echo.network.models.FAQDto
import com.huaga.life_echo.network.models.UserProfileDto
interface ProfileApiPort {
suspend fun getUserProfile(): Result<UserProfileDto>
suspend fun getFAQs(): Result<List<FAQDto>>
suspend fun submitFeedback(content: String, contact: String?): Result<Unit>
}

View File

@@ -6,18 +6,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.huaga.life_echo.data.database.Chapter
import com.huaga.life_echo.data.repository.ChapterRepository
import com.huaga.life_echo.network.ApiService
import com.huaga.life_echo.feature.memoir.ports.MemoirApiPort
import com.huaga.life_echo.network.models.MemoirStateDto
import com.huaga.life_echo.network.models.TasksStatusDto
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class MyMemoirViewModel(
private val chapterRepository: ChapterRepository,
private val apiService: ApiService
private val memoirApi: MemoirApiPort,
) : ViewModel() {
val chapters = chapterRepository.getAllChapters()
@@ -42,7 +40,7 @@ class MyMemoirViewModel(
viewModelScope.launch {
isLoading.value = true
error.value = null
apiService.exportPdf(bookId).fold(
memoirApi.exportPdf(bookId).fold(
onSuccess = { pdfBytes ->
isLoading.value = false
onSuccess(pdfBytes)
@@ -63,13 +61,11 @@ class MyMemoirViewModel(
isLoading.value = true
error.value = null
try {
val result = apiService.getChapters()
val result = memoirApi.getChapters()
result.fold(
onSuccess = { chapterDtosFromApi ->
// 保存ChapterDto包含images信息
chapterDtos.value = chapterDtosFromApi
// 转换为本地Chapter实体并保存
val chapters = chapterDtosFromApi.map { dto ->
Chapter(
id = dto.id,
@@ -102,15 +98,12 @@ class MyMemoirViewModel(
}
}
/**
* 加载回忆录状态
*/
fun loadMemoirState() {
viewModelScope.launch {
isLoading.value = true
error.value = null
try {
val result = apiService.getMemoirState()
val result = memoirApi.getMemoirState()
result.fold(
onSuccess = { state ->
memoirState.value = state
@@ -127,13 +120,10 @@ class MyMemoirViewModel(
}
}
/**
* 检查任务状态
*/
fun checkTasksStatus() {
viewModelScope.launch {
try {
val result = apiService.getTasksStatus()
val result = memoirApi.getTasksStatus()
result.fold(
onSuccess = { status ->
tasksStatus.value = status
@@ -150,7 +140,7 @@ class MyMemoirViewModel(
fun loadBookInfo() {
viewModelScope.launch {
apiService.getBookInfo().fold(
memoirApi.getBookInfo().fold(
onSuccess = { bookInfo.value = it },
onFailure = { }
)
@@ -160,7 +150,7 @@ class MyMemoirViewModel(
fun updateBookTitle(title: String, subtitle: String?) {
viewModelScope.launch {
bookInfo.value?.let { book ->
apiService.updateBookTitle(book.id, title, subtitle).fold(
memoirApi.updateBookTitle(book.id, title, subtitle).fold(
onSuccess = { bookInfo.value = it },
onFailure = { }
)
@@ -172,20 +162,14 @@ class MyMemoirViewModel(
showFullTextReading.value = !showFullTextReading.value
}
/**
* 清除章节(将章节标记为 disabled
* 成功后刷新章节列表并清除选中状态
*/
@RequiresApi(Build.VERSION_CODES.O)
fun disableChapter(chapterId: String, onSuccess: () -> Unit = {}, onError: (String) -> Unit = {}) {
viewModelScope.launch {
isLoading.value = true
try {
apiService.disableChapter(chapterId).fold(
memoirApi.disableChapter(chapterId).fold(
onSuccess = {
// 清除选中状态,回到目录
clearSelection()
// 刷新章节列表
refreshChapters()
onSuccess()
},

View File

@@ -0,0 +1,95 @@
package com.huaga.life_echo.ui.viewmodel
import com.huaga.life_echo.data.database.Chapter
import com.huaga.life_echo.data.database.ChapterDao
import com.huaga.life_echo.data.repository.ChapterRepository
import com.huaga.life_echo.feature.memoir.ports.MemoirApiPort
import com.huaga.life_echo.network.models.BookDto
import com.huaga.life_echo.network.models.ChapterContentDto
import com.huaga.life_echo.network.models.ChapterDto
import com.huaga.life_echo.network.models.MemoirStateDto
import com.huaga.life_echo.network.models.TasksStatusDto
import com.huaga.life_echo.testutil.MainDispatcherRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MyMemoirViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun refresh_chapters_uses_memoir_port_instead_of_raw_api_service() =
runTest(mainDispatcherRule.dispatcher.scheduler) {
val memoirApi = FakeMemoirApiPort(chaptersResult = Result.success(emptyList()))
val viewModel = MyMemoirViewModel(
chapterRepository = ChapterRepository(chapterDao = FakeChapterDao()),
memoirApi = memoirApi,
)
viewModel.refreshChapters()
advanceUntilIdle()
assertEquals(1, memoirApi.getChaptersCalls)
}
@Test
fun load_book_info_uses_memoir_port() =
runTest(mainDispatcherRule.dispatcher.scheduler) {
val book = BookDto(id = "b1", title = "Test Book")
val memoirApi = FakeMemoirApiPort(bookInfoResult = Result.success(book))
val viewModel = MyMemoirViewModel(
chapterRepository = ChapterRepository(chapterDao = FakeChapterDao()),
memoirApi = memoirApi,
)
viewModel.loadBookInfo()
advanceUntilIdle()
assertEquals(book, viewModel.bookInfo.value)
}
private class FakeMemoirApiPort(
private val chaptersResult: Result<List<ChapterDto>> = Result.success(emptyList()),
private val bookInfoResult: Result<BookDto> = Result.failure(Exception("not set")),
) : MemoirApiPort {
var getChaptersCalls = 0
private set
override suspend fun getBookInfo(): Result<BookDto> = bookInfoResult
override suspend fun getChapters(): Result<List<ChapterDto>> {
getChaptersCalls++
return chaptersResult
}
override suspend fun getChapterById(id: String): Result<ChapterDto> =
Result.failure(Exception("not implemented"))
override suspend fun getChapterContent(id: String): Result<ChapterContentDto> =
Result.failure(Exception("not implemented"))
override suspend fun updateBookTitle(bookId: String, title: String, subtitle: String?): Result<BookDto> =
Result.failure(Exception("not implemented"))
override suspend fun disableChapter(chapterId: String): Result<Unit> =
Result.failure(Exception("not implemented"))
override suspend fun exportPdf(bookId: String): Result<ByteArray> =
Result.failure(Exception("not implemented"))
override suspend fun getMemoirState(): Result<MemoirStateDto> =
Result.failure(Exception("not implemented"))
override suspend fun getTasksStatus(): Result<TasksStatusDto> =
Result.failure(Exception("not implemented"))
}
private class FakeChapterDao : ChapterDao {
override fun getAllChapters() = flowOf(emptyList<Chapter>())
override suspend fun getChapterById(id: String): Chapter? = null
override suspend fun getChaptersByCategory(category: String): List<Chapter> = emptyList()
override suspend fun insertChapter(chapter: Chapter) = Unit
override suspend fun insertChapters(chapters: List<Chapter>) = Unit
override suspend fun updateChapter(chapter: Chapter) = Unit
override suspend fun deleteChapter(chapter: Chapter) = Unit
}
}