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>
This commit is contained in:
Kevin
2026-05-06 13:51:43 +08:00
parent 59d4b19d7d
commit 7ad52fce89
27 changed files with 1271 additions and 270 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 B

View File

@@ -0,0 +1,68 @@
"""服务端托管的预设头像(白名单文件名)。"""
from __future__ import annotations
from pathlib import Path
_PRESETS_DIR = Path(__file__).resolve().parent / "avatar_presets"
# 与仓库内静态文件一致01.png … 08.png
ALLOWED_PRESET_FILENAMES: frozenset[str] = frozenset(f"{i:02d}.png" for i in range(1, 9))
PRESET_IDS: tuple[str, ...] = tuple(f"{i:02d}" for i in range(1, 9))
def preset_filename_for_id(preset_id: str) -> str | None:
"""preset_id 形如 \"01\",返回 \"01.png\";非法则 None。"""
stripped = preset_id.strip()
name = f"{stripped}.png"
if name in ALLOWED_PRESET_FILENAMES:
return name
return None
def avatar_url_for_preset_filename(filename: str) -> str:
return f"/api/auth/avatar-presets/{filename}"
def list_preset_items() -> list[tuple[str, str]]:
"""(preset_id, avatar_url) 列表,供 GET /avatar-presets。"""
return [
(pid, avatar_url_for_preset_filename(f"{pid}.png")) for pid in PRESET_IDS
]
def preset_file_path(filename: str) -> Path | None:
if filename not in ALLOWED_PRESET_FILENAMES:
return None
path = (_PRESETS_DIR / filename).resolve()
try:
path.relative_to(_PRESETS_DIR.resolve())
except ValueError:
return None
return path
def _avatar_upload_stem_allowed(stem: str) -> bool:
"""允许 UUID、数字 ID、含连字符/下划线的安全文件名主干。"""
if not stem or len(stem) > 128:
return False
return all(c.isalnum() or c in "-_" for c in stem)
def safe_avatar_upload_path(filename: str, avatar_dir: Path) -> Path | None:
"""用户上传头像文件名形如 {user_id}.jpg防路径穿越。"""
if "/" in filename or "\\" in filename or ".." in filename:
return None
if not filename.endswith(".jpg"):
return None
stem = filename[:-4]
if not _avatar_upload_stem_allowed(stem):
return None
base = avatar_dir.resolve()
path = (avatar_dir / filename).resolve()
try:
path.relative_to(base)
except ValueError:
return None
return path

View File

@@ -9,7 +9,15 @@ from app.core.config import settings
from app.core.dependencies import get_current_user
from app.core.logging import get_logger
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_filename_for_id,
preset_file_path,
safe_avatar_upload_path,
)
from app.features.auth.schemas import (
AvatarPresetItem,
ChangePasswordRequest,
ChangePhoneRequest,
LoginRequest,
@@ -18,6 +26,7 @@ from app.features.auth.schemas import (
RegisterRequest,
ResetPasswordRequest,
SendSmsRequest,
SetAvatarPresetRequest,
SmsLoginRequest,
SmsRegisterRequest,
TokenResponse,
@@ -329,14 +338,72 @@ async def upload_avatar(
) from e
@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={400: {"description": "无效的预设编号"}},
)
async def set_avatar_preset(
request: SetAvatarPresetRequest,
current_user: User = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
):
filename = preset_filename_for_id(request.preset_id)
if filename is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的预设头像编号",
)
path = preset_file_path(filename)
if path is None or not path.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="预设头像不可用",
)
avatar_url = avatar_url_for_preset_filename(filename)
try:
user = await service.update_avatar_url(current_user.id, avatar_url)
except AuthError as e:
raise _map_auth_error(e)
return _user_response(user)
@router.get(
"/avatar-presets/{filename}",
summary="获取预设头像图片",
responses={404: {"description": "预设不存在"}},
)
async def get_avatar_preset(filename: str):
path = preset_file_path(filename)
if path is None or not path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="预设头像不存在",
)
return FileResponse(path, media_type="image/png")
@router.get(
"/avatars/{filename}",
summary="获取头像图片",
responses={404: {"description": "头像不存在"}},
)
async def get_avatar(filename: str):
file_path = AVATAR_DIR / filename
if not file_path.exists():
file_path = safe_avatar_upload_path(filename, AVATAR_DIR)
if file_path is None or not file_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="头像不存在",

View File

@@ -104,3 +104,18 @@ class UpdateNicknameRequest(BaseModel):
class AvatarUploadResponse(BaseModel):
avatar_url: str
class SetAvatarPresetRequest(BaseModel):
preset_id: str = Field(
...,
min_length=2,
max_length=2,
pattern=r"^\d{2}$",
description="预设编号,如 0108",
)
class AvatarPresetItem(BaseModel):
id: str
url: str

View File

@@ -0,0 +1,170 @@
"""预设头像 HTTP 契约。"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.core.dependencies import get_current_user
from app.features.auth.deps import get_auth_service
from app.features.auth.router import router as auth_router
from app.features.auth.service import AuthService
from app.features.user.models import User
def _mock_current_user() -> User:
u = MagicMock(spec=User)
u.id = str(uuid.uuid4())
u.phone = "13800000000"
u.email = None
u.nickname = "测试用户"
u.avatar_url = None
u.subscription_type = "free"
u.created_at = datetime.now(timezone.utc)
return u
@pytest.fixture
def preset_auth_app() -> FastAPI:
app = FastAPI()
app.include_router(auth_router)
fixed_user = _mock_current_user()
async def _fake_update_avatar(uid: str, url: str):
fixed_user.avatar_url = url
return fixed_user
mock_service = MagicMock(spec=AuthService)
mock_service.update_avatar_url = AsyncMock(side_effect=_fake_update_avatar)
app.dependency_overrides[get_auth_service] = lambda: mock_service
app.dependency_overrides[get_current_user] = lambda: fixed_user
app.state._mock_auth_service = mock_service
app.state._fixed_user = fixed_user
return app
@pytest.mark.asyncio
async def test_list_avatar_presets(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatar-presets")
assert r.status_code == 200
items = r.json()
assert len(items) == 8
assert items[0]["id"] == "01"
assert items[0]["url"] == "/api/auth/avatar-presets/01.png"
@pytest.mark.asyncio
async def test_get_avatar_preset_ok(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatar-presets/01.png")
assert r.status_code == 200
assert r.headers.get("content-type", "").startswith("image/png")
@pytest.mark.asyncio
async def test_get_avatar_preset_unknown(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatar-presets/99.png")
assert r.status_code == 404
@pytest.mark.asyncio
async def test_get_avatar_preset_path_traversal(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatar-presets/../secrets.env")
assert r.status_code == 404
@pytest.mark.asyncio
async def test_set_avatar_preset_ok(preset_auth_app: FastAPI) -> None:
uid = preset_auth_app.state._fixed_user.id
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.put(
"/api/auth/me/avatar/preset",
json={"preset_id": "02"},
headers={"Authorization": "Bearer x"},
)
assert r.status_code == 200
body = r.json()
assert body["avatar_url"] == "/api/auth/avatar-presets/02.png"
svc: MagicMock = preset_auth_app.state._mock_auth_service
svc.update_avatar_url.assert_awaited_once_with(
uid, "/api/auth/avatar-presets/02.png"
)
@pytest.mark.asyncio
async def test_set_avatar_preset_invalid(preset_auth_app: FastAPI) -> None:
transport = ASGITransport(app=preset_auth_app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.put(
"/api/auth/me/avatar/preset",
json={"preset_id": "99"},
headers={"Authorization": "Bearer x"},
)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_get_uploaded_avatar_rejects_traversal() -> None:
app = FastAPI()
app.include_router(auth_router)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatars/../../../etc/passwd")
assert r.status_code == 404
@pytest.mark.asyncio
async def test_get_uploaded_avatar_ok_with_safe_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import app.features.auth.router as auth_router_mod
avatar_dir = tmp_path / "avatars"
avatar_dir.mkdir()
(avatar_dir / "abc-def-123.jpg").write_bytes(b"x")
monkeypatch.setattr(auth_router_mod, "AVATAR_DIR", avatar_dir)
app = FastAPI()
app.include_router(auth_router)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatars/abc-def-123.jpg")
assert r.status_code == 200
@pytest.mark.asyncio
async def test_get_uploaded_avatar_ok_when_stem_has_underscore(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import app.features.auth.router as auth_router_mod
avatar_dir = tmp_path / "avatars"
avatar_dir.mkdir()
(avatar_dir / "user_abc_01.jpg").write_bytes(b"x")
monkeypatch.setattr(auth_router_mod, "AVATAR_DIR", avatar_dir)
app = FastAPI()
app.include_router(auth_router)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/api/auth/avatars/user_abc_01.jpg")
assert r.status_code == 200