refactor: unify storage config keys and refresh local dev setup

This commit is contained in:
qzl
2026-03-26 13:25:25 +08:00
parent b765b9e3e1
commit 5900993ee7
61 changed files with 1164 additions and 129 deletions
+19 -2
View File
@@ -3,11 +3,11 @@ from __future__ import annotations
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, File, UploadFile, status
from schemas.shared.user import UserContext
from v1.users.dependencies import get_user_service
from v1.users.schemas import UserSearchRequest, UserUpdateRequest
from v1.users.schemas import AvatarUploadResponse, UserSearchRequest, UserUpdateRequest
from v1.users.service import UserService
@@ -29,6 +29,23 @@ async def update_me(
return await service.update_me(payload)
@router.post(
"/me/avatar",
response_model=AvatarUploadResponse,
status_code=status.HTTP_200_OK,
)
async def upload_avatar(
service: Annotated[UserService, Depends(get_user_service)],
file: UploadFile = File(),
) -> AvatarUploadResponse:
payload = await file.read()
return await service.upload_avatar(
filename=file.filename,
content_type=file.content_type,
payload=payload,
)
@router.post("/search", response_model=list[UserContext])
async def search_users(
payload: UserSearchRequest,
+4
View File
@@ -38,3 +38,7 @@ class UserUpdateRequest(BaseModel):
if self.username is None and self.avatar_url is None and self.bio is None:
raise ValueError("At least one field must be provided")
return self
class AvatarUploadResponse(BaseModel):
url: str = Field(description="Public URL of the uploaded avatar")
+90 -1
View File
@@ -11,11 +11,13 @@ from core.agentscope.caches.user_context_cache import (
create_user_context_cache,
)
from core.auth.models import CurrentUser
from core.config.settings import config
from core.db.base_service import BaseService
from core.logging import get_logger
from schemas.shared.user import UserContext, parse_profile_settings
from services.base.supabase import supabase_service
from v1.users.repository import UserRepository
from v1.users.schemas import UserSearchRequest, UserUpdateRequest
from v1.users.schemas import AvatarUploadResponse, UserSearchRequest, UserUpdateRequest
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,6 +29,16 @@ logger = get_logger("v1.users.service")
_PHONE_QUERY_PATTERN = re.compile(r"^[+()\-\s\d]{4,32}$")
def _mime_to_suffix(mime_type: str) -> str:
"""Convert MIME type to file suffix."""
mapping = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
return mapping.get(mime_type, "bin")
class AuthLookupGateway(Protocol):
async def search_user_ids_by_phone(
self, query: str, limit: int = 20
@@ -164,6 +176,83 @@ class UserService(BaseService):
settings=parse_profile_settings(user.settings),
)
async def upload_avatar(
self,
*,
filename: str | None,
content_type: str | None,
payload: bytes,
) -> AvatarUploadResponse:
user_id = self.require_user_id()
if not isinstance(content_type, str):
raise HTTPException(status_code=422, detail="Unsupported image type")
mime_type = content_type.lower()
allowed_types = {"image/jpeg", "image/png", "image/webp"}
if mime_type not in allowed_types:
raise HTTPException(
status_code=422,
detail="Unsupported image type. Allowed: JPEG, PNG, WebP",
)
max_size_bytes = config.storage.avatar.max_size_mb * 1024 * 1024
if len(payload) > max_size_bytes:
raise HTTPException(
status_code=413,
detail=f"Image too large. Maximum size: {config.storage.avatar.max_size_mb}MB",
)
if not payload:
raise HTTPException(status_code=422, detail="Empty image")
suffix = _mime_to_suffix(mime_type)
path = f"{user_id}/avatar.{suffix}"
bucket_name = config.storage.avatar.bucket
try:
stored_path = await supabase_service.upload_bytes(
bucket=bucket_name,
path=path,
content=payload,
content_type=mime_type,
)
except Exception: # noqa: BLE001
logger.exception(
"Avatar upload failed",
extra={
"bucket": bucket_name,
"path": path,
"mime_type": mime_type,
"user_id": str(user_id),
},
)
raise HTTPException(status_code=502, detail="Failed to upload avatar")
public_url = f"{config.supabase.public_url}/storage/v1/object/public/{bucket_name}/{stored_path}"
update_data: dict[str, str | None] = {"avatar_url": public_url}
try:
user = await self._repository.update_by_user_id(user_id, update_data)
await self._session.commit()
except SQLAlchemyError:
await self._session.rollback()
raise HTTPException(status_code=503, detail="User store unavailable")
if user is None:
raise HTTPException(status_code=404, detail="User not found")
try:
await self._user_context_cache.invalidate_user(user_id=user_id)
except Exception as exc:
logger.warning(
"Failed to invalidate user context cache after avatar upload",
user_id=str(user_id),
error=str(exc),
)
return AvatarUploadResponse(url=public_url)
async def get_by_username(self, username: str) -> UserContext:
try:
user = await self._repository.get_by_username(username)