120 lines
11 KiB
Markdown
120 lines
11 KiB
Markdown
# 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) можно уточнить при реализации, не меняя подход.
|