Files
ds-galo/openspec/changes/archive/2026-10-12-add-user-avatar/design.md
T

11 KiB
Raw Blame History

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

  1. Миграция Postgres 000014_user_avatar_objects.up.sql и SQLite 000006_user_avatar_objects.up.sql: создать user_avatar_objects с FK на users и objects (каскад/SET NULL по обстоятельствам), уникальность (user_id, role).
  2. Правка GC (ClaimGarbage) в Postgres и SQLite + сборка мусора теперь учитывает аватар.
  3. Бэкенд: сервис/хендлеры аватара, отдача /api/avatars/..., заполнение avatar_url, маршруты.
  4. Клиенты: веб и Android.
  5. Откат: удалить маршруты и таблицу; avatar_url очищается, объекты аватара становятся недоступными и освобождаются GC. Существующие данные не портятся.

Open Questions

  • Точное значение предела размера (сейчас 10 MB) и набор принимаемых форматов (по факту — то, что отдаёт canvas/Android: JPEG/PNG/WebP) можно уточнить при реализации, не меняя подход.