From 762a27326bdff0a0eb42686b5f2dafb016eb06b2 Mon Sep 17 00:00:00 2001 From: iammm0 Date: Wed, 11 Feb 2026 16:06:32 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=94=AF=E4=BB=98=E4=B8=8E=E5=A5=97=E9=A4=90=E7=9B=B8?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化PaymentRepository、ApiService、PaymentModels - 优化PlanDetailsCard、PersonalInfoScreen、PlanBalanceScreen、ProfileScreen、UpgradePlanScreen - 优化PaymentViewModel Co-authored-by: Cursor --- .../data/repository/PaymentRepository.kt | 3 +- .../com/huaga/life_echo/network/ApiService.kt | 42 +++++++++++++++++- .../life_echo/network/models/PaymentModels.kt | 31 ++++++++----- .../ui/components/profile/PlanDetailsCard.kt | 43 ++++++++++++++++++- .../ui/screens/PersonalInfoScreen.kt | 2 +- .../life_echo/ui/screens/PlanBalanceScreen.kt | 2 +- .../life_echo/ui/screens/ProfileScreen.kt | 4 +- .../life_echo/ui/screens/UpgradePlanScreen.kt | 15 ++++--- .../ui/viewmodel/PaymentViewModel.kt | 43 +++++++++++++------ 9 files changed, 147 insertions(+), 38 deletions(-) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/repository/PaymentRepository.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/PaymentRepository.kt index fc54177..59bae6c 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/data/repository/PaymentRepository.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/PaymentRepository.kt @@ -2,6 +2,7 @@ package com.huaga.life_echo.data.repository 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 @@ -15,7 +16,7 @@ class PaymentRepository( return apiService.getPlans() } - suspend fun getCurrentPlan(): Result { + suspend fun getCurrentPlan(): Result { return apiService.getCurrentPlan() } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt index 31c607f..ee8bf4f 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt @@ -8,11 +8,15 @@ import io.ktor.client.call.* import io.ktor.client.engine.android.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive class ApiService( tokenManager: TokenManager? = null, @@ -27,6 +31,11 @@ class ApiService( install(Logging) { level = LogLevel.INFO } + install(HttpTimeout) { + requestTimeoutMillis = 45_000 // 单次请求总超时(如创建订单) + connectTimeoutMillis = 15_000 // 连接超时 + socketTimeoutMillis = 45_000 // 读写超时 + } // 如果提供了tokenManager和authService,安装认证拦截器 if (tokenManager != null && authService != null) { @@ -181,7 +190,7 @@ class ApiService( } } - suspend fun getCurrentPlan(): Result { + suspend fun getCurrentPlan(): Result { return try { val response = client.get("$BASE_URL/api/plans/current") { contentType(ContentType.Application.Json) @@ -241,7 +250,8 @@ class ApiService( /** * 创建支付订单 - * 返回微信支付参数或支付宝订单字符串 + * 返回微信支付参数或支付宝订单字符串。 + * 失败时尽量返回后端返回的 detail 信息,便于 debug。 */ suspend fun createPaymentOrder( planId: String, @@ -252,11 +262,39 @@ class ApiService( contentType(ContentType.Application.Json) setBody(CreatePaymentOrderRequest(planId, paymentMethod)) } + if (!response.status.isSuccess()) { + val errorBody = response.body() + val detail = parseApiErrorDetail(errorBody) + val msg = detail.ifEmpty { "请求失败: ${response.status}" } + return Result.failure(Exception(msg)) + } Result.success(response.body()) } catch (e: Exception) { + // 超时、网络异常等:保留原始异常信息 Result.failure(e) } } + + /** + * 解析 FastAPI 错误响应中的 detail 字段(字符串或数组),用于展示后端返回的详尽错误信息 + */ + private fun parseApiErrorDetail(errorBody: String?): String { + if (errorBody.isNullOrBlank()) return "" + return try { + val json = Json.parseToJsonElement(errorBody).jsonObject + val detail = json["detail"] ?: return "" + when { + detail is kotlinx.serialization.json.JsonPrimitive -> detail.content + detail is kotlinx.serialization.json.JsonArray && detail.isNotEmpty() -> { + val first = detail.firstOrNull()?.jsonObject + first?.get("msg")?.jsonPrimitive?.content ?: detail.toString() + } + else -> detail.toString() + } + } catch (_: Exception) { + errorBody.take(500) + } + } /** * 查询支付订单状态 diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/models/PaymentModels.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/models/PaymentModels.kt index c4e3165..78e1157 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/models/PaymentModels.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/models/PaymentModels.kt @@ -2,6 +2,7 @@ package com.huaga.life_echo.network.models import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement /** * 付费系统相关的数据模型 @@ -19,6 +20,17 @@ data class PlanBenefitDto( val features: List = emptyList() // 功能列表 ) +/** 当前套餐 DTO(与后端 /api/plans/current 返回的 CurrentPlanResponse 对应) */ +@Serializable +data class CurrentPlanDto( + @SerialName("plan_id") val planId: String, + @SerialName("plan_name") val planName: String, + @SerialName("subscription_type") val subscriptionType: String? = null, + @SerialName("expires_at") val expiresAt: String? = null, + val features: List = emptyList(), + val usage: Map? = null +) + // 套餐信息DTO(与后端 /api/plans 返回字段对应) @Serializable data class PlanDto( @@ -66,18 +78,17 @@ data class SubscriptionStatusDto( val daysRemaining: Int? = null ) -// 额度校验结果DTO +// 额度校验结果DTO(与后端 /api/quota/check 的 snake_case 对应) @Serializable data class QuotaCheckDto( - val hasQuota: Boolean, - val remainingWords: Int? = null, - val remainingChapters: Int? = null, - val remainingConversations: Int? = null, - // 已用量与上限(用于展示 "已用 X / 共 Y") - val usedConversations: Int? = null, - val usedChapters: Int? = null, - val maxConversations: Int? = null, - val maxChapters: Int? = null, + @SerialName("has_quota") val hasQuota: Boolean, + @SerialName("remaining_words") val remainingWords: Int? = null, + @SerialName("remaining_chapters") val remainingChapters: Int? = null, + @SerialName("remaining_conversations") val remainingConversations: Int? = null, + @SerialName("used_conversations") val usedConversations: Int? = null, + @SerialName("used_chapters") val usedChapters: Int? = null, + @SerialName("max_conversations") val maxConversations: Int? = null, + @SerialName("max_chapters") val maxChapters: Int? = null, val message: String? = null ) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/profile/PlanDetailsCard.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/profile/PlanDetailsCard.kt index e3be732..6576c28 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/profile/PlanDetailsCard.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/profile/PlanDetailsCard.kt @@ -11,11 +11,52 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.huaga.life_echo.network.models.CurrentPlanDto import com.huaga.life_echo.network.models.PlanDto import com.huaga.life_echo.utils.PaymentUtils /** - * 套餐详情卡片组件 + * 套餐详情卡片组件(当前套餐,来自 /api/plans/current) + */ +@Composable +fun PlanDetailsCard( + plan: CurrentPlanDto, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = plan.planName, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + if (plan.features.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + plan.features.forEach { feature -> + Text( + text = "• $feature", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +/** + * 套餐详情卡片组件(完整套餐信息,来自 /api/plans 列表) */ @Composable fun PlanDetailsCard( diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/PersonalInfoScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/PersonalInfoScreen.kt index 1d74a04..342d2b6 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/PersonalInfoScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/PersonalInfoScreen.kt @@ -124,7 +124,7 @@ fun PersonalInfoScreen( verticalAlignment = Alignment.CenterVertically ) { PlanStatusBadge( - planName = currentPlan?.displayName ?: when (currentUser?.subscription_type) { + planName = currentPlan?.planName ?: when (currentUser?.subscription_type) { "free" -> "免费体验版" "premium" -> "高级版" "professional" -> "专业版" diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/PlanBalanceScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/PlanBalanceScreen.kt index aa4cc41..7986acb 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/PlanBalanceScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/PlanBalanceScreen.kt @@ -126,7 +126,7 @@ fun PlanBalanceScreen( color = SlatePurple ) Text( - text = plan.displayName, + text = plan.planName, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MediumPurple diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt index f46663f..bb5a024 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt @@ -145,7 +145,7 @@ fun ProfileScreen( userProfile?.nickname ?: currentUser!!.nickname } else null, planName = if (isLoggedIn && currentUser != null) { - currentPlan?.displayName ?: when (currentUser!!.subscription_type) { + currentPlan?.planName ?: when (currentUser!!.subscription_type) { "free" -> "免费体验版" "pro" -> "Pro 版" "pro_plus" -> "Pro+ 版" @@ -180,7 +180,7 @@ fun ProfileScreen( SettingItem( icon = AppIcons.Star, label = "当前套餐", - description = plan.displayName, + description = plan.planName, onPress = { navController?.navigate(Screen.PlanDetails.route) } ) } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/UpgradePlanScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/UpgradePlanScreen.kt index 08439bc..e530ad5 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/UpgradePlanScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/UpgradePlanScreen.kt @@ -124,7 +124,7 @@ fun UpgradePlanScreen( if (AppConfig.isDebugMode) { item { TestSubscriptionSection( - currentPlanId = currentPlan?.id, + currentPlanId = currentPlan?.planId, isLoading = testSubscriptionLoading, message = testSubscriptionMessage, onSelectPlan = { planId -> viewModel.setTestPlan(planId) } @@ -151,7 +151,10 @@ fun UpgradePlanScreen( } } else { items( - items = plans, + items = plans.filter { plan -> + // 一分钱测试版仅在开发版本展示 + plan.id != "test" || AppConfig.isDebugMode + }, key = { plan -> plan.id } ) { plan -> val planWithCurrentFlag = com.huaga.life_echo.network.models.PlanDto( @@ -164,7 +167,7 @@ fun UpgradePlanScreen( benefits = plan.benefits, features = plan.features, isActive = plan.isActive, - isCurrentPlan = currentPlan?.id == plan.id, + isCurrentPlan = currentPlan?.planId == plan.id, maxConversations = plan.maxConversations, maxChapters = plan.maxChapters, isPopular = plan.isPopular @@ -172,7 +175,7 @@ fun UpgradePlanScreen( PlanCard( plan = planWithCurrentFlag, onClick = { - if (plan.id != currentPlan?.id && plan.price > 0) { + if (plan.id != currentPlan?.planId && plan.price > 0) { selectedPlanId = plan.id showPaymentMethodDialog = true } @@ -188,9 +191,7 @@ fun UpgradePlanScreen( PaymentMethodDialog( onSelectMethod = { method -> showPaymentMethodDialog = false - if (activity != null) { - viewModel.startPayment(activity, selectedPlanId!!, method) - } + viewModel.startPayment(activity, selectedPlanId!!, method) }, onDismiss = { showPaymentMethodDialog = false diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/PaymentViewModel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/PaymentViewModel.kt index 0d5de2e..f3828e9 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/PaymentViewModel.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/PaymentViewModel.kt @@ -4,6 +4,7 @@ import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.huaga.life_echo.data.repository.PaymentRepository +import com.huaga.life_echo.network.models.CurrentPlanDto import com.huaga.life_echo.network.models.OrderDto import com.huaga.life_echo.network.models.PlanDto import com.huaga.life_echo.network.models.QuotaCheckDto @@ -16,6 +17,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** * 支付状态 @@ -48,8 +50,8 @@ class PaymentViewModel( private val _plans = MutableStateFlow>(emptyList()) val plans: StateFlow> = _plans - private val _currentPlan = MutableStateFlow(null) - val currentPlan: StateFlow = _currentPlan + private val _currentPlan = MutableStateFlow(null) + val currentPlan: StateFlow = _currentPlan private val _quota = MutableStateFlow(null) val quota: StateFlow = _quota @@ -146,11 +148,11 @@ class PaymentViewModel( /** * 发起支付 * - * @param activity 当前 Activity + * @param activity 当前 Activity(微信支付可不传,支付宝支付必传) * @param planId 套餐 ID * @param paymentMethod "wechat" 或 "alipay" */ - fun startPayment(activity: Activity, planId: String, paymentMethod: String) { + fun startPayment(activity: Activity?, planId: String, paymentMethod: String) { if (paymentManager == null) { _paymentState.value = PaymentState.Failed("支付模块未初始化") return @@ -162,11 +164,24 @@ class PaymentViewModel( return } + // 微信支付不需要 Activity,可继续;支付宝需要 Activity + if (paymentMethod == "alipay" && activity == null) { + _paymentState.value = PaymentState.Failed("无法获取页面上下文,请重试") + return + } + viewModelScope.launch { _paymentState.value = PaymentState.CreatingOrder - // 1. 创建支付订单 - paymentRepository.createPaymentOrder(planId, paymentMethod).fold( + // 1. 创建支付订单(45 秒超时,避免卡在「正在创建订单」) + val orderResult = withTimeoutOrNull(45_000L) { + paymentRepository.createPaymentOrder(planId, paymentMethod) + } + if (orderResult == null) { + _paymentState.value = PaymentState.Failed("创建订单超时,请检查网络后重试") + return@launch + } + orderResult.fold( onSuccess = { response -> _paymentState.value = PaymentState.Paying(response.orderId, paymentMethod) @@ -182,11 +197,13 @@ class PaymentViewModel( } "alipay" -> { val orderString = response.alipayOrderString - if (!orderString.isNullOrEmpty()) { - invokeAlipay(activity, orderString, response.orderId) - } else { - _paymentState.value = - PaymentState.Failed("未获取到支付宝订单信息") + when { + orderString.isNullOrEmpty() -> + _paymentState.value = PaymentState.Failed("未获取到支付宝订单信息") + activity == null -> + _paymentState.value = PaymentState.Failed("无法获取页面上下文,请重试") + else -> + invokeAlipay(activity, orderString, response.orderId) } } else -> { @@ -196,8 +213,8 @@ class PaymentViewModel( } }, onFailure = { error -> - _paymentState.value = - PaymentState.Failed(error.message ?: "创建订单失败") + val message = error.message ?: "创建订单失败" + _paymentState.value = PaymentState.Failed(message) } ) }