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:
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 分钟有效
|
||||
|
||||
Reference in New Issue
Block a user