* feat(api): implement Google OAuth login and user management - Added Google OpenID Connect login functionality, allowing users to authenticate using their Google accounts. - Created new endpoints for Google login, including user registration and linking existing accounts. - Introduced Google token verification logic and error handling for authentication failures. - Updated environment configuration to include Google OAuth client IDs and verification settings. - Enhanced user model to support OpenID and linked Google accounts. This feature improves user experience by enabling seamless sign-in with Google, while maintaining security and integrity of user data. * fix(auth): wire staging Google token verifier * chore(deps): update expo to version 55.0.6 and adjust @expo/env dependency in pnpm-lock.yaml * chore(deps): update Babel dependencies to version 7.29.7 in package-lock.json * feat(auth): enhance phone login for China users - Updated phone login functionality to support only mainland China (+86) mobile numbers. - Added user prompts and descriptions for phone login, including confirmation and cancellation options. - Adjusted translations for both English and Chinese to reflect the new phone login requirements. - Updated Google OAuth client IDs in configuration files for production and staging environments. * chore(deps): add peer flag to use-sync-external-store in package-lock.json * chore(deps): add @emnapi/core and @emnapi/runtime to package-lock.json * fix(app-expo): align Android native dependencies * fix(app-expo): normalize lockfile for npm 10 * fix(config): update environment variable handling to use static access - Introduced a static mapping for public environment variables to ensure proper access during the release bundle. - Updated the `requirePublicEnv` and `optionalPublicEnv` functions to reference the new `PUBLIC_ENV` object instead of directly accessing `process.env`. - Added comments to clarify the necessity of static access for certain environment variables. * feat(app-expo): dark mode, FAQ i18n, eval ASR, and theme cleanup (#34) * feat(app-expo): dark mode, FAQ i18n, version CI, and theme cleanup Implement light/dark scene colors across chat, reading, and headers; remove default/brand theme picker and ThemeVariablesProvider. Localize FAQ in-app, fix dark-mode text visibility, and remove the unused /api/faqs endpoint. Align About/version with Expo config and inject APP_VERSION in CI builds. Also includes phone E164 auth/SMS updates, eval ASR page, and related API work. * revert: remove phone E.164 changes from dark-mode branch These auth/SMS internationalization updates were accidentally bundled into the dark-mode commit; restore 11-digit CN phone flow and drop related API, migration, and Expo UI work from this branch. * fix: address PR review issues for dark mode and eval ASR Use light foreground colors for sepia reading in dark mode, fix chat send button contrast, stream-limit eval ASR uploads, restore LiveTester phone validation, and remove unused AudioSegmenter code. * fix(app-expo): improve chat send button contrast in light and dark mode Add dedicated send button colors (accent fill in dark, primary fill in light), use RNText to avoid NativeWind overrides, and restore dark labels in light mode for readable composer actions. --------- Co-authored-by: Kevin <kevin@brighteng.org> --------- Co-authored-by: penghanyuan <penghanyuan@gmail.com> Co-authored-by: Kevin <kevin@brighteng.org>
517 lines
15 KiB
Python
517 lines
15 KiB
Python
import time
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, File, UploadFile, status
|
||
from fastapi.responses import FileResponse
|
||
|
||
from app.core.config import settings
|
||
from app.core.cos_url_keys import (
|
||
avatar_url_for_api_response,
|
||
best_effort_delete_cos_object_for_url,
|
||
)
|
||
from app.core.deps_types import CurrentUserDep
|
||
from app.core.errors import BadRequestError, NotFoundError
|
||
from app.core.logging import get_logger
|
||
from app.core.openapi import error_responses
|
||
from app.features.auth.deps import get_auth_service
|
||
from app.features.auth.preset_avatars import (
|
||
avatar_url_for_preset_filename,
|
||
list_preset_items,
|
||
preset_file_path,
|
||
preset_filename_for_id,
|
||
safe_avatar_upload_path,
|
||
)
|
||
from app.features.auth.schemas import (
|
||
AvatarPresetItem,
|
||
ChangePasswordRequest,
|
||
ChangePhoneRequest,
|
||
GoogleLoginRequest,
|
||
LoginRequest,
|
||
MockSmsLoginRequest,
|
||
RefreshTokenRequest,
|
||
RegisterRequest,
|
||
ResetPasswordRequest,
|
||
SendSmsRequest,
|
||
SetAvatarPresetRequest,
|
||
SmsLoginRequest,
|
||
SmsRegisterRequest,
|
||
TokenResponse,
|
||
UpdateNicknameRequest,
|
||
UserResponse,
|
||
)
|
||
from app.features.auth.service import AuthService
|
||
from app.features.user.models import User
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
router = APIRouter(
|
||
prefix="/api/auth",
|
||
tags=["auth"],
|
||
responses=error_responses(401),
|
||
)
|
||
|
||
AVATAR_DIR = Path("uploads/avatars")
|
||
|
||
# ── helpers ──────────────────────────────────────────────────
|
||
|
||
|
||
def _user_response(user: User) -> UserResponse:
|
||
raw_lang = getattr(user, "language_preference", "zh")
|
||
lang = str(raw_lang).strip().lower() if isinstance(raw_lang, str) else "zh"
|
||
if lang not in ("zh", "en"):
|
||
lang = "zh"
|
||
return UserResponse(
|
||
id=user.id,
|
||
phone=user.phone,
|
||
email=user.email,
|
||
nickname=user.nickname,
|
||
avatar_url=avatar_url_for_api_response(user.avatar_url),
|
||
subscription_type=user.subscription_type,
|
||
created_at=user.created_at.isoformat(),
|
||
language_preference=lang,
|
||
)
|
||
|
||
|
||
def _check_terms(agreed: bool) -> None:
|
||
if not agreed:
|
||
raise BadRequestError("请先阅读并同意用户协议和隐私政策")
|
||
|
||
|
||
def _mock_sms_login_route_enabled() -> bool:
|
||
env = (settings.app_environment or "").lower().strip()
|
||
if env == "production":
|
||
return False
|
||
return bool(settings.mock_sms_login_enabled)
|
||
|
||
|
||
# ── registration & login ─────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/register",
|
||
response_model=TokenResponse,
|
||
status_code=status.HTTP_201_CREATED,
|
||
summary="手机号密码注册",
|
||
responses=error_responses(400, descriptions={400: "手机号/邮箱已注册或参数错误"}),
|
||
)
|
||
async def register(
|
||
request: RegisterRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.register(
|
||
phone=request.phone,
|
||
password=request.password,
|
||
nickname=request.nickname,
|
||
email=request.email,
|
||
language=request.language,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/login",
|
||
response_model=TokenResponse,
|
||
summary="手机号密码登录",
|
||
responses=error_responses(401, descriptions={401: "手机号或密码错误"}),
|
||
)
|
||
async def login(
|
||
request: LoginRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.login(
|
||
phone=request.phone,
|
||
password=request.password,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/refresh",
|
||
response_model=TokenResponse,
|
||
summary="刷新访问令牌",
|
||
responses=error_responses(
|
||
401,
|
||
descriptions={
|
||
401: "刷新令牌无效/已过期;已轮换 token 被重复使用时会吊销全部会话(REFRESH_TOKEN_REUSE)"
|
||
},
|
||
),
|
||
)
|
||
async def refresh_token(
|
||
request: RefreshTokenRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
result = await service.refresh_tokens(
|
||
refresh_token=request.refresh_token,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
# ── logout ────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/logout",
|
||
status_code=status.HTTP_200_OK,
|
||
summary="登出当前设备",
|
||
)
|
||
async def logout(
|
||
request: RefreshTokenRequest,
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
await service.logout(request.refresh_token, current_user.id)
|
||
return {"message": "登出成功"}
|
||
|
||
|
||
@router.post(
|
||
"/logout/all",
|
||
summary="登出所有设备",
|
||
)
|
||
async def logout_all_devices(
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
count = await service.logout_all(current_user.id)
|
||
return {"message": f"已登出所有设备,共撤销 {count} 个令牌"}
|
||
|
||
|
||
# ── user profile ──────────────────────────────────────────────
|
||
|
||
|
||
@router.get(
|
||
"/me",
|
||
response_model=UserResponse,
|
||
summary="获取当前用户信息",
|
||
)
|
||
async def get_me(
|
||
current_user: CurrentUserDep,
|
||
):
|
||
return _user_response(current_user)
|
||
|
||
|
||
@router.put(
|
||
"/me/nickname",
|
||
response_model=UserResponse,
|
||
summary="修改昵称",
|
||
)
|
||
async def update_nickname(
|
||
request: UpdateNicknameRequest,
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
user = await service.update_nickname(current_user.id, request.nickname)
|
||
return _user_response(user)
|
||
|
||
|
||
# ── avatar ────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/me/avatar",
|
||
response_model=UserResponse,
|
||
summary="上传头像",
|
||
responses=error_responses(400, descriptions={400: "文件类型或大小不符合要求"}),
|
||
)
|
||
async def upload_avatar(
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
file: UploadFile = File(...),
|
||
):
|
||
file_content = await file.read()
|
||
logger.debug(
|
||
"上传头像: user_id={} filename={} content_type={} size={}",
|
||
current_user.id,
|
||
file.filename,
|
||
file.content_type,
|
||
len(file_content),
|
||
)
|
||
user = await service.upload_avatar(
|
||
current_user.id,
|
||
file_content,
|
||
file.content_type or "",
|
||
old_avatar_url=current_user.avatar_url,
|
||
)
|
||
return _user_response(user)
|
||
|
||
|
||
@router.get(
|
||
"/avatar-presets",
|
||
response_model=list[AvatarPresetItem],
|
||
summary="预设头像列表",
|
||
)
|
||
async def list_avatar_presets():
|
||
return [
|
||
AvatarPresetItem(id=item_id, url=item_url)
|
||
for item_id, item_url in list_preset_items()
|
||
]
|
||
|
||
|
||
@router.put(
|
||
"/me/avatar/preset",
|
||
response_model=UserResponse,
|
||
summary="使用预设头像",
|
||
responses=error_responses(400, descriptions={400: "无效的预设编号"}),
|
||
)
|
||
async def set_avatar_preset(
|
||
request: SetAvatarPresetRequest,
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
filename = preset_filename_for_id(request.preset_id)
|
||
if filename is None:
|
||
raise BadRequestError("无效的预设头像编号")
|
||
path = preset_file_path(filename)
|
||
if path is None or not path.exists():
|
||
raise BadRequestError("预设头像不可用")
|
||
best_effort_delete_cos_object_for_url(current_user.avatar_url)
|
||
|
||
avatar_url = f"{avatar_url_for_preset_filename(filename)}?v={time.time_ns()}"
|
||
user = await service.update_avatar_url(current_user.id, avatar_url)
|
||
return _user_response(user)
|
||
|
||
|
||
@router.get(
|
||
"/avatar-presets/{filename}",
|
||
summary="获取预设头像图片",
|
||
responses=error_responses(404, descriptions={404: "预设不存在"}),
|
||
)
|
||
async def get_avatar_preset(filename: str):
|
||
path = preset_file_path(filename)
|
||
if path is None or not path.exists():
|
||
raise NotFoundError("预设头像不存在")
|
||
return FileResponse(path, media_type="image/png")
|
||
|
||
|
||
@router.get(
|
||
"/avatars/{filename}",
|
||
summary="获取头像图片",
|
||
responses=error_responses(404, descriptions={404: "头像不存在"}),
|
||
)
|
||
async def get_avatar(filename: str):
|
||
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||
file_path = safe_avatar_upload_path(filename, AVATAR_DIR)
|
||
if file_path is None or not file_path.exists():
|
||
raise NotFoundError("头像不存在")
|
||
return FileResponse(file_path, media_type="image/jpeg")
|
||
|
||
|
||
# ── SMS verification ──────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/sms/send",
|
||
summary="发送短信验证码",
|
||
responses=error_responses(
|
||
400,
|
||
429,
|
||
502,
|
||
503,
|
||
descriptions={
|
||
400: "手机号格式或用途不合法",
|
||
429: "发送过于频繁(RATE_LIMITED)",
|
||
502: "短信服务商调用失败(PROVIDER_ERROR,可重试)",
|
||
503: "短信服务未配置或不可用(SERVICE_UNAVAILABLE)",
|
||
},
|
||
),
|
||
)
|
||
async def send_sms_code(
|
||
request: SendSmsRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
if not request.phone.isdigit():
|
||
raise BadRequestError("手机号格式不正确")
|
||
|
||
valid_purposes = ["register", "login", "reset_password", "change_phone"]
|
||
if request.purpose not in valid_purposes:
|
||
raise BadRequestError(f"无效的用途,必须是: {', '.join(valid_purposes)}")
|
||
|
||
_success, message, expires_in = await service.send_sms_code(
|
||
phone=request.phone,
|
||
purpose=request.purpose,
|
||
ip_address=None,
|
||
)
|
||
|
||
return {"message": message, "expires_in": expires_in}
|
||
|
||
|
||
@router.post(
|
||
"/login/sms",
|
||
response_model=TokenResponse,
|
||
summary="短信验证码登录(新用户自动注册)",
|
||
responses=error_responses(400, descriptions={400: "验证码错误"}),
|
||
)
|
||
async def login_with_sms(
|
||
request: SmsLoginRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.login_with_sms(
|
||
phone=request.phone,
|
||
code=request.code,
|
||
nickname=request.nickname,
|
||
language=request.language,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/login/google",
|
||
response_model=TokenResponse,
|
||
summary="Google 账号登录(新用户自动注册)",
|
||
responses=error_responses(
|
||
400,
|
||
401,
|
||
503,
|
||
descriptions={
|
||
400: "未同意协议或参数错误",
|
||
401: "Google 登录凭证无效",
|
||
503: "Google 登录尚未配置",
|
||
},
|
||
),
|
||
)
|
||
async def login_with_google(
|
||
request: GoogleLoginRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.login_with_google(
|
||
id_token=request.id_token,
|
||
language=request.language,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/mock/sms-login",
|
||
response_model=TokenResponse,
|
||
summary="[评测] Mock 短信登录(跳过验证码)",
|
||
description=(
|
||
"需 config deploy.mock_sms_login_enabled=true 且 APP_ENV 非 production。"
|
||
"供 Eval Web 等内网工具联调,勿在生产环境开启。"
|
||
),
|
||
responses=error_responses(404, descriptions={404: "未启用或生产环境已禁用"}),
|
||
)
|
||
async def mock_sms_login_route(
|
||
request: MockSmsLoginRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
if not _mock_sms_login_route_enabled():
|
||
raise NotFoundError("Not Found")
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.mock_sms_login(
|
||
phone=request.phone,
|
||
nickname=request.nickname,
|
||
language=request.language,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/register/sms",
|
||
response_model=TokenResponse,
|
||
status_code=status.HTTP_201_CREATED,
|
||
summary="短信验证码注册",
|
||
responses=error_responses(400, descriptions={400: "验证码错误或手机号/邮箱已注册"}),
|
||
)
|
||
async def register_with_sms(
|
||
request: SmsRegisterRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
_check_terms(request.agreed_to_terms)
|
||
result = await service.register_with_sms(
|
||
phone=request.phone,
|
||
code=request.code,
|
||
password=request.password,
|
||
nickname=request.nickname,
|
||
email=request.email,
|
||
language=request.language,
|
||
)
|
||
return TokenResponse(
|
||
access_token=result["access_token"],
|
||
refresh_token=result["refresh_token"],
|
||
)
|
||
|
||
|
||
# ── password & phone management ───────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/password/reset",
|
||
summary="通过短信验证码重置密码",
|
||
responses=error_responses(
|
||
400,
|
||
404,
|
||
descriptions={
|
||
400: "验证码错误",
|
||
404: "用户不存在",
|
||
},
|
||
),
|
||
)
|
||
async def reset_password(
|
||
request: ResetPasswordRequest,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
await service.reset_password(
|
||
phone=request.phone,
|
||
code=request.code,
|
||
new_password=request.new_password,
|
||
)
|
||
return {"message": "密码重置成功"}
|
||
|
||
|
||
@router.post(
|
||
"/password/change",
|
||
summary="修改密码(需旧密码)",
|
||
responses=error_responses(400, descriptions={400: "旧密码错误"}),
|
||
)
|
||
async def change_password(
|
||
request: ChangePasswordRequest,
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
await service.change_password(
|
||
user_id=current_user.id,
|
||
old_password=request.old_password,
|
||
new_password=request.new_password,
|
||
)
|
||
return {"message": "密码修改成功"}
|
||
|
||
|
||
@router.post(
|
||
"/phone/change",
|
||
response_model=UserResponse,
|
||
summary="更换手机号",
|
||
responses=error_responses(400, descriptions={400: "验证码错误或手机号已被占用"}),
|
||
)
|
||
async def change_phone(
|
||
request: ChangePhoneRequest,
|
||
current_user: CurrentUserDep,
|
||
service: AuthService = Depends(get_auth_service),
|
||
):
|
||
user = await service.change_phone(
|
||
user_id=current_user.id,
|
||
new_phone=request.new_phone,
|
||
code=request.code,
|
||
)
|
||
return _user_response(user)
|