refactor: 优化前端支付与套餐相关
- 优化PaymentRepository、ApiService、PaymentModels - 优化PlanDetailsCard、PersonalInfoScreen、PlanBalanceScreen、ProfileScreen、UpgradePlanScreen - 优化PaymentViewModel Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付订单状态
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" -> "专业版"
|
||||
|
||||
@@ -126,7 +126,7 @@ fun PlanBalanceScreen(
|
||||
color = SlatePurple
|
||||
)
|
||||
Text(
|
||||
text = plan.displayName,
|
||||
text = plan.planName,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MediumPurple
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user