feat: 扩展前端网络层服务

- 扩展ApiService添加新接口
- 优化AuthService认证服务
- 增强WebSocketClient连接和消息处理
- 更新AuthModels和ConversationModels数据模型
This commit is contained in:
iammm0
2026-01-23 14:02:42 +08:00
parent 4a8f1a3b88
commit 09c50bc320
5 changed files with 293 additions and 43 deletions

View File

@@ -1,7 +1,6 @@
package com.huaga.life_echo.network
import com.huaga.life_echo.data.auth.TokenManager
import com.huaga.life_echo.data.mock.MockDataProvider
import com.huaga.life_echo.network.interceptors.AuthInterceptorPlugin
import com.huaga.life_echo.network.models.*
import io.ktor.client.*
@@ -44,6 +43,17 @@ class ApiService(
// ==================== 对话相关API ====================
suspend fun createConversation(): Result<CreateConversationResponse> {
return try {
val response = client.post("$BASE_URL/api/conversations") {
contentType(ContentType.Application.Json)
}
Result.success(response.body())
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun getConversationList(): Result<List<ConversationListItemDto>> {
return try {
val response = client.get("$BASE_URL/api/conversations") {
@@ -51,8 +61,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
// 接口失败时返回Mock数据
Result.success(MockDataProvider.getMockConversations())
Result.failure(e)
}
}
@@ -63,7 +72,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockConversationDetail(id))
Result.failure(e)
}
}
@@ -74,7 +83,33 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockMessages(conversationId))
Result.failure(e)
}
}
suspend fun deleteConversation(conversationId: String): Result<Unit> {
return try {
val response = client.delete("$BASE_URL/api/conversations/$conversationId") {
contentType(ContentType.Application.Json)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun organizeConversation(conversationId: String): Result<Unit> {
return try {
val response = client.post("$BASE_URL/api/conversations/$conversationId/organize") {
contentType(ContentType.Application.Json)
}
if (response.status.isSuccess()) {
Result.success(Unit)
} else {
Result.failure(Exception("整理失败: ${response.status}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
@@ -87,7 +122,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockBookInfo())
Result.failure(e)
}
}
@@ -98,7 +133,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockChapters())
Result.failure(e)
}
}
@@ -109,8 +144,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
val chapters = MockDataProvider.getMockChapters()
Result.success(chapters.find { it.id == id } ?: chapters[0])
Result.failure(e)
}
}
@@ -121,7 +155,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockChapterContent(id))
Result.failure(e)
}
}
@@ -133,8 +167,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
// 更新失败时返回Mock数据
Result.success(MockDataProvider.getMockBookInfo())
Result.failure(e)
}
}
@@ -159,7 +192,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockPlans())
Result.failure(e)
}
}
@@ -170,7 +203,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockCurrentPlan())
Result.failure(e)
}
}
@@ -181,7 +214,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockQuotaCheck())
Result.failure(e)
}
}
@@ -204,7 +237,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockOrders())
Result.failure(e)
}
}
@@ -215,8 +248,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
val orders = MockDataProvider.getMockOrders()
Result.success(orders.find { it.id == orderId } ?: orders[0])
Result.failure(e)
}
}
@@ -229,7 +261,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockUserProfile())
Result.failure(e)
}
}
@@ -240,7 +272,7 @@ class ApiService(
}
Result.success(response.body())
} catch (e: Exception) {
Result.success(MockDataProvider.getMockFAQs())
Result.failure(e)
}
}

View File

@@ -7,21 +7,27 @@ import com.huaga.life_echo.network.models.RefreshTokenRequest
import com.huaga.life_echo.network.models.RegisterRequest
import com.huaga.life_echo.network.models.TokenResponse
import com.huaga.life_echo.network.models.UserResponse
import com.huaga.life_echo.network.models.ValidationErrorResponse
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.forms.*
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.http.Headers
import io.ktor.http.append
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import java.io.File
/**
* 认证服务
@@ -66,8 +72,29 @@ class AuthService {
Result.success(tokenResponse)
}
HttpStatusCode.BadRequest -> {
// 尝试解析简单错误响应
try {
val error = response.body<ErrorResponse>()
Result.failure(Exception(error.detail))
} catch (e: Exception) {
Result.failure(Exception("注册失败: ${response.status}"))
}
}
HttpStatusCode.UnprocessableEntity -> {
// FastAPI验证错误422- 尝试解析验证错误详情
try {
val validationError = response.body<ValidationErrorResponse>()
val errorMessages = validationError.detail.joinToString(", ") { it.msg }
Result.failure(Exception("数据验证失败: $errorMessages"))
} catch (e: Exception) {
// 如果无法解析为验证错误,尝试解析为简单错误
try {
val error = response.body<ErrorResponse>()
Result.failure(Exception(error.detail))
} catch (e2: Exception) {
Result.failure(Exception("请求格式错误: ${response.status}"))
}
}
}
else -> {
Result.failure(Exception("注册失败: ${response.status}"))
@@ -97,8 +124,29 @@ class AuthService {
Result.failure(Exception("手机号或密码错误"))
}
HttpStatusCode.BadRequest -> {
// 尝试解析简单错误响应
try {
val error = response.body<ErrorResponse>()
Result.failure(Exception(error.detail))
} catch (e: Exception) {
Result.failure(Exception("请求错误: ${response.status}"))
}
}
HttpStatusCode.UnprocessableEntity -> {
// FastAPI验证错误422- 尝试解析验证错误详情
try {
val validationError = response.body<ValidationErrorResponse>()
val errorMessages = validationError.detail.joinToString(", ") { it.msg }
Result.failure(Exception("数据验证失败: $errorMessages"))
} catch (e: Exception) {
// 如果无法解析为验证错误,尝试解析为简单错误
try {
val error = response.body<ErrorResponse>()
Result.failure(Exception(error.detail))
} catch (e2: Exception) {
Result.failure(Exception("请求格式错误: ${response.status}"))
}
}
}
else -> {
Result.failure(Exception("登录失败: ${response.status}"))
@@ -188,4 +236,53 @@ class AuthService {
Result.failure(Exception("网络错误: ${e.message}", e))
}
}
/**
* 上传用户头像
*/
suspend fun uploadAvatar(accessToken: String, imageFile: File): Result<UserResponse> {
return try {
val response = client.post("$AUTH_BASE/me/avatar") {
header("Authorization", "Bearer $accessToken")
setBody(
MultiPartFormDataContent(
formData {
append(
key = "file",
headers = Headers.build {
append(HttpHeaders.ContentType, "image/jpeg")
append(HttpHeaders.ContentDisposition, "form-data; name=\"file\"; filename=\"${imageFile.name}\"")
}
) {
imageFile.readBytes()
}
}
)
)
}
when (response.status) {
HttpStatusCode.OK -> {
val userResponse = response.body<UserResponse>()
Result.success(userResponse)
}
HttpStatusCode.BadRequest -> {
try {
val error = response.body<ErrorResponse>()
Result.failure(Exception(error.detail))
} catch (e: Exception) {
Result.failure(Exception("上传失败: ${response.status}"))
}
}
HttpStatusCode.Unauthorized -> {
Result.failure(Exception("未授权"))
}
else -> {
Result.failure(Exception("上传头像失败: ${response.status}"))
}
}
} catch (e: Exception) {
Result.failure(Exception("网络错误: ${e.message}", e))
}
}
}

View File

@@ -1,7 +1,7 @@
package com.huaga.life_echo.network
import io.ktor.client.*
import io.ktor.client.engine.android.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.websocket.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
@@ -15,9 +15,10 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import android.util.Log
class WebSocketClient {
private val client = HttpClient(Android) {
private val client = HttpClient(OkHttp) {
install(WebSockets)
install(ContentNegotiation) {
json(Json {
@@ -39,8 +40,10 @@ class WebSocketClient {
private var currentToken: String? = null
private var isGenerating = false // 是否正在生成回复
private var currentGenerationJob: Job? = null // 当前生成任务
private var onErrorCallback: ((String) -> Unit)? = null // 错误回调
companion object {
private const val TAG = "WebSocketClient"
private const val BASE_URL = com.huaga.life_echo.config.AppConfig.WS_BASE_URL
private const val RECONNECT_DELAY_MS = 3000L
private const val MAX_RECONNECT_ATTEMPTS = 5
@@ -49,8 +52,14 @@ class WebSocketClient {
suspend fun connect(
conversationId: String,
token: String? = null,
onMessage: (WebSocketMessage) -> Unit
onMessage: (WebSocketMessage) -> Unit,
onError: ((String) -> Unit)? = null
) {
// 如果已连接,先断开
if (isConnected) {
disconnect()
}
val baseUrl = "$BASE_URL/ws/conversation/$conversationId"
val url = if (token != null) {
"$baseUrl?token=$token"
@@ -58,61 +67,141 @@ class WebSocketClient {
baseUrl
}
onErrorCallback = onError
Log.d(TAG, "开始连接WebSocket: $url")
try {
// 建立WebSocket连接
session = client.webSocketSession {
url {
takeFrom(url)
}
}
Log.d(TAG, "WebSocket session创建成功")
currentConversationId = conversationId
currentToken = token
isConnected = true
// 启动消息接收协程
scope.launch {
// 启动消息接收协程在设置isConnected之前
val receiveJob = scope.launch {
receiveMessages(onMessage)
}
// 发送连接消息
// 等待一小段时间确保连接建立
delay(100)
// 检查session是否有效
if (session == null || session?.isActive != true) {
Log.e(TAG, "WebSocket连接失败session无效")
throw Exception("WebSocket连接失败session无效")
}
Log.d(TAG, "WebSocket session有效准备发送连接消息")
// 发送连接消息不等待确认由服务器返回connect消息时再设置isConnected
try {
sendMessage(WebSocketMessage(
type = MessageType.connect,
conversation_id = conversationId,
data = buildJsonObject { put("status", JsonPrimitive("connected")) }
))
Log.d(TAG, "连接消息已发送,等待服务器确认")
} catch (e: Exception) {
// 发送失败,连接可能有问题
Log.e(TAG, "发送连接消息失败: ${e.message}", e)
isConnected = false
receiveJob.cancel()
throw Exception("发送连接消息失败: ${e.message}")
}
// 注意isConnected将在收到服务器的connect确认消息时设置为true
} catch (e: Exception) {
Log.e(TAG, "WebSocket连接异常: ${e.message}", e)
isConnected = false
session?.close()
session = null
onErrorCallback?.invoke("连接失败: ${e.message}")
throw e
}
}
private suspend fun receiveMessages(onMessage: (WebSocketMessage) -> Unit) {
try {
while (isConnected) {
// 持续接收消息,直到连接关闭
while (true) {
val frame = session?.incoming?.receive() as? Frame.Text
?: break
val message = Json.decodeFromString<WebSocketMessage>(frame.readText())
try {
val text = frame.readText()
Log.d(TAG, "收到WebSocket消息: $text")
val message = Json.decodeFromString<WebSocketMessage>(text)
// 如果收到connect消息设置连接状态为已连接
if (message.type == MessageType.connect) {
isConnected = true
Log.d(TAG, "WebSocket连接已确认状态设置为已连接")
}
// 如果收到error消息设置连接状态为未连接
if (message.type == MessageType.error) {
isConnected = false
val errorMsg = message.getString("message") ?: "未知错误"
Log.e(TAG, "收到错误消息: $errorMsg")
onErrorCallback?.invoke(errorMsg)
}
Log.d(TAG, "处理消息类型: ${message.type}")
onMessage(message)
messageFlow.emit(message)
} catch (e: Exception) {
// JSON解析失败记录错误但继续接收
Log.e(TAG, "消息解析失败: ${e.message}", e)
onErrorCallback?.invoke("消息解析失败: ${e.message}")
}
} catch (_: Exception) {
}
} catch (e: Exception) {
// 连接异常
Log.e(TAG, "接收消息异常: ${e.message}", e)
isConnected = false
val errorMsg = "连接异常: ${e.message}"
onErrorCallback?.invoke(errorMsg)
// 触发重连
reconnectJob?.cancel()
reconnectJob = scope.launch {
Log.d(TAG, "开始重连...")
reconnectWithBackoff(onMessage)
}
}
}
suspend fun sendMessage(message: WebSocketMessage) {
if (!isConnected && message.type != MessageType.connect) {
Log.w(TAG, "尝试发送消息但未连接: ${message.type}")
throw Exception("WebSocket未连接无法发送消息")
}
if (session == null || session?.isActive != true) {
Log.w(TAG, "WebSocket会话无效")
isConnected = false
throw Exception("WebSocket会话无效")
}
try {
val json = Json.encodeToString(WebSocketMessage.serializer(), message)
Log.d(TAG, "发送消息: ${message.type}, 内容: $json")
session?.send(Frame.Text(json))
Log.d(TAG, "消息发送成功: ${message.type}")
} catch (e: Exception) {
// 处理发送失败
// 发送失败,可能连接已断开
Log.e(TAG, "发送消息失败: ${e.message}", e)
isConnected = false
onErrorCallback?.invoke("发送消息失败: ${e.message}")
throw e
}
}
@@ -127,6 +216,12 @@ class WebSocketClient {
}
suspend fun sendTextMessage(text: String, conversationId: String) {
Log.d(TAG, "准备发送文本消息: $text")
if (!isConnected) {
Log.w(TAG, "WebSocket未连接无法发送文本消息")
throw Exception("WebSocket未连接请先建立连接")
}
sendMessage(WebSocketMessage(
type = MessageType.text,
conversation_id = conversationId,
@@ -168,6 +263,7 @@ class WebSocketClient {
}
suspend fun disconnect() {
Log.d(TAG, "断开WebSocket连接")
isConnected = false
isGenerating = false
reconnectJob?.cancel()
@@ -176,6 +272,7 @@ class WebSocketClient {
session = null
currentConversationId = null
currentToken = null
Log.d(TAG, "WebSocket连接已断开")
}
private suspend fun reconnectWithBackoff(

View File

@@ -48,8 +48,23 @@ data class UserResponse(
val created_at: String
)
// 错误响应
// 错误响应 - 支持简单字符串格式
@Serializable
data class ErrorResponse(
val detail: String
)
// FastAPI验证错误详情
@Serializable
data class ValidationErrorDetail(
val type: String,
val loc: List<String>,
val msg: String,
val input: String? = null
)
// FastAPI验证错误响应422
@Serializable
data class ValidationErrorResponse(
val detail: List<ValidationErrorDetail>
)

View File

@@ -29,6 +29,15 @@ data class MessageDto(
val messageType: String = "text" // "text", "audio", "image"
)
// 创建对话响应DTO
@Serializable
data class CreateConversationResponse(
val id: String,
val user_id: String,
val started_at: String,
val status: String
)
// 对话详情DTO
@Serializable
data class ConversationDetailDto(