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:
2026-07-01 22:20:47 +08:00
parent 3dcf769660
commit 21a06e8664
13 changed files with 546 additions and 15 deletions

View File

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

View File

@@ -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.** { *; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
}

View File

@@ -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 分钟有效

View File

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

View File

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