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

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