feat: add delete avatar

This commit is contained in:
2026-05-17 14:54:28 +03:00
parent 6f6d10567e
commit d3b5e0c107
10 changed files with 180 additions and 6 deletions

View File

@@ -39,5 +39,5 @@ class IUserRepository(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity: async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity:
raise NotImplementedError raise NotImplementedError

View File

@@ -1,6 +1,7 @@
from src.application.commands.get_me import GetMeCommand from src.application.commands.get_me import GetMeCommand
from src.application.commands.set_phone import SetPhoneCommand from src.application.commands.set_phone import SetPhoneCommand
from src.application.commands.set_avatar import SetAvatarCommand from src.application.commands.set_avatar import SetAvatarCommand
from src.application.commands.delete_avatar import DeleteAvatarCommand
from src.application.commands.set_encrypted_mnemonic_start import SetEncryptedMnemonicStartCommand from src.application.commands.set_encrypted_mnemonic_start import SetEncryptedMnemonicStartCommand
from src.application.commands.set_encrypted_mnemonic_complete import SetEncryptedMnemonicCompleteCommand from src.application.commands.set_encrypted_mnemonic_complete import SetEncryptedMnemonicCompleteCommand
from src.application.commands.update_bank_details_start import UpdateBankDetailsStartCommand from src.application.commands.update_bank_details_start import UpdateBankDetailsStartCommand

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from botocore.exceptions import ClientError
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ICache, ILogger, IS3
from src.application.domain.entities import UserEntity
from src.infrastructure.database.decorators import transactional
class DeleteAvatarCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache, s3: IS3):
self._unit_of_work = unit_of_work
self._logger = logger
self._cache = cache
self._s3 = s3
async def __call__(self, user_id: str) -> UserEntity:
prior = await self._unit_of_work.user_repository.get_user_by_id(user_id)
link = prior.avatar_link
if link:
key = self._s3.object_key_from_public_url(link)
if key:
try:
await self._s3.delete_object(key=key)
except ClientError as exc:
code = exc.response.get('Error', {}).get('Code', '')
if code not in ('NoSuchKey', '404'):
self._logger.warning(f'S3 delete avatar failed user_id={user_id} code={code}: {exc}')
user = await self._clear_avatar_link(user_id)
await self._cache.set_user(user_id, user)
self._logger.info(f'Avatar removed user_id={user_id}')
return user
@transactional
async def _clear_avatar_link(self, user_id: str) -> UserEntity:
return await self._unit_of_work.user_repository.set_avatar_link(user_id, None)

View File

@@ -6,6 +6,7 @@ from PIL import UnidentifiedImageError
from ulid import ULID from ulid import ULID
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import ICache, ILogger, IS3 from src.application.contracts import ICache, ILogger, IS3
from src.application.domain.entities import UserEntity from src.application.domain.entities import UserEntity
@@ -23,6 +24,8 @@ class SetAvatarCommand:
self._s3 = s3 self._s3 = s3
async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]: async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]:
prior = await self._unit_of_work.user_repository.get_user_by_id(user_id)
old_link = prior.avatar_link
try: try:
webp_bytes = image_bytes_to_webp(image_bytes) webp_bytes = image_bytes_to_webp(image_bytes)
except UnidentifiedImageError as exc: except UnidentifiedImageError as exc:
@@ -46,6 +49,17 @@ class SetAvatarCommand:
user = await self._save_avatar_link(user_id, url) user = await self._save_avatar_link(user_id, url)
await self._cache.set_user(user_id, user) await self._cache.set_user(user_id, user)
if old_link:
old_key = self._s3.object_key_from_public_url(old_link)
if old_key and old_key != object_key:
try:
await self._s3.delete_object(key=old_key)
except ClientError as exc:
code = exc.response.get('Error', {}).get('Code', '')
if code not in ('NoSuchKey', '404'):
self._logger.warning(f'S3 delete old avatar failed user_id={user_id} code={code}: {exc}')
self._logger.info(f'Avatar set for user_id={user_id} key={object_key}') self._logger.info(f'Avatar set for user_id={user_id} key={object_key}')
return user, len(webp_bytes) return user, len(webp_bytes)

View File

@@ -7,3 +7,11 @@ from typing import Protocol, runtime_checkable
class IS3(Protocol): class IS3(Protocol):
async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str: async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str:
... ...
async def delete_object(self, *, key: str) -> None:
...
def object_key_from_public_url(self, url: str) -> str | None:
...

View File

@@ -89,7 +89,7 @@ class UserRepository(IUserRepository):
async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity: async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity:
return await self._update_field(user_id, encrypted_mnemonic=encrypted_mnemonic) return await self._update_field(user_id, encrypted_mnemonic=encrypted_mnemonic)
async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity: async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity:
return await self._update_field(user_id, avatar_link=avatar_link) return await self._update_field(user_id, avatar_link=avatar_link)
async def get_password_hash(self, user_id: str) -> str: async def get_password_hash(self, user_id: str) -> str:

View File

@@ -24,6 +24,52 @@ class S3Service:
self._endpoint_url = endpoint_url.strip().rstrip('/') if endpoint_url and endpoint_url.strip() else None self._endpoint_url = endpoint_url.strip().rstrip('/') if endpoint_url and endpoint_url.strip() else None
self._use_reg_ru_website_public_host = use_reg_ru_website_public_host self._use_reg_ru_website_public_host = use_reg_ru_website_public_host
@staticmethod
def _url_prefix_variants(prefix: str) -> list[str]:
p = prefix.rstrip('/') + '/'
out = [p]
if p.startswith('https://'):
out.append('http://' + p[8:])
elif p.startswith('http://'):
out.append('https://' + p[7:])
return out
def _public_url_prefixes(self) -> list[str]:
acc: list[str] = []
pb = self._public_base_url
if pb:
acc.extend(self._url_prefix_variants(pb))
ep = self._endpoint_url
if ep:
base = f'{ep.rstrip("/")}/{self._bucket}'
acc.extend(self._url_prefix_variants(base))
if ep and self._use_reg_ru_website_public_host and 's3.regru.cloud' in ep.lower():
wh = f'https://{self._bucket}.website.regru.cloud'
acc.extend(self._url_prefix_variants(wh))
if not ep:
if self._region == 'us-east-1':
h = f'https://{self._bucket}.s3.amazonaws.com'
else:
h = f'https://{self._bucket}.s3.{self._region}.amazonaws.com'
acc.extend(self._url_prefix_variants(h))
seen: set[str] = set()
uniq: list[str] = []
for x in sorted(acc, key=len, reverse=True):
if x not in seen:
seen.add(x)
uniq.append(x)
return uniq
def object_key_from_public_url(self, url: str) -> str | None:
u = (url or '').strip()
if not u:
return None
for p in self._public_url_prefixes():
if u.startswith(p):
k = u[len(p):].split('?', 1)[0].split('#', 1)[0]
return k if k else None
return None
def _object_url(self, key: str) -> str: def _object_url(self, key: str) -> str:
if self._public_base_url: if self._public_base_url:
return f'{self._public_base_url}/{key}' return f'{self._public_base_url}/{key}'
@@ -62,3 +108,18 @@ class S3Service:
ContentType=content_type, ContentType=content_type,
) )
return self._object_url(key) return self._object_url(key)
async def delete_object(self, *, key: str) -> None:
session = get_session()
kw: dict[str, object] = {'region_name': self._region}
aid = self._access_key_id
sk = self._secret_access_key
ep = self._endpoint_url
if aid:
kw['aws_access_key_id'] = aid
if sk:
kw['aws_secret_access_key'] = sk
if ep:
kw['endpoint_url'] = ep
async with session.create_client('s3', **kw) as client:
await client.delete_object(Bucket=self._bucket, Key=key)

View File

@@ -2,6 +2,7 @@ from src.presentation.dependencies.commands import (
get_get_me_command, get_get_me_command,
get_set_phone_command, get_set_phone_command,
get_set_avatar_command, get_set_avatar_command,
get_delete_avatar_command,
get_set_encrypted_mnemonic_start_command, get_set_encrypted_mnemonic_start_command,
get_set_encrypted_mnemonic_complete_command, get_set_encrypted_mnemonic_complete_command,
get_update_bank_details_start_command, get_update_bank_details_start_command,

View File

@@ -1,6 +1,6 @@
from fastapi import Depends from fastapi import Depends
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService, IS3 from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService, IS3
from src.presentation.dependencies.cache import get_cache from src.presentation.dependencies.cache import get_cache
from src.presentation.dependencies.logger import get_logger from src.presentation.dependencies.logger import get_logger
@@ -35,6 +35,15 @@ def get_set_avatar_command(
return SetAvatarCommand(unit_of_work=unit_of_work, logger=logger, cache=cache, s3=s3) return SetAvatarCommand(unit_of_work=unit_of_work, logger=logger, cache=cache, s3=s3)
def get_delete_avatar_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
s3: IS3 = Depends(get_s3_storage),
) -> DeleteAvatarCommand:
return DeleteAvatarCommand(unit_of_work=unit_of_work, logger=logger, cache=cache, s3=s3)
def get_set_encrypted_mnemonic_start_command( def get_set_encrypted_mnemonic_start_command(
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work), unit_of_work: IUnitOfWork = Depends(get_unit_of_work),

View File

@@ -1,16 +1,17 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse from fastapi.responses import ORJSONResponse
from starlette import status from starlette import status
from src.application.commands import SetPhoneCommand, SetAvatarCommand from src.application.commands import SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand
from src.application.domain.dto import AuthContext from src.application.domain.dto import AuthContext
from src.presentation.decorators import require_access_token, csrf_protect from src.presentation.decorators import require_access_token, csrf_protect
from src.presentation.dependencies import ( from src.presentation.dependencies import (
get_delete_avatar_command,
get_set_avatar_command, get_set_avatar_command,
get_set_phone_command, get_set_phone_command,
) )
from src.presentation.schemas import SetAvatarRequest, SetPhoneRequest from src.presentation.schemas import SetAvatarRequest, SetPhoneRequest
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
from src.presentation.schemas.me_public import SetAvatarPublicResponse from src.presentation.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse
from src.presentation.serializers import me_user_public from src.presentation.serializers import me_user_public
@@ -45,6 +46,26 @@ _SET_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
} }
_DELETE_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
status.HTTP_401_UNAUTHORIZED: {
'description': 'Не передан или неверен access token.',
'model': ApiErrorPayload,
},
status.HTTP_404_NOT_FOUND: {
'description': 'Учётная запись не найдена.',
'model': ApiErrorPayload,
},
status.HTTP_500_INTERNAL_SERVER_ERROR: {
'description': 'Внутренняя ошибка сервера; клиенту отдаётся обобщённое сообщение.',
'model': ApiErrorPayload,
},
status.HTTP_503_SERVICE_UNAVAILABLE: {
'description': 'S3 не сконфигурирован или временная недоступность удаления объекта.',
'model': ApiErrorPayload,
},
}
@account_settings_router.patch(path='/phone', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) @account_settings_router.patch(path='/phone', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
@csrf_protect() @csrf_protect()
async def set_phone( async def set_phone(
@@ -64,7 +85,8 @@ async def set_phone(
response_model=SetAvatarPublicResponse, response_model=SetAvatarPublicResponse,
summary='Обновить аватар', summary='Обновить аватар',
description=( description=(
'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль.' 'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль. '
'После успешной записи удаляется предыдущий объект в S3 (если ссылку удаётся сопоставить с ключом).'
), ),
response_description=( response_description=(
'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.' 'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.'
@@ -82,6 +104,27 @@ async def set_avatar(
pub = me_user_public(user) pub = me_user_public(user)
return SetAvatarPublicResponse(**pub.model_dump(), webp_size_bytes=webp_size) return SetAvatarPublicResponse(**pub.model_dump(), webp_size_bytes=webp_size)
@account_settings_router.delete(
path='/avatar',
response_class=ORJSONResponse,
status_code=status.HTTP_200_OK,
response_model=MeUserPublicResponse,
summary='Удалить аватар',
description=(
'Удаляет файл в объектном хранилище при известном URL и обнуляет avatar_link в профиле.'
),
responses=_DELETE_AVATAR_ERROR_RESPONSES,
)
@csrf_protect()
async def delete_avatar(
request: Request,
auth: AuthContext = Depends(require_access_token),
command: DeleteAvatarCommand = Depends(get_delete_avatar_command),
) -> MeUserPublicResponse:
user = await command(user_id=auth.user_id)
return me_user_public(user)
# #
# @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) # @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def encrypted_mnemonic_start( # async def encrypted_mnemonic_start(