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

120 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) можно уточнить при реализации, не меняя подход.