Files
aiagent/docs/android-app-design.md
renjianbo a7512a5423 docs: update 3 reference docs + add Android design, Feishu bot config, productization plan
- Rewrite api-reference.md: 245 endpoints across 38 modules, correct auth paths and response format
- Rewrite 内置工具列表.md: all 56 real tools in 11 categories
- Fix quickstart.md: local dev ports (3001/8038) vs Docker (8037/8038), --port 8038
- Add android-app-design.md: Kotlin/Compose/MVVM design with SSE, FCM, voice
- Add 飞书智能体配置手册.md: all 6 bots config, capabilities, memory architecture
- Add 产品化落地方案.md: PWA/voice/push/Flutter productization roadmap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-13 21:58:10 +08:00

721 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Android App 设计文档
天工智能体平台 Android 客户端,提供 AI 对话、语音交互、推送通知等核心移动端能力。
---
## 一、技术选型
| 层面 | 选择 | 理由 |
|------|------|------|
| 语言 | Kotlin | Android 官方首选,协程天然适配 SSE 流式 |
| UI 框架 | Jetpack Compose | 声明式 UI与消息列表/流式更新天然契合 |
| 架构 | MVVM + Repository | ViewModel 管理 UI 状态Repository 统一数据源 |
| 网络 | OkHttp 4 + Retrofit 2 | Retrofit 处理 RESTOkHttp 拦截器处理 JWT/重登录 |
| SSE | 自定义 OkHttp EventSource | Retrofit 不原生支持 SSE需基于 OkHttp 手动解析 |
| 本地存储 | Room + DataStore | Room 存消息历史DataStore 存 Token/偏好 |
| 图片加载 | Coil | Kotlin 原生Compose 集成好 |
| 音频录制 | MediaRecorder | 系统原生 API无额外依赖 |
| 音频播放 | Media3 ExoPlayer | Google 官方推荐 |
| 推送 | Firebase Cloud Messaging | Google 官方,免费 |
---
## 二、项目结构
```
android/
├── app/
│ ├── build.gradle.kts
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── java/com/tiangong/aiagent/
│ │ ├── TiangongApp.kt # Application 类
│ │ ├── MainActivity.kt # 单 Activity 入口
│ │ │
│ │ ├── data/
│ │ │ ├── remote/
│ │ │ │ ├── ApiService.kt # Retrofit 接口定义
│ │ │ │ ├── SseClient.kt # SSE 流式解析器
│ │ │ │ └── AuthInterceptor.kt # JWT 注入 + 401 自动重登录
│ │ │ ├── local/
│ │ │ │ ├── AppDatabase.kt # Room 数据库
│ │ │ │ ├── MessageDao.kt # 消息 DAO
│ │ │ │ └── ConversationDao.kt # 会话 DAO
│ │ │ └── repository/
│ │ │ ├── AuthRepository.kt # 登录/Token 管理
│ │ │ ├── ChatRepository.kt # 对话/SSE 流式
│ │ │ ├── AgentRepository.kt # Agent 列表/详情
│ │ │ └── NotificationRepository.kt # 通知轮询
│ │ │
│ │ ├── domain/
│ │ │ └── model/
│ │ │ ├── Agent.kt # 智能体
│ │ │ ├── Message.kt # 消息user/assistant/tool/system
│ │ │ ├── Conversation.kt # 会话
│ │ │ ├── SseEvent.kt # SSE 事件密封类
│ │ │ ├── TokenUsage.kt # Token 用量
│ │ │ └── Notification.kt # 通知
│ │ │
│ │ ├── ui/
│ │ │ ├── navigation/
│ │ │ │ └── NavGraph.kt # 路由导航图
│ │ │ ├── theme/
│ │ │ │ └── Theme.kt # Material3 主题
│ │ │ ├── login/
│ │ │ │ ├── LoginScreen.kt
│ │ │ │ └── LoginViewModel.kt
│ │ │ ├── chat/
│ │ │ │ ├── ChatScreen.kt # 对话主界面
│ │ │ │ ├── ChatViewModel.kt # SSE 流式消费 + 消息管理
│ │ │ │ └── components/
│ │ │ │ ├── MessageBubble.kt # 气泡(支持 Markdown
│ │ │ │ ├── VoiceInputButton.kt # 语音录制按钮
│ │ │ │ ├── ToolCallCard.kt # 工具调用卡片
│ │ │ │ └── StreamingText.kt # 打字机流式文本
│ │ │ ├── agents/
│ │ │ │ ├── AgentListScreen.kt
│ │ │ │ └── AgentListViewModel.kt
│ │ │ └── settings/
│ │ │ └── SettingsScreen.kt
│ │ │
│ │ ├── util/
│ │ │ ├── AudioRecorder.kt # 录音工具MediaRecorder
│ │ │ ├── AudioPlayer.kt # TTS 播放器ExoPlayer
│ │ │ ├── MarkdownRenderer.kt # Markdown 渲染
│ │ │ └── FcmTokenManager.kt # FCM Token 注册/同步
│ │ │
│ │ └── di/
│ │ └── AppModule.kt # Hilt 依赖注入
│ │
│ └── res/
│ ├── values/strings.xml
│ └── drawable/ # 图标资源
├── build.gradle.kts # 根构建文件
├── settings.gradle.kts
└── gradle.properties
```
---
## 三、网络层设计
### 3.1 基础配置
```kotlin
// data/remote/ApiService.kt
interface ApiService {
// --- 认证 ---
@FormUrlEncoded
@POST("api/v1/auth/login")
suspend fun login(
@Field("username") username: String,
@Field("password") password: String
): LoginResponse
@GET("api/v1/auth/me")
suspend fun getCurrentUser(): UserResponse
// --- Agent ---
@GET("api/v1/agents")
suspend fun getAgents(
@Query("skip") skip: Int = 0,
@Query("limit") limit: Int = 100
): Response<List<AgentResponse>> // X-Total-Count 在 header
@GET("api/v1/agents/{agentId}")
suspend fun getAgent(@Path("agentId") agentId: String): AgentResponse
// --- 对话(非流式) ---
@POST("api/v1/agent-chat/{agentId}")
suspend fun chat(
@Path("agentId") agentId: String,
@Body request: ChatRequest
): ChatResponse
// --- 通知 ---
@GET("api/v1/notifications/unread-count")
suspend fun getUnreadCount(): UnreadCountResponse
@GET("api/v1/notifications")
suspend fun getNotifications(
@Query("unread_only") unreadOnly: Boolean = false,
@Query("limit") limit: Int = 50,
@Query("offset") offset: Int = 0
): List<NotificationResponse>
// --- FCM Token 注册(新增后端接口) ---
@POST("api/v1/push/register")
suspend fun registerFcmToken(@Body request: FcmRegisterRequest)
}
```
### 3.2 关键接口参数
**登录form-encoded** — 后端使用 OAuth2PasswordRequestForm不走 JSON
```kotlin
// 请求
data class LoginRequest(
val username: String, // Field: username
val password: String // Field: password
)
// 响应
data class LoginResponse(
val access_token: String,
val token_type: String // "bearer"
)
```
**对话请求/响应**
```kotlin
data class ChatRequest(
val message: String,
@SerializedName("session_id") val sessionId: String? = null,
val streamlined: Boolean = false
)
// 流式 SSE 事件密封类
sealed class SseEvent {
data class Message(val content: String) : SseEvent()
data class ToolCall(val toolName: String, val toolInput: String) : SseEvent()
data class ToolResult(val toolName: String, val toolOutput: String, val success: Boolean) : SseEvent()
data class Plan(val plan: PlanObject) : SseEvent()
data class Done(
val sessionId: String,
val iterationsUsed: Int,
val toolCallsMade: Int,
val tokenUsage: TokenUsage
) : SseEvent()
data class Error(val error: String) : SseEvent()
}
```
### 3.3 SSE 流式解析器
后端 SSE 格式为 `event: type\ndata: json\n\n`,需手动解析:
```kotlin
// data/remote/SseClient.kt
class SseClient(private val okHttpClient: OkHttpClient) {
fun connect(url: String, token: String, body: ChatRequest): Flow<SseEvent> = callbackFlow {
val requestBody = body.toJson().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url(url)
.header("Authorization", "Bearer $token")
.header("Accept", "text/event-stream")
.post(requestBody)
.build()
val call = okHttpClient.newCall(request)
val response = call.execute()
if (!response.isSuccessful) {
close(IOException("HTTP ${response.code}"))
return@callbackFlow
}
val source = response.body?.source() ?: return@callbackFlow
var eventType = ""
var data = ""
while (!source.exhausted()) {
val line = source.readUtf8Line() ?: break
when {
line.startsWith("event: ") -> eventType = line.removePrefix("event: ")
line.startsWith("data: ") -> data = line.removePrefix("data: ")
line.isEmpty() -> {
// 空行 = 事件结束
if (data.isNotEmpty()) {
val event = parseEvent(eventType, data)
trySend(event)
}
eventType = ""
data = ""
}
}
}
close()
}
private fun parseEvent(type: String, json: String): SseEvent = when (type) {
"message" -> SseEvent.Message(extractContent(json))
"tool_call" -> {
val obj = JsonParser.parseString(json).asJsonObject
SseEvent.ToolCall(
obj["tool_name"].asString,
obj["tool_input"].asString
)
}
"tool_result" -> {
val obj = JsonParser.parseString(json).asJsonObject
SseEvent.ToolResult(
obj["tool_name"].asString,
obj["tool_output"].asString,
obj["success"].asBoolean
)
}
"done" -> SseEvent.Done(/* parse full done payload */)
"error" -> SseEvent.Error(extractError(json))
else -> SseEvent.Message("") // 未知类型忽略
}
}
```
### 3.4 JWT 拦截器 + 自动重登录
Token 30 分钟过期,无 refresh 端点401 时自动重登录:
```kotlin
// data/remote/AuthInterceptor.kt
class AuthInterceptor(
private val tokenStore: TokenDataStore, // DataStore 存 token
private val credentialStore: CredentialStore // 加密存用户名密码
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking { tokenStore.getToken() }
val request = if (token != null) {
chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
val response = chain.proceed(request)
// 401 自动重登录
if (response.code == 401) {
response.close()
val credentials = runBlocking { credentialStore.getCredentials() }
if (credentials != null) {
val newToken = runBlocking { reLogin(credentials) }
if (newToken != null) {
val retryRequest = request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
return chain.proceed(retryRequest)
}
}
// 重登录也失败,跳转登录页(通过 EventBus 或回调通知 UI
}
return response
}
private suspend fun reLogin(cred: Credentials): String? {
// 使用单独的 OkHttp 客户端(不走本拦截器,避免死循环)
val client = OkHttpClient()
val body = FormBody.Builder()
.add("username", cred.username)
.add("password", cred.password)
.build()
val request = Request.Builder()
.url("${BuildConfig.BASE_URL}/api/v1/auth/login")
.post(body)
.build()
val response = client.newCall(request).execute()
return if (response.isSuccessful) {
val json = JsonParser.parseString(response.body?.string()!!).asJsonObject
val newToken = json["access_token"].asString
tokenStore.saveToken(newToken)
newToken
} else null
}
}
```
---
## 四、屏幕设计
### 4.1 整体导航
```
MainActivity
└── NavHost
├── LoginScreen (startDestination)
├── ChatScreen (需登录)
├── AgentListScreen (需登录,从 Chat 顶部切出)
├── SettingsScreen (需登录)
└── AboutScreen
```
### 4.2 登录页 — LoginScreen
| 项 | 说明 |
|------|------|
| 元素 | 用户名输入框、密码输入框、登录按钮、服务器地址(可配置) |
| 状态 | 空闲、加载中、错误提示 |
| 逻辑 | 登录成功 → 存储 Token + 凭据 → 跳转 ChatScreen |
| 安全 | 凭据存 EncryptedSharedPreferencesAndroidX Security |
### 4.3 对话页 — ChatScreen
核心页面,承载主要交互。
| 区域 | 说明 |
|------|------|
| 顶部栏 | 当前 Agent 名称点击切换、Agent 在线状态、通知铃铛(红点未读数) |
| 消息列表 | LazyColumn 倒序渲染,消息气泡(用户右对齐/助手左对齐),支持 Markdown 渲染 |
| 工具调用卡片 | 可折叠卡片,显示工具名/输入/输出,加载状态 |
| 流式文本 | 打字机效果,逐 token 显示,光标闪烁 |
| 底部输入栏 | 文本输入框 + 语音按钮 + 发送按钮 + 加号(图片/文件) |
| 空状态 | 首次进入显示"你好,我是 <Agent名>" + 快捷提问建议 |
**状态管理ChatViewModel**
| 事件 | 处理 |
|------|------|
| 发送消息 | 创建 user Message → 添加到列表 → 创建 assistant 占位 Message → 开启 SSE 连接 |
| SSE token | 追加到当前 assistant Message.content |
| SSE tool_call | 插入 ToolCallCard折叠状态 |
| SSE tool_result | 更新 ToolCallCard 为展开状态,显示结果 |
| SSE done | 停止流式,更新 token_usage保存消息到 Room |
| SSE error | 显示错误提示,消息气泡变红色 |
| 切换 Agent | 结束当前 SSE → 清空列表 → 加载新 Agent 信息 |
| 退后台/杀进程 | 保存 session_id下次恢复时带 session_id 继续对话 |
**消息气泡样式**
| 类型 | 对齐 | 样式 |
|------|------|------|
| user | 右对齐 | 主题色背景,白色字 |
| assistant (Markdown) | 左对齐 | 卡片背景Markdown 渲染正文 |
| tool_call | 左对齐 | 缩进卡片,灰色边框,"正在调用 xxx 工具..." |
| tool_result | 左对齐 | 缩进卡片,绿色边框(成功)/红色(失败),显示摘要 |
| system | 居中 | 灰色小字,如"会话已创建" |
### 4.4 Agent 列表页 — AgentListScreen
| 项 | 说明 |
|------|------|
| 触发 | 对话页顶部栏点击 Agent 名称 |
| 样式 | BottomSheet 或 全屏页面 |
| 列表项 | Agent 头像首字母、名称、描述、状态标签published/stopped |
| 操作 | 点击切换当前对话 Agent自动创建新 session |
| 搜索 | 顶部搜索栏,调用 `GET /agents?search=` |
### 4.5 设置页 — SettingsScreen
| 项 | 说明 |
|------|------|
| 服务器地址 | 可编辑,默认 `http://101.43.95.130:8038` |
| 语音 | TTS 开关、音色选择alloy/echo/fable/onyx/nova/shimmer |
| 推送 | 推送开关(注册/注销 FCM Token |
| 主题 | 亮色/暗色/跟随系统 |
| 账户 | 当前用户信息、退出登录 |
---
## 五、语音交互
### 5.1 语音输入ASR
```
用户按住语音按钮
-> MediaRecorder 录制 AAC/WebM
-> 松开按钮,停止录制
-> 上传到 POST /api/v1/voice/asr (multipart)
-> 返回 { "text": "..." }
-> 填入输入框(或直接发送)
```
| 项 | 实现 |
|------|------|
| 录制 | `MediaRecorder`,输出格式 `AAC_ADTS``WEBM`(兼容后端 Whisper |
| 采样率 | 16000 HzWhisper 推荐) |
| 按钮交互 | 按住录音、上滑取消、松开发送(类似微信) |
| 振幅动画 | 录音时显示波形动画(`AudioRecord.read()` 获取 PCM 振幅) |
```kotlin
// util/AudioRecorder.kt
class AudioRecorder(private val context: Context) {
private var mediaRecorder: MediaRecorder? = null
private var outputFile: File? = null
fun startRecording(): File {
outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac")
mediaRecorder = MediaRecorder(context).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(16000)
setOutputFile(outputFile!!.absolutePath)
prepare()
start()
}
return outputFile!!
}
fun stopRecording() {
mediaRecorder?.apply {
stop()
release()
}
mediaRecorder = null
}
}
```
### 5.2 语音输出TTS
```
助手回复完成
-> 调用 POST /api/v1/voice/tts (JSON: text + voice)
-> 返回 { "audio_url": "/api/v1/uploads/tts/xxx.mp3" }
-> ExoPlayer 播放
-> 消息气泡右下角显示播放按钮
```
| 项 | 实现 |
|------|------|
| 播放器 | `ExoPlayer` (Media3),支持缓存 |
| 触发 | 消息气泡旁播放按钮 / 设置中开启自动朗读 |
| 打断 | 用户开始录音时,停止当前 TTS |
---
## 六、推送通知
### 6.1 架构
```
后端任务完成/告警
-> PushService (新增)
-> Firebase Cloud Messaging
-> Android 设备
-> 通知栏展示
-> 点击跳转 App 对应页面
```
### 6.2 客户端实现
```kotlin
// 1. 获取 FCM Token
class TiangongApp : Application() {
override fun onCreate() {
super.onCreate()
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
// 同步到后端
CoroutineScope(Dispatchers.IO).launch {
apiService.registerFcmToken(FcmRegisterRequest(
token = token,
platform = "android"
))
}
}
}
}
}
// 2. 前台消息处理 — 通知弹窗 + 红点更新
class TiangongMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val title = message.notification?.title ?: message.data["title"] ?: return
val body = message.notification?.body ?: message.data["body"] ?: ""
val url = message.data["url"] ?: ""
showNotification(title, body, url)
}
private fun showNotification(title: String, body: String, deepLink: String) {
val channelId = when (message.data["priority"]) {
"urgent" -> "agent_urgent"
else -> "agent_default"
}
// 创建通知渠道 + 显示通知
// 点击通知 -> deepLink 跳转 (如打开对话页)
}
}
```
### 6.3 通知渠道
| 渠道 ID | 名称 | 级别 | 行为 |
|------|------|------|------|
| agent_reply | AI 回复 | DEFAULT | 声音 + 状态栏 |
| agent_urgent | 紧急通知 | HIGH | 声音 + 振动 + 悬浮 |
| agent_alert | 系统告警 | MAX | 全屏通知 |
### 6.4 后端需新增接口
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/push/register` | 注册 FCM Token绑定 user_id |
| DELETE | `/api/v1/push/unregister` | 注销 FCM Token |
| POST | `/api/v1/push/send` | (内部) 发送推送 — 由 notify_user 工具/告警服务调用 |
需新增 `push_tokens` 表:
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| user_id | UUID | 用户 ID |
| token | VARCHAR(512) | FCM token |
| platform | VARCHAR(16) | android / ios / web |
| created_at | DATETIME | 注册时间 |
| last_used_at | DATETIME | 最后一次推送时间 |
---
## 七、数据层
### 7.1 Room 数据库
```kotlin
// data/local/AppDatabase.kt
@Database(entities = [MessageEntity::class, ConversationEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun conversationDao(): ConversationDao
}
// MessageEntity
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey val id: String, // UUID客户端生成
val conversationId: String, // 会话 ID (即 session_id)
val agentId: String?, // 来源 Agent
val role: String, // user / assistant / tool / system
val content: String, // 正文
val toolName: String?, // 工具名role=tool 时)
val toolInput: String?, // 工具输入
val toolOutput: String?, // 工具输出
val tokenUsage: String?, // JSON 序列化的 TokenUsage
val createdAt: Long // 时间戳
)
// ConversationEntity
@Entity(tableName = "conversations")
data class ConversationEntity(
@PrimaryKey val sessionId: String, // = 后端 session_id
val agentId: String?,
val agentName: String?,
val title: String?, // 首条消息截取
val lastMessage: String?,
val lastMessageAt: Long,
val messageCount: Int
)
```
### 7.2 DataStore
| 键 | 类型 | 说明 |
|------|------|------|
| access_token | String | JWT Token |
| server_url | String | 服务器地址 |
| current_agent_id | String | 当前选中的 Agent ID |
| tts_enabled | Boolean | 是否自动朗读 |
| tts_voice | String | 音色选择 |
| push_enabled | Boolean | 推送开关 |
| theme_mode | String | light / dark / system |
---
## 八、构建配置
### 8.1 build.gradle.kts关键依赖
```kotlin
// app/build.gradle.kts
dependencies {
// Compose BOM
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.7")
// Lifecycle + ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.0")
// Network
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Room
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// DataStore
implementation("androidx.datastore:datastore-preferences:1.1.1")
// DI
implementation("com.google.dagger:hilt-android:2.51")
kapt("com.google.dagger:hilt-compiler:2.51")
// Firebase (推送)
implementation(platform("com.google.firebase:firebase-bom:33.1.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
// Media (TTS 播放)
implementation("androidx.media3:media3-exoplayer:1.3.1")
// Markdown 渲染
implementation("io.noties.markwon:core:4.6.2")
// Security (加密凭据)
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}
```
### 8.2 最低版本
| 项 | 值 |
|------|------|
| minSdk | 26 (Android 8.0) |
| targetSdk | 34 (Android 14) |
| compileSdk | 34 |
| Kotlin | 2.0 |
| AGP | 8.5 |
---
## 九、工作量估算
| 模块 | 内容 | 工作量 |
|------|------|--------|
| 项目搭建 | Gradle 配置、Hilt、主题、导航骨架 | 0.5天 |
| 网络层 | Retrofit 接口 + OkHttp 拦截器 + SSE 解析器 | 1天 |
| 登录 | 登录页 + ViewModel + Token 持久化 + 自动重登录 | 0.5天 |
| 对话页 | ChatScreen + ChatViewModel + SSE 流式消费 + 消息气泡 | 2天 |
| Markdown 渲染 | Markwon 集成 + 代码块 + 表格 + 图片 | 0.5天 |
| 工具调用 | ToolCallCard + 折叠展开 + 实时更新 | 0.5天 |
| Agent 列表 | AgentListScreen + 切换 Agent | 0.5天 |
| 语音输入 | AudioRecorder + 语音按钮 + 上传 + ASR 调用 | 1天 |
| 语音输出 | ExoPlayer + TTS 调用 + 播放按钮 | 0.5天 |
| 推送 | FCM 集成 + 通知渠道 + 后端接口 | 1天 |
| 本地存储 | Room 数据库 + DataStore + 消息持久化 | 1天 |
| 设置页 | SettingsScreen + 主题切换 | 0.5天 |
| 后端推送 | FCM 集成 + push_tokens 表 + 推送 API | 1天 |
| 测试联调 | 端到端测试 + 多机型适配 | 2天 |
| **合计** | | **12天** |
---
## 十、后端需配合改造
| 改动 | 说明 | 优先级 |
|------|------|--------|
| 新增 `/api/v1/voice/asr` | 接收音频文件,返回文本(已有 speech_to_text 工具,封装为 API | P0 |
| 新增 `/api/v1/voice/tts` | 接收文本,返回音频 URL已有 text_to_speech 工具,封装为 API | P0 |
| 新增 `/api/v1/push/register` | 接收 FCM Token绑定用户 | P1 |
| 新增 `push_tokens` 表 | 存储设备推送 Token | P1 |
| FCM Server SDK | 后端集成 `firebase-admin`,从 notify_user 触发推送 | P1 |
| Token 过期延长 | 30分钟 → 7天移动端场景或增加 refresh_token 机制 | P1 |