11 KiB
Design
Context
См. proposal.md — Why. Текущее состояние, определяющее подход:
users.avatar_url(text) существует в Postgres и SQLite с миграции000001; поляUser.AvatarURL/PublicUser.AvatarURL/SelfUser.AvatarURLиavatarUrlв обоих клиентах уже объявлены, но никто их не пишет и не отдаёт.- Объектное хранилище фото-центрично: журнал
objects(pending/ready/deleting) плюсphoto_objects(photo_id, object_id, role); URL строится черезstorage.URL(key)как подписанная ссылка/api/objects/<key>?e=&s. - Сборка мусора (
ClaimGarbage, Postgres и SQLite) удаляетready-объекты без привязки вphoto_objects. Значит объект аватара обязан иметь привязку, иначе его подоберёт GC. cmd/reconcileсканирует и удаляет сирот только под префиксомobjects/; ключи аватара под этим префиксом попадут в журнал и будут безопасны.- Обработка изображений:
disintegration/imaging(imaging.Fill— квадратный кроп) и WebP. - Android-кэш Coil (
ObjectCacheKey) ключует по пути, отбрасывая query, чтобы переживать ротацию подписи у фото. Это влияет на форму URL аватара.
Goals / Non-Goals
Goals:
- Круглый аватар с загрузкой и кадрированием в вебе и Android; отображение везде, где виден пользователь.
- Хранение аватара, безопасное относительно существующего GC и
reconcile. - Минимум изменений в коде чтения пользователей.
Non-Goals:
- Серверное кадрирование по геометрии (кадр делает клиент).
- Аватары на публичных share-страницах (там сейчас нет авторов в UI).
- Хранение исходника загруженного фото как оригинального объекта аватара.
- Анимированные аватары (GIF/видео).
Decisions
Кадрирование на клиенте, сервер нормализует
Клиент (веб — canvas, Android — Compose) отдаёт уже готовый квадрат; сервер не принимает
геометрию и не доверяет ей. Причины: у каждой платформы есть естественный кроп-UI; серверное
API остаётся «тупым» (один файл), не нужно валидировать прямоугольник/поворот.
Альтернатива: серверный кроп по x,y,w,h,angle — один алгоритм, но API сложнее, а кроп-UI всё
равно нужен на клиенте. Отклонено.
Реализация UI: без новых зависимостей — собственный canvas в вебе и Compose-кроп в Android.
Альтернатива: сторонние библиотеки кропа; допустимо, но добавляет зависимости.
Новый эндпоинт с одним файлом
POST /api/auth/me/avatar (multipart file = квадрат) и DELETE /api/auth/me/avatar. Ответ —
обновлённый self-профиль (как у прочих изменений профиля).
Нормализация: две квадратные нарезки, WebP, без EXIF
Сервер декодирует, отклоняет не-изображения, делает imaging.Fill в 512 и 128 и перекодирует в
WebP. Повторный энкод снимает EXIF/GPS. Роли нарезок: avatar (512) и avatar_small (128).
Альтернатива: одна нарезка 512 — меньше объектов, но лишний трафик в списках. Отклонено.
Альтернатива: хранить оригинал аватара как object — не нужно, аватар не «открывают».
Привязка через user_avatar_objects по образцу photo_objects
Новая таблица user_avatar_objects(user_id, object_id, role). GC получает второе исключение: не
удалять объекты, на которые ссылается ни фото, ни аватар. Это сохраняет инвариант «журнал
объектов — источник правды», делает reconcile безопасным и автоматически освобождает
старый блоб при замене/удалении аватара: старая привязка снимается, объект теряет ссылку и его
подбирает существующий воркер.
Альтернатива: отдельный префикс (avatars/) вне журнала — проще, но выпадает из журнала,
лишается управления жизненным циклом и нарушает принцип из media-objects. Отклонено.
Альтернатива: хранить аватар как фото без альбома (album_id nullable уже есть) — переиспользует
photo_objects бесплатно, но засоряет фото-представления (корзина, теги). Отклонено.
Стабильный URL с версией в пути
GET /api/avatars/:userID/:version отдаёт малую нарезку, ?size=large — крупную; :version —
идентификатор текущего объекта аватара, сегмент нужен только для инвалидации кэша.
users.avatar_url заполняется значением /api/avatars/<userID>/<version>.
Чтение публичное (без авторизации): теги <img> и клиентские загрузчики изображений
(Coil) не могут отправить заголовок Authorization, поэтому защищённый маршрут отдавал бы
401 и «битую» картинку. Загрузка и удаление остаются под авторизацией. Путь не угадывается
по одному идентификатору: userID и version — непредсказуемые UUID.
Причины: существующие запросы (members, social, photos, auth/me) уже выбирают
avatar_url, поэтому «аватар везде» не требует правок чтения; версия в пути, а не в query,
потому что Coil ключует кэш по пути (query отбрасывается) — иначе после замены отдавалась бы
старая картинка.
Альтернатива: подписанные /api/objects?e=&s как у фото — работает в <img>, но подпись
пришлось бы резолвить в четырёх сервисах на каждый ответ, а ссылка бы истекала (нельзя хранить
в колонке). Отклонено.
Альтернатива: версия в query ?v= — ломает кэш Coil без правки ObjectCacheKey. Отклонено.
Заглушка и удаление
При отсутствии аватара — нейтральный силуэт: в веб-спрайт добавляется иконка person, в Android
используется Icons.Filled.Person. DELETE /api/auth/me/avatar снимает привязку и очищает
avatar_url.
Лимит размера
Отдельный небольшой предел на файл аватара (константа 10 MB), а не общий IMAGE_MAX_UPLOAD_SIZE
(100 MB): аватар такого размера не нужен, а ранний отказ экономит ресурсы.
Risks / Trade-offs
- [GC-запрос меняется в двух сторах, легко забыть SQLite] → задача явно требует правку обоих бэкендов и тест на освобождение объекта аватара.
- [Денормализация
avatar_urlможет разойтись с фактической привязкой] → URL строится из идентификатора объекта в момент привязки; замена всегда переписывает оба. - [Клиент может прислать не-квадрат] → сервер делает
imaging.Fill— результат всегда квадрат независимо от входа. - [Coil: смена версии в пути меняет ключ кэша → старая запись остаётся на диске] → приемлемо, Coil вытеснит её по LRU; корректность важнее.
- [Кроп-UI на двух платформах — дублирование усилий] → умышленно: каждый UI нативен платформе.
- [Учётные записи без
avatar_url] → заглушка во всех точках отображения; значениеnull/пусто.
Migration Plan
- Миграция Postgres
000014_user_avatar_objects.up.sqlи SQLite000006_user_avatar_objects.up.sql: создатьuser_avatar_objectsс FK наusersиobjects(каскад/SET NULLпо обстоятельствам), уникальность(user_id, role). - Правка GC (
ClaimGarbage) в Postgres и SQLite + сборка мусора теперь учитывает аватар. - Бэкенд: сервис/хендлеры аватара, отдача
/api/avatars/..., заполнениеavatar_url, маршруты. - Клиенты: веб и Android.
- Откат: удалить маршруты и таблицу;
avatar_urlочищается, объекты аватара становятся недоступными и освобождаются GC. Существующие данные не портятся.
Open Questions
- Точное значение предела размера (сейчас 10 MB) и набор принимаемых форматов (по факту — то, что отдаёт canvas/Android: JPEG/PNG/WebP) можно уточнить при реализации, не меняя подход.