feat: complete P0-2 Token Refresh, P0-4 Android ProGuard, P0-5 App Update
- P0-2: Refresh token with Redis storage (UUID tokens, 30-day TTL, rotation on refresh) - P0-2: Backend POST /auth/refresh and /auth/revoke endpoints - P0-2: Android AuthInterceptor refresh-before-re-login flow - P0-2: AuthRepository saves refresh_token on login, revokes on logout - P0-4: Enable R8 minification (isMinifyEnabled=true) with comprehensive keep rules - P0-5: Backend GET /api/v1/app/check-update with force_update support - P0-5: Android AppUpdateManager with force/optional update dialogs on startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,7 @@ buildConfigField("String", "BASE_URL", "\"http://192.168.31.135:8037/\"")
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
|
||||
138
android/app/proguard-rules.pro
vendored
138
android/app/proguard-rules.pro
vendored
@@ -1,7 +1,139 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# ─────────── General ───────────
|
||||
-keepattributes *Annotation*
|
||||
-keep class com.tiangong.aiagent.data.remote.dto.** { *; }
|
||||
-keepattributes Signature
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
-keepattributes Exceptions
|
||||
|
||||
# Bugly
|
||||
# ─────────── DTOs (Gson serialization) ───────────
|
||||
-keep class com.tiangong.aiagent.data.remote.dto.** { *; }
|
||||
-keepclassmembers class com.tiangong.aiagent.data.remote.dto.** { *; }
|
||||
|
||||
# ─────────── Gson ───────────
|
||||
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||
-keep class com.google.gson.** { *; }
|
||||
-keepclassmembers,allowobfuscation class * {
|
||||
@com.google.gson.annotations.SerializedName <fields>;
|
||||
}
|
||||
-keep class com.google.gson.stream.** { *; }
|
||||
-dontwarn com.google.gson.**
|
||||
|
||||
# ─────────── Retrofit ───────────
|
||||
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepclassmembers,allowobfuscation class * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
-dontwarn retrofit2.**
|
||||
-dontwarn javax.annotation.**
|
||||
-dontwarn kotlin.Unit
|
||||
-dontwarn retrofit2.KotlinExtensions
|
||||
-dontwarn retrofit2.KotlinExtensions$*
|
||||
|
||||
# ─────────── OkHttp ───────────
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep class okio.** { *; }
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
|
||||
# ─────────── Room ───────────
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-keepclassmembers class * extends androidx.room.RoomDatabase {
|
||||
abstract <methods>;
|
||||
}
|
||||
-keep @androidx.room.Entity class * { *; }
|
||||
-keep @androidx.room.Dao class * { *; }
|
||||
-keepclassmembers @androidx.room.Entity class * {
|
||||
@androidx.room.PrimaryKey <fields>;
|
||||
}
|
||||
-keep class * extends androidx.room.migration.Migration { *; }
|
||||
-keep class androidx.room.** { *; }
|
||||
-dontwarn androidx.room.**
|
||||
|
||||
# ─────────── Hilt / Dagger ───────────
|
||||
-keep class dagger.** { *; }
|
||||
-keep class javax.inject.** { *; }
|
||||
-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; }
|
||||
-keep class dagger.hilt.** { *; }
|
||||
-keep class * extends dagger.hilt.android.lifecycle.HiltViewModel { *; }
|
||||
-keepclassmembers class * {
|
||||
@javax.inject.Inject <init>(...);
|
||||
}
|
||||
-keepclassmembers class * {
|
||||
@dagger.internal.InjectedFieldSignature <fields>;
|
||||
}
|
||||
-dontwarn dagger.**
|
||||
-dontwarn javax.inject.**
|
||||
-dontwarn dagger.hilt.**
|
||||
|
||||
# ─────────── Compose ───────────
|
||||
-keep class androidx.compose.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@androidx.compose.runtime.Composable <methods>;
|
||||
}
|
||||
-dontwarn androidx.compose.**
|
||||
|
||||
# ─────────── Firebase FCM ───────────
|
||||
-keep class com.google.firebase.** { *; }
|
||||
-keep class com.google.android.gms.** { *; }
|
||||
-dontwarn com.google.firebase.**
|
||||
-dontwarn com.google.android.gms.**
|
||||
|
||||
# ─────────── Coil ───────────
|
||||
-keep class coil.** { *; }
|
||||
-dontwarn coil.**
|
||||
|
||||
# ─────────── DataStore ───────────
|
||||
-keep class androidx.datastore.** { *; }
|
||||
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
|
||||
<fields>;
|
||||
}
|
||||
-dontwarn androidx.datastore.**
|
||||
|
||||
# ─────────── Security Crypto ───────────
|
||||
-keep class androidx.security.crypto.** { *; }
|
||||
-dontwarn androidx.security.crypto.**
|
||||
|
||||
# ─────────── ExoPlayer / Media3 ───────────
|
||||
-keep class androidx.media3.** { *; }
|
||||
-dontwarn androidx.media3.**
|
||||
|
||||
# ─────────── Markwon ───────────
|
||||
-keep class io.noties.markwon.** { *; }
|
||||
-dontwarn io.noties.markwon.**
|
||||
|
||||
# ─────────── Splash Screen ───────────
|
||||
-keep class androidx.core.splashscreen.** { *; }
|
||||
|
||||
# ─────────── Bugly (Tencent crash reporting) ───────────
|
||||
-dontwarn com.tencent.bugly.**
|
||||
-keep public class com.tencent.bugly.** { *; }
|
||||
|
||||
# ─────────── Kotlin Coroutines ───────────
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.coroutines.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
-dontwarn kotlinx.coroutines.**
|
||||
|
||||
# ─────────── Kotlin ───────────
|
||||
-keep class kotlin.** { *; }
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-dontwarn kotlin.**
|
||||
|
||||
# ─────────── BuildConfig ───────────
|
||||
-keep class com.tiangong.aiagent.BuildConfig { *; }
|
||||
|
||||
# ─────────── Application / Activities ───────────
|
||||
-keep class com.tiangong.aiagent.AgentApplication { *; }
|
||||
-keep class com.tiangong.aiagent.MainActivity { *; }
|
||||
|
||||
# ─────────── ViewModels (Hilt-injected) ───────────
|
||||
-keep class * extends androidx.lifecycle.ViewModel { *; }
|
||||
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
|
||||
<init>(...);
|
||||
}
|
||||
|
||||
# ─────────── Keep Compose previews in debug ───────────
|
||||
-keep class androidx.compose.ui.tooling.** { *; }
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.tiangong.aiagent.data.local.TokenDataStore
|
||||
import com.tiangong.aiagent.ui.navigation.NavGraph
|
||||
import com.tiangong.aiagent.ui.theme.TiangongTheme
|
||||
import com.tiangong.aiagent.util.AppUpdateManager
|
||||
import com.tiangong.aiagent.util.FcmTokenManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
@@ -27,6 +28,9 @@ class MainActivity : ComponentActivity() {
|
||||
@Inject
|
||||
lateinit var fcmTokenManager: FcmTokenManager
|
||||
|
||||
@Inject
|
||||
lateinit var appUpdateManager: AppUpdateManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -35,6 +39,9 @@ class MainActivity : ComponentActivity() {
|
||||
// Initialize FCM push (no-op if google-services.json is missing)
|
||||
fcmTokenManager.initialize()
|
||||
|
||||
// Check for app update (non-blocking, shows dialog if update available)
|
||||
appUpdateManager.checkForUpdate()
|
||||
|
||||
// Handle notification click deep link
|
||||
val notificationIdFromNotification = intent?.getStringExtra("notification_id")
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class TokenDataStore @Inject constructor(
|
||||
) {
|
||||
companion object {
|
||||
private val KEY_TOKEN = stringPreferencesKey("access_token")
|
||||
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||
private val KEY_SERVER_URL = stringPreferencesKey("server_url")
|
||||
private val KEY_CURRENT_AGENT_ID = stringPreferencesKey("current_agent_id")
|
||||
private val KEY_CURRENT_AGENT_NAME = stringPreferencesKey("current_agent_name")
|
||||
@@ -46,6 +47,21 @@ class TokenDataStore @Inject constructor(
|
||||
context.dataStore.edit { it.remove(KEY_TOKEN) }
|
||||
}
|
||||
|
||||
// ─── Refresh Token ───
|
||||
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
|
||||
|
||||
suspend fun saveRefreshToken(token: String) {
|
||||
context.dataStore.edit { it[KEY_REFRESH_TOKEN] = token }
|
||||
}
|
||||
|
||||
suspend fun getRefreshToken(): String? {
|
||||
return context.dataStore.data.first()[KEY_REFRESH_TOKEN]
|
||||
}
|
||||
|
||||
suspend fun clearRefreshToken() {
|
||||
context.dataStore.edit { it.remove(KEY_REFRESH_TOKEN) }
|
||||
}
|
||||
|
||||
// ─── Server URL ───
|
||||
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[KEY_SERVER_URL] ?: com.tiangong.aiagent.BuildConfig.BASE_URL
|
||||
|
||||
@@ -20,6 +20,12 @@ interface ApiService {
|
||||
@GET("api/v1/auth/me")
|
||||
suspend fun getCurrentUser(): UserResponse
|
||||
|
||||
@POST("api/v1/auth/refresh")
|
||||
suspend fun refreshToken(@Body request: RefreshRequest): RefreshResponse
|
||||
|
||||
@POST("api/v1/auth/revoke")
|
||||
suspend fun revokeToken(@Body request: RefreshRequest): Response<Unit>
|
||||
|
||||
// ─── Agents ───
|
||||
@GET("api/v1/agents")
|
||||
suspend fun getAgents(
|
||||
@@ -192,6 +198,13 @@ interface ApiService {
|
||||
@Body request: MemoryImportRequest
|
||||
): Response<Unit>
|
||||
|
||||
// ─── App Update ───
|
||||
@GET("api/v1/app/check-update")
|
||||
suspend fun checkUpdate(
|
||||
@Query("platform") platform: String = "android",
|
||||
@Query("version") version: String
|
||||
): AppVersionResponse
|
||||
|
||||
// ─── Vector Memories ───
|
||||
@GET("api/v1/agents/{agentId}/memory/vector-memories")
|
||||
suspend fun getVectorMemories(
|
||||
|
||||
@@ -48,13 +48,26 @@ class AuthInterceptor(
|
||||
|
||||
var response = chain.proceed(request)
|
||||
|
||||
// 401 auto re-login (with retry limit to prevent infinite loops)
|
||||
if (response.code == 401 && !originalRequest.url.encodedPath.endsWith("/login")) {
|
||||
// 401 auto refresh → re-login
|
||||
if (response.code == 401 && !originalRequest.url.encodedPath.endsWith("/login")
|
||||
&& !originalRequest.url.encodedPath.endsWith("/refresh")
|
||||
) {
|
||||
// 1) Try refresh token first
|
||||
val refreshedToken = performTokenRefresh()
|
||||
if (refreshedToken != null) {
|
||||
response.close()
|
||||
cachedToken = refreshedToken
|
||||
val retryRequest = request.newBuilder()
|
||||
.header("Authorization", "Bearer $refreshedToken")
|
||||
.build()
|
||||
return chain.proceed(retryRequest)
|
||||
}
|
||||
|
||||
// 2) Fall back to credential re-login
|
||||
val credentials = credentialStore.getCredentials()
|
||||
if (credentials != null) {
|
||||
val newToken = performReLogin(credentials)
|
||||
if (newToken != null) {
|
||||
// Close the 401 response since we're retrying with new token
|
||||
response.close()
|
||||
cachedToken = newToken
|
||||
val retryRequest = request.newBuilder()
|
||||
@@ -68,6 +81,43 @@ class AuthInterceptor(
|
||||
return response
|
||||
}
|
||||
|
||||
private fun performTokenRefresh(): String? {
|
||||
return try {
|
||||
val refreshToken = kotlinx.coroutines.runBlocking {
|
||||
tokenDataStore.getRefreshToken()
|
||||
} ?: return null
|
||||
|
||||
val client = OkHttpClient()
|
||||
val body = okhttp3.RequestBody.create(
|
||||
okhttp3.MediaType.parse("application/json")!!,
|
||||
"""{"refresh_token":"$refreshToken"}"""
|
||||
)
|
||||
val refreshUrl = (cachedServerUrl ?: com.tiangong.aiagent.BuildConfig.BASE_URL).trimEnd('/') + "/api/v1/auth/refresh"
|
||||
val refreshRequest = Request.Builder()
|
||||
.url(refreshUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val refreshResponse = client.newCall(refreshRequest).execute()
|
||||
if (refreshResponse.isSuccessful) {
|
||||
val json = refreshResponse.body?.string() ?: return null
|
||||
val obj = JsonParser.parseString(json).asJsonObject
|
||||
val newToken = obj["access_token"].asString
|
||||
val newRefreshToken = obj["refresh_token"]?.asString
|
||||
// Persist new tokens
|
||||
scope.launch {
|
||||
tokenDataStore.saveToken(newToken)
|
||||
if (newRefreshToken != null) {
|
||||
tokenDataStore.saveRefreshToken(newRefreshToken)
|
||||
}
|
||||
}
|
||||
newToken
|
||||
} else null
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun performReLogin(credentials: Pair<String, String>): String? {
|
||||
return try {
|
||||
val client = OkHttpClient()
|
||||
@@ -86,9 +136,13 @@ class AuthInterceptor(
|
||||
val json = loginResponse.body?.string() ?: return null
|
||||
val obj = JsonParser.parseString(json).asJsonObject
|
||||
val newToken = obj["access_token"].asString
|
||||
// Persist the new token
|
||||
val newRefreshToken = obj["refresh_token"]?.asString
|
||||
// Persist the new tokens
|
||||
scope.launch {
|
||||
tokenDataStore.saveToken(newToken)
|
||||
if (newRefreshToken != null) {
|
||||
tokenDataStore.saveRefreshToken(newRefreshToken)
|
||||
}
|
||||
}
|
||||
newToken
|
||||
} else null
|
||||
|
||||
@@ -6,7 +6,18 @@ import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class LoginResponse(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("token_type") val tokenType: String
|
||||
@SerializedName("token_type") val tokenType: String,
|
||||
@SerializedName("refresh_token") val refreshToken: String? = null
|
||||
)
|
||||
|
||||
data class RefreshRequest(
|
||||
@SerializedName("refresh_token") val refreshToken: String
|
||||
)
|
||||
|
||||
data class RefreshResponse(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("token_type") val tokenType: String,
|
||||
@SerializedName("refresh_token") val refreshToken: String? = null
|
||||
)
|
||||
|
||||
data class UserResponse(
|
||||
@@ -423,6 +434,17 @@ data class MemoryImportRequest(
|
||||
@SerializedName("vector_memories") val vectorMemories: List<Map<String, Any?>> = emptyList()
|
||||
)
|
||||
|
||||
// ─────────── App Update ───────────
|
||||
|
||||
data class AppVersionResponse(
|
||||
@SerializedName("latest_version") val latestVersion: String,
|
||||
@SerializedName("current_version") val currentVersion: String,
|
||||
@SerializedName("has_update") val hasUpdate: Boolean,
|
||||
@SerializedName("force_update") val forceUpdate: Boolean,
|
||||
@SerializedName("update_url") val updateUrl: String = "",
|
||||
@SerializedName("release_notes") val releaseNotes: String = ""
|
||||
)
|
||||
|
||||
// ─────────── Vector Memories ───────────
|
||||
|
||||
data class VectorMemoryDto(
|
||||
|
||||
@@ -24,6 +24,9 @@ class AuthRepository @Inject constructor(
|
||||
return try {
|
||||
val response = apiService.login(username, password)
|
||||
tokenDataStore.saveToken(response.accessToken)
|
||||
if (response.refreshToken != null) {
|
||||
tokenDataStore.saveRefreshToken(response.refreshToken)
|
||||
}
|
||||
credentialStore.saveCredentials(username, password)
|
||||
// Update AuthInterceptor memory cache so subsequent requests carry the token
|
||||
authInterceptor.updateToken(response.accessToken)
|
||||
@@ -84,6 +87,15 @@ class AuthRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
// Revoke refresh token server-side (best-effort)
|
||||
try {
|
||||
val refreshToken = tokenDataStore.getRefreshToken()
|
||||
if (refreshToken != null) {
|
||||
apiService.revokeToken(
|
||||
com.tiangong.aiagent.data.remote.dto.RefreshRequest(refreshToken)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
tokenDataStore.clearAll()
|
||||
credentialStore.clearCredentials()
|
||||
authInterceptor.updateToken(null)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.tiangong.aiagent.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.tiangong.aiagent.BuildConfig
|
||||
import com.tiangong.aiagent.data.remote.ApiService
|
||||
import com.tiangong.aiagent.data.remote.dto.AppVersionResponse
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AppUpdateManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val apiService: ApiService
|
||||
) {
|
||||
/**
|
||||
* Check for app update asynchronously. If update is found, shows a dialog.
|
||||
* Called from MainActivity.onCreate().
|
||||
*/
|
||||
fun checkForUpdate() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val response = apiService.checkUpdate(
|
||||
platform = "android",
|
||||
version = BuildConfig.VERSION_NAME
|
||||
)
|
||||
if (response.hasUpdate) {
|
||||
withContext(Dispatchers.Main) {
|
||||
showUpdateDialog(response)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Silently ignore — update check is non-critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUpdateDialog(response: AppVersionResponse) {
|
||||
try {
|
||||
val builder = if (response.forceUpdate) {
|
||||
android.app.AlertDialog.Builder(context)
|
||||
.setTitle("发现新版本 ${response.latestVersion}")
|
||||
.setMessage(buildDialogMessage(response))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("立即更新") { _, _ ->
|
||||
openDownloadUrl(response.updateUrl)
|
||||
}
|
||||
} else {
|
||||
android.app.AlertDialog.Builder(context)
|
||||
.setTitle("发现新版本 ${response.latestVersion}")
|
||||
.setMessage(buildDialogMessage(response))
|
||||
.setCancelable(true)
|
||||
.setPositiveButton("立即更新") { _, _ ->
|
||||
openDownloadUrl(response.updateUrl)
|
||||
}
|
||||
.setNegativeButton("稍后", null)
|
||||
}
|
||||
|
||||
// Show dialog from a new activity context is safer but we use application context
|
||||
// wrapped in a theme dialog
|
||||
val dialog = builder.create()
|
||||
// Ensure dialog is shown in an activity context if possible
|
||||
dialog.show()
|
||||
} catch (_: Exception) {
|
||||
// Fallback: open browser
|
||||
try {
|
||||
openDownloadUrl(response.updateUrl)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDialogMessage(response: AppVersionResponse): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("当前版本: ${BuildConfig.VERSION_NAME}\n")
|
||||
sb.append("最新版本: ${response.latestVersion}\n")
|
||||
if (response.releaseNotes.isNotBlank()) {
|
||||
sb.append("\n更新内容:\n${response.releaseNotes}")
|
||||
}
|
||||
if (response.forceUpdate) {
|
||||
sb.append("\n\n此版本为强制更新,请立即升级。")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun openDownloadUrl(url: String) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
59
backend/app/api/app_update.py
Normal file
59
backend/app/api/app_update.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
应用升级检查 API
|
||||
"""
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/app",
|
||||
tags=["app"],
|
||||
)
|
||||
|
||||
# 升级配置(可从 .env 或数据库读取,默认从配置文件)
|
||||
_LATEST_ANDROID_VERSION = getattr(settings, 'LATEST_ANDROID_VERSION', settings.APP_VERSION)
|
||||
_LATEST_ANDROID_URL = getattr(settings, 'LATEST_ANDROID_URL', "")
|
||||
_ANDROID_FORCE_UPDATE_MIN = getattr(settings, 'ANDROID_FORCE_UPDATE_MIN', "1.0.0")
|
||||
_ANDROID_RELEASE_NOTES = getattr(settings, 'ANDROID_RELEASE_NOTES', "")
|
||||
|
||||
|
||||
def _version_tuple(v: str) -> tuple:
|
||||
"""将版本号字符串转为可比较的元组。"""
|
||||
try:
|
||||
return tuple(int(x) for x in v.strip().split("."))
|
||||
except (ValueError, AttributeError):
|
||||
return (0,)
|
||||
|
||||
|
||||
@router.get("/check-update")
|
||||
async def check_update(
|
||||
platform: str = Query("android", description="平台: android / ios"),
|
||||
version: str = Query("1.0.0", description="当前客户端版本号"),
|
||||
):
|
||||
"""检查应用是否有新版本可用。"""
|
||||
if platform == "android":
|
||||
latest = _LATEST_ANDROID_VERSION
|
||||
url = _LATEST_ANDROID_URL
|
||||
force_min = _ANDROID_FORCE_UPDATE_MIN
|
||||
notes = _ANDROID_RELEASE_NOTES
|
||||
else:
|
||||
latest = settings.APP_VERSION
|
||||
url = ""
|
||||
force_min = "1.0.0"
|
||||
notes = ""
|
||||
|
||||
current_tuple = _version_tuple(version)
|
||||
latest_tuple = _version_tuple(latest)
|
||||
force_tuple = _version_tuple(force_min)
|
||||
|
||||
has_update = current_tuple < latest_tuple
|
||||
force_update = current_tuple < force_tuple
|
||||
|
||||
return {
|
||||
"latest_version": latest,
|
||||
"current_version": version,
|
||||
"has_update": has_update,
|
||||
"force_update": force_update,
|
||||
"update_url": url or "",
|
||||
"release_notes": notes,
|
||||
}
|
||||
@@ -9,7 +9,10 @@ import re
|
||||
import secrets
|
||||
import logging
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||
from app.core.security import (
|
||||
verify_password, get_password_hash, create_access_token,
|
||||
create_refresh_token, verify_refresh_token, revoke_refresh_token,
|
||||
)
|
||||
from app.models.user import User
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.config import settings
|
||||
@@ -86,6 +89,12 @@ class Token(BaseModel):
|
||||
"""令牌响应模型"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
refresh_token: str | None = None
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
"""刷新令牌请求"""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -175,7 +184,17 @@ async def login(
|
||||
expires_delta=expires,
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
refresh_token = create_refresh_token(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
workspace_id=ws_id or "",
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
@@ -275,6 +294,48 @@ async def switch_workspace(
|
||||
return {"access_token": new_token, "token_type": "bearer", "workspace_id": workspace_id}
|
||||
|
||||
|
||||
# ─── Token 刷新 & 撤销 ────────────────────────────────────────
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_access_token(body: RefreshRequest):
|
||||
"""用 refresh_token 换取新的 access_token(同时轮换 refresh_token)。"""
|
||||
data = verify_refresh_token(body.refresh_token)
|
||||
if not data:
|
||||
raise UnauthorizedError("无效或已过期的刷新令牌")
|
||||
|
||||
user_id = data.get("user_id", "")
|
||||
username = data.get("username", "")
|
||||
ws_id = data.get("ws", "")
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
access_token = create_access_token(
|
||||
data={"sub": user_id, "username": username, "ws": ws_id},
|
||||
expires_delta=timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
|
||||
# 撤销旧 refresh token,签发新的(轮换)
|
||||
revoke_refresh_token(body.refresh_token)
|
||||
new_refresh_token = create_refresh_token(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
workspace_id=ws_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"refresh_token": new_refresh_token,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/revoke")
|
||||
async def revoke_token(body: RefreshRequest):
|
||||
"""撤销 refresh token(退出登录时调用)。"""
|
||||
revoke_refresh_token(body.refresh_token)
|
||||
return {"message": "令牌已撤销"}
|
||||
|
||||
|
||||
# ─── 密码重置 ───────────────────────────────────────────────
|
||||
|
||||
RESET_CODE_TTL_SEC = 600 # 验证码 10 分钟有效
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
"""
|
||||
安全相关功能:密码加密、JWT等
|
||||
安全相关功能:密码加密、JWT、Refresh Token
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
import bcrypt
|
||||
from app.core.config import settings
|
||||
|
||||
# Redis client (lazy init)
|
||||
_redis = None
|
||||
|
||||
def _get_redis():
|
||||
global _redis
|
||||
if _redis is None:
|
||||
try:
|
||||
from app.core.redis_client import get_redis_client
|
||||
_redis = get_redis_client()
|
||||
except Exception:
|
||||
_redis = False
|
||||
return _redis
|
||||
|
||||
# Refresh token TTL: 30 days
|
||||
REFRESH_TOKEN_TTL_SEC = 30 * 24 * 3600 # 30 days
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证密码"""
|
||||
@@ -26,7 +44,7 @@ def get_password_hash(password: str) -> str:
|
||||
password_bytes = password.encode('utf-8')
|
||||
if len(password_bytes) > 72:
|
||||
password_bytes = password_bytes[:72]
|
||||
|
||||
|
||||
# 生成盐并哈希密码
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||
@@ -40,7 +58,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY or settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
return encoded_jwt
|
||||
@@ -53,3 +71,41 @@ def decode_access_token(token: str) -> Optional[dict]:
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
# ─── Refresh Token ──────────────────────────────────────────
|
||||
|
||||
def create_refresh_token(user_id: str, username: str, workspace_id: str = "") -> str:
|
||||
"""创建 refresh token,存储到 Redis,返回 token 字符串。"""
|
||||
token = str(uuid.uuid4())
|
||||
data = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"ws": workspace_id,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
r = _get_redis()
|
||||
if r:
|
||||
r.setex(f"refresh_token:{token}", REFRESH_TOKEN_TTL_SEC, json.dumps(data))
|
||||
return token
|
||||
|
||||
|
||||
def verify_refresh_token(token: str) -> Optional[dict]:
|
||||
"""验证 refresh token,返回存储的数据或 None。"""
|
||||
r = _get_redis()
|
||||
if not r:
|
||||
return None
|
||||
raw = r.get(f"refresh_token:{token}")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def revoke_refresh_token(token: str):
|
||||
"""撤销 refresh token。"""
|
||||
r = _get_redis()
|
||||
if r:
|
||||
r.delete(f"refresh_token:{token}")
|
||||
|
||||
@@ -510,7 +510,7 @@ async def startup_event():
|
||||
logger.error(f"定时任务调度器启动失败: {e}")
|
||||
|
||||
# 注册路由
|
||||
from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal
|
||||
from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(workspaces.router)
|
||||
@@ -558,6 +558,7 @@ app.include_router(scene_contracts.router)
|
||||
app.include_router(teams.router)
|
||||
app.include_router(agent_memory.router)
|
||||
app.include_router(legal.router)
|
||||
app.include_router(app_update.router)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
Reference in New Issue
Block a user