refactor: 优化前端支付与套餐相关

- 优化PaymentRepository、ApiService、PaymentModels
- 优化PlanDetailsCard、PersonalInfoScreen、PlanBalanceScreen、ProfileScreen、UpgradePlanScreen
- 优化PaymentViewModel

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iammm0
2026-02-11 16:06:32 +08:00
parent c0ca5e0dbd
commit 762a27326b
9 changed files with 147 additions and 38 deletions

View File

@@ -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<PlanDto> {
suspend fun getCurrentPlan(): Result<CurrentPlanDto> {
return apiService.getCurrentPlan()
}

View File

@@ -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<PlanDto> {
suspend fun getCurrentPlan(): Result<CurrentPlanDto> {
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,12 +262,40 @@ class ApiService(
contentType(ContentType.Application.Json)
setBody(CreatePaymentOrderRequest(planId, paymentMethod))
}
if (!response.status.isSuccess()) {
val errorBody = response.body<String>()
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)
}
}
/**
* 查询支付订单状态
*/

View File

@@ -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<String> = 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<String> = emptyList(),
val usage: Map<String, JsonElement>? = 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
)

View File

@@ -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(

View File

@@ -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" -> "专业版"

View File

@@ -126,7 +126,7 @@ fun PlanBalanceScreen(
color = SlatePurple
)
Text(
text = plan.displayName,
text = plan.planName,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = MediumPurple

View File

@@ -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) }
)
}

View File

@@ -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

View File

@@ -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<List<PlanDto>>(emptyList())
val plans: StateFlow<List<PlanDto>> = _plans
private val _currentPlan = MutableStateFlow<PlanDto?>(null)
val currentPlan: StateFlow<PlanDto?> = _currentPlan
private val _currentPlan = MutableStateFlow<CurrentPlanDto?>(null)
val currentPlan: StateFlow<CurrentPlanDto?> = _currentPlan
private val _quota = MutableStateFlow<QuotaCheckDto?>(null)
val quota: StateFlow<QuotaCheckDto?> = _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)
}
)
}