2026-05-13 16:15:21 +08:00
|
|
|
|
import time
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-05-22 13:44:50 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, File, UploadFile, status
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from fastapi.responses import FileResponse
|
|
|
|
|
|
|
2026-04-20 11:58:32 +08:00
|
|
|
|
from app.core.config import settings
|
2026-05-18 15:34:50 +08:00
|
|
|
|
from app.core.cos_url_keys import (
|
|
|
|
|
|
avatar_url_for_api_response,
|
|
|
|
|
|
best_effort_delete_cos_object_for_url,
|
|
|
|
|
|
)
|
2026-05-22 13:44:50 +08:00
|
|
|
|
from app.core.deps_types import CurrentUserDep
|
|
|
|
|
|
from app.core.errors import BadRequestError, NotFoundError
|
2026-03-22 16:45:57 +08:00
|
|
|
|
from app.core.logging import get_logger
|
2026-05-22 13:44:50 +08:00
|
|
|
|
from app.core.openapi import error_responses
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.features.auth.deps import get_auth_service
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
from app.features.auth.preset_avatars import (
|
|
|
|
|
|
avatar_url_for_preset_filename,
|
|
|
|
|
|
list_preset_items,
|
|
|
|
|
|
preset_file_path,
|
2026-05-22 13:44:50 +08:00
|
|
|
|
preset_filename_for_id,
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
safe_avatar_upload_path,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.features.auth.schemas import (
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
AvatarPresetItem,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
ChangePasswordRequest,
|
|
|
|
|
|
ChangePhoneRequest,
|
merge dark mode and google OAuth (#35)
* 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>
2026-06-09 11:14:36 +08:00
|
|
|
|
GoogleLoginRequest,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
LoginRequest,
|
2026-04-20 11:58:32 +08:00
|
|
|
|
MockSmsLoginRequest,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
RefreshTokenRequest,
|
|
|
|
|
|
RegisterRequest,
|
|
|
|
|
|
ResetPasswordRequest,
|
|
|
|
|
|
SendSmsRequest,
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
SetAvatarPresetRequest,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
SmsLoginRequest,
|
|
|
|
|
|
SmsRegisterRequest,
|
|
|
|
|
|
TokenResponse,
|
|
|
|
|
|
UpdateNicknameRequest,
|
|
|
|
|
|
UserResponse,
|
|
|
|
|
|
)
|
2026-05-22 13:44:50 +08:00
|
|
|
|
from app.features.auth.service import AuthService
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.features.user.models import User
|
|
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(
|
|
|
|
|
|
prefix="/api/auth",
|
|
|
|
|
|
tags=["auth"],
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(401),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
AVATAR_DIR = Path("uploads/avatars")
|
|
|
|
|
|
|
|
|
|
|
|
# ── helpers ──────────────────────────────────────────────────
|
|
|
|
|
|
|
merge dark mode and google OAuth (#35)
* 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>
2026-06-09 11:14:36 +08:00
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
def _user_response(user: User) -> UserResponse:
|
feat(i18n): persist language preference and thread through chat, memoir, TTS
- Add users.language_preference (Alembic 0018, default zh); capture at signup/SMS
only; expose on auth and profile APIs
- Lite English prompts for chat and memoir; localized stage labels and agent
names (Life Echo / 岁月知己)
- Tencent TTS: language-aware synthesis, ModelType=1 for 501004, English chunking
- WebSocket pipeline: emit all AGENT_RESPONSE segments when TTS cancels; INFO logs
for tts_this_turn and TTS decisions; on-demand TTS logging
- Expo: device language on auth, i18n tiers/agent name, [SPLIT] streaming UX fixes
- Tests for migration, prompts, pipeline, router tts_this_turn, reply segments
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:16:49 +08:00
|
|
|
|
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"
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return UserResponse(
|
|
|
|
|
|
id=user.id,
|
|
|
|
|
|
phone=user.phone,
|
|
|
|
|
|
email=user.email,
|
|
|
|
|
|
nickname=user.nickname,
|
2026-05-18 15:34:50 +08:00
|
|
|
|
avatar_url=avatar_url_for_api_response(user.avatar_url),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
subscription_type=user.subscription_type,
|
|
|
|
|
|
created_at=user.created_at.isoformat(),
|
feat(i18n): persist language preference and thread through chat, memoir, TTS
- Add users.language_preference (Alembic 0018, default zh); capture at signup/SMS
only; expose on auth and profile APIs
- Lite English prompts for chat and memoir; localized stage labels and agent
names (Life Echo / 岁月知己)
- Tencent TTS: language-aware synthesis, ModelType=1 for 501004, English chunking
- WebSocket pipeline: emit all AGENT_RESPONSE segments when TTS cancels; INFO logs
for tts_this_turn and TTS decisions; on-demand TTS logging
- Expo: device language on auth, i18n tiers/agent name, [SPLIT] streaming UX fixes
- Tests for migration, prompts, pipeline, router tts_this_turn, reply segments
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:16:49 +08:00
|
|
|
|
language_preference=lang,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _check_terms(agreed: bool) -> None:
|
|
|
|
|
|
if not agreed:
|
2026-05-22 13:44:50 +08:00
|
|
|
|
raise BadRequestError("请先阅读并同意用户协议和隐私政策")
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-20 11:58:32 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
# ── registration & login ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/register",
|
|
|
|
|
|
response_model=TokenResponse,
|
|
|
|
|
|
status_code=status.HTTP_201_CREATED,
|
|
|
|
|
|
summary="手机号密码注册",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(400, descriptions={400: "手机号/邮箱已注册或参数错误"}),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def register(
|
|
|
|
|
|
request: RegisterRequest,
|
|
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
|
|
|
|
|
_check_terms(request.agreed_to_terms)
|
2026-05-22 13:44:50 +08:00
|
|
|
|
result = await service.register(
|
|
|
|
|
|
phone=request.phone,
|
|
|
|
|
|
password=request.password,
|
|
|
|
|
|
nickname=request.nickname,
|
|
|
|
|
|
email=request.email,
|
|
|
|
|
|
language=request.language,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return TokenResponse(
|
|
|
|
|
|
access_token=result["access_token"],
|
|
|
|
|
|
refresh_token=result["refresh_token"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/login",
|
|
|
|
|
|
response_model=TokenResponse,
|
|
|
|
|
|
summary="手机号密码登录",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(401, descriptions={401: "手机号或密码错误"}),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def login(
|
|
|
|
|
|
request: LoginRequest,
|
|
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
|
|
|
|
|
_check_terms(request.agreed_to_terms)
|
2026-05-22 13:44:50 +08:00
|
|
|
|
result = await service.login(
|
|
|
|
|
|
phone=request.phone,
|
|
|
|
|
|
password=request.password,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return TokenResponse(
|
|
|
|
|
|
access_token=result["access_token"],
|
|
|
|
|
|
refresh_token=result["refresh_token"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/refresh",
|
|
|
|
|
|
response_model=TokenResponse,
|
|
|
|
|
|
summary="刷新访问令牌",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(
|
|
|
|
|
|
401,
|
|
|
|
|
|
descriptions={
|
|
|
|
|
|
401: "刷新令牌无效/已过期;已轮换 token 被重复使用时会吊销全部会话(REFRESH_TOKEN_REUSE)"
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def refresh_token(
|
|
|
|
|
|
request: RefreshTokenRequest,
|
|
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
2026-05-22 13:44:50 +08:00
|
|
|
|
result = await service.refresh_tokens(
|
|
|
|
|
|
refresh_token=request.refresh_token,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
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,
|
2026-05-22 13:44:50 +08:00
|
|
|
|
current_user: CurrentUserDep,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
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(
|
2026-05-22 13:44:50 +08:00
|
|
|
|
current_user: CurrentUserDep,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
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(
|
2026-05-22 13:44:50 +08:00
|
|
|
|
current_user: CurrentUserDep,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
):
|
|
|
|
|
|
return _user_response(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put(
|
|
|
|
|
|
"/me/nickname",
|
|
|
|
|
|
response_model=UserResponse,
|
|
|
|
|
|
summary="修改昵称",
|
|
|
|
|
|
)
|
|
|
|
|
|
async def update_nickname(
|
|
|
|
|
|
request: UpdateNicknameRequest,
|
2026-05-22 13:44:50 +08:00
|
|
|
|
current_user: CurrentUserDep,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
2026-05-22 13:44:50 +08:00
|
|
|
|
user = await service.update_nickname(current_user.id, request.nickname)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return _user_response(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── avatar ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/me/avatar",
|
|
|
|
|
|
response_model=UserResponse,
|
|
|
|
|
|
summary="上传头像",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(400, descriptions={400: "文件类型或大小不符合要求"}),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def upload_avatar(
|
2026-05-22 13:44:50 +08:00
|
|
|
|
current_user: CurrentUserDep,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
2026-05-22 13:44:50 +08:00
|
|
|
|
file: UploadFile = File(...),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
):
|
|
|
|
|
|
file_content = await file.read()
|
2026-03-22 16:45:57 +08:00
|
|
|
|
logger.debug(
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"上传头像: user_id={} filename={} content_type={} size={}",
|
2026-03-22 16:45:57 +08:00
|
|
|
|
current_user.id,
|
|
|
|
|
|
file.filename,
|
|
|
|
|
|
file.content_type,
|
|
|
|
|
|
len(file_content),
|
|
|
|
|
|
)
|
2026-05-22 13:44:50 +08:00
|
|
|
|
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)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
@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="使用预设头像",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(400, descriptions={400: "无效的预设编号"}),
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def set_avatar_preset(
|
|
|
|
|
|
request: SetAvatarPresetRequest,
|
2026-05-22 13:44:50 +08:00
|
|
|
|
current_user: CurrentUserDep,
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
|
|
|
|
|
filename = preset_filename_for_id(request.preset_id)
|
|
|
|
|
|
if filename is None:
|
2026-05-22 13:44:50 +08:00
|
|
|
|
raise BadRequestError("无效的预设头像编号")
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
path = preset_file_path(filename)
|
|
|
|
|
|
if path is None or not path.exists():
|
2026-05-22 13:44:50 +08:00
|
|
|
|
raise BadRequestError("预设头像不可用")
|
2026-05-18 15:34:50 +08:00
|
|
|
|
best_effort_delete_cos_object_for_url(current_user.avatar_url)
|
|
|
|
|
|
|
2026-05-13 16:15:21 +08:00
|
|
|
|
avatar_url = f"{avatar_url_for_preset_filename(filename)}?v={time.time_ns()}"
|
2026-05-22 13:44:50 +08:00
|
|
|
|
user = await service.update_avatar_url(current_user.id, avatar_url)
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
return _user_response(user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
|
"/avatar-presets/{filename}",
|
|
|
|
|
|
summary="获取预设头像图片",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(404, descriptions={404: "预设不存在"}),
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def get_avatar_preset(filename: str):
|
|
|
|
|
|
path = preset_file_path(filename)
|
|
|
|
|
|
if path is None or not path.exists():
|
2026-05-22 13:44:50 +08:00
|
|
|
|
raise NotFoundError("预设头像不存在")
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
return FileResponse(path, media_type="image/png")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
@router.get(
|
|
|
|
|
|
"/avatars/{filename}",
|
|
|
|
|
|
summary="获取头像图片",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(404, descriptions={404: "头像不存在"}),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def get_avatar(filename: str):
|
2026-05-18 15:34:50 +08:00
|
|
|
|
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00
|
|
|
|
file_path = safe_avatar_upload_path(filename, AVATAR_DIR)
|
|
|
|
|
|
if file_path is None or not file_path.exists():
|
2026-05-22 13:44:50 +08:00
|
|
|
|
raise NotFoundError("头像不存在")
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return FileResponse(file_path, media_type="image/jpeg")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── SMS verification ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/sms/send",
|
|
|
|
|
|
summary="发送短信验证码",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(
|
|
|
|
|
|
400,
|
|
|
|
|
|
429,
|
|
|
|
|
|
502,
|
|
|
|
|
|
503,
|
|
|
|
|
|
descriptions={
|
|
|
|
|
|
400: "手机号格式或用途不合法",
|
|
|
|
|
|
429: "发送过于频繁(RATE_LIMITED)",
|
|
|
|
|
|
502: "短信服务商调用失败(PROVIDER_ERROR,可重试)",
|
|
|
|
|
|
503: "短信服务未配置或不可用(SERVICE_UNAVAILABLE)",
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def send_sms_code(
|
|
|
|
|
|
request: SendSmsRequest,
|
|
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
|
|
|
|
|
if not request.phone.isdigit():
|
2026-05-22 13:44:50 +08:00
|
|
|
|
raise BadRequestError("手机号格式不正确")
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
valid_purposes = ["register", "login", "reset_password", "change_phone"]
|
|
|
|
|
|
if request.purpose not in valid_purposes:
|
2026-05-22 13:44:50 +08:00
|
|
|
|
raise BadRequestError(f"无效的用途,必须是: {', '.join(valid_purposes)}")
|
|
|
|
|
|
|
|
|
|
|
|
_success, message, expires_in = await service.send_sms_code(
|
|
|
|
|
|
phone=request.phone,
|
|
|
|
|
|
purpose=request.purpose,
|
|
|
|
|
|
ip_address=None,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
return {"message": message, "expires_in": expires_in}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/login/sms",
|
|
|
|
|
|
response_model=TokenResponse,
|
|
|
|
|
|
summary="短信验证码登录(新用户自动注册)",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(400, descriptions={400: "验证码错误"}),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def login_with_sms(
|
|
|
|
|
|
request: SmsLoginRequest,
|
|
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
|
|
|
|
|
_check_terms(request.agreed_to_terms)
|
2026-05-22 13:44:50 +08:00
|
|
|
|
result = await service.login_with_sms(
|
|
|
|
|
|
phone=request.phone,
|
|
|
|
|
|
code=request.code,
|
|
|
|
|
|
nickname=request.nickname,
|
|
|
|
|
|
language=request.language,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return TokenResponse(
|
|
|
|
|
|
access_token=result["access_token"],
|
|
|
|
|
|
refresh_token=result["refresh_token"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
merge dark mode and google OAuth (#35)
* 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>
2026-06-09 11:14:36 +08:00
|
|
|
|
@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"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-20 11:58:32 +08:00
|
|
|
|
@router.post(
|
|
|
|
|
|
"/mock/sms-login",
|
|
|
|
|
|
response_model=TokenResponse,
|
|
|
|
|
|
summary="[评测] Mock 短信登录(跳过验证码)",
|
|
|
|
|
|
description=(
|
2026-05-22 13:44:50 +08:00
|
|
|
|
"需 config deploy.mock_sms_login_enabled=true 且 APP_ENV 非 production。"
|
2026-04-20 11:58:32 +08:00
|
|
|
|
"供 Eval Web 等内网工具联调,勿在生产环境开启。"
|
|
|
|
|
|
),
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(404, descriptions={404: "未启用或生产环境已禁用"}),
|
2026-04-20 11:58:32 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def mock_sms_login_route(
|
|
|
|
|
|
request: MockSmsLoginRequest,
|
|
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
|
|
|
|
|
if not _mock_sms_login_route_enabled():
|
2026-05-22 13:44:50 +08:00
|
|
|
|
raise NotFoundError("Not Found")
|
2026-04-20 11:58:32 +08:00
|
|
|
|
_check_terms(request.agreed_to_terms)
|
2026-05-22 13:44:50 +08:00
|
|
|
|
result = await service.mock_sms_login(
|
|
|
|
|
|
phone=request.phone,
|
|
|
|
|
|
nickname=request.nickname,
|
|
|
|
|
|
language=request.language,
|
|
|
|
|
|
)
|
2026-04-20 11:58:32 +08:00
|
|
|
|
return TokenResponse(
|
|
|
|
|
|
access_token=result["access_token"],
|
|
|
|
|
|
refresh_token=result["refresh_token"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
@router.post(
|
|
|
|
|
|
"/register/sms",
|
|
|
|
|
|
response_model=TokenResponse,
|
|
|
|
|
|
status_code=status.HTTP_201_CREATED,
|
|
|
|
|
|
summary="短信验证码注册",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(400, descriptions={400: "验证码错误或手机号/邮箱已注册"}),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def register_with_sms(
|
|
|
|
|
|
request: SmsRegisterRequest,
|
|
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
|
|
|
|
|
_check_terms(request.agreed_to_terms)
|
2026-05-22 13:44:50 +08:00
|
|
|
|
result = await service.register_with_sms(
|
|
|
|
|
|
phone=request.phone,
|
|
|
|
|
|
code=request.code,
|
|
|
|
|
|
password=request.password,
|
|
|
|
|
|
nickname=request.nickname,
|
|
|
|
|
|
email=request.email,
|
|
|
|
|
|
language=request.language,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return TokenResponse(
|
|
|
|
|
|
access_token=result["access_token"],
|
|
|
|
|
|
refresh_token=result["refresh_token"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── password & phone management ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/password/reset",
|
|
|
|
|
|
summary="通过短信验证码重置密码",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(
|
|
|
|
|
|
400,
|
|
|
|
|
|
404,
|
|
|
|
|
|
descriptions={
|
|
|
|
|
|
400: "验证码错误",
|
|
|
|
|
|
404: "用户不存在",
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def reset_password(
|
|
|
|
|
|
request: ResetPasswordRequest,
|
|
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
2026-05-22 13:44:50 +08:00
|
|
|
|
await service.reset_password(
|
|
|
|
|
|
phone=request.phone,
|
|
|
|
|
|
code=request.code,
|
|
|
|
|
|
new_password=request.new_password,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return {"message": "密码重置成功"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/password/change",
|
|
|
|
|
|
summary="修改密码(需旧密码)",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(400, descriptions={400: "旧密码错误"}),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def change_password(
|
|
|
|
|
|
request: ChangePasswordRequest,
|
2026-05-22 13:44:50 +08:00
|
|
|
|
current_user: CurrentUserDep,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
2026-05-22 13:44:50 +08:00
|
|
|
|
await service.change_password(
|
|
|
|
|
|
user_id=current_user.id,
|
|
|
|
|
|
old_password=request.old_password,
|
|
|
|
|
|
new_password=request.new_password,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return {"message": "密码修改成功"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/phone/change",
|
|
|
|
|
|
response_model=UserResponse,
|
|
|
|
|
|
summary="更换手机号",
|
2026-05-22 13:44:50 +08:00
|
|
|
|
responses=error_responses(400, descriptions={400: "验证码错误或手机号已被占用"}),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
async def change_phone(
|
|
|
|
|
|
request: ChangePhoneRequest,
|
2026-05-22 13:44:50 +08:00
|
|
|
|
current_user: CurrentUserDep,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
service: AuthService = Depends(get_auth_service),
|
|
|
|
|
|
):
|
2026-05-22 13:44:50 +08:00
|
|
|
|
user = await service.change_phone(
|
|
|
|
|
|
user_id=current_user.id,
|
|
|
|
|
|
new_phone=request.new_phone,
|
|
|
|
|
|
code=request.code,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return _user_response(user)
|