9.8 KiB
Design
Context
См. proposal.md — Why. Существенное для реализации:
users.emailсейчасNOT NULL UNIQUEв обеих схемах; уникальность —users_email_key.- Два хранилища с общим интерфейсом
auth.Repository:store.go(pgx) иsqlite_store.go(database/sql). Две отдельные истории миграций:backend/migrations/*.sql(Postgres) иbackend/migrations/sqlite/*.sql(консолидированная + инкременты). models.PublicUserиспользуется во всех публичных представлениях (участники, загрузчики, комментарии).User.Public()конвертирует в него;/auth/meсейчас тоже отдаётPublic().- FK уже каскадные (
albums.owner_id,photos,comments,likes,tags,notifications,share_links,invites→usersON DELETE CASCADE). - Блобы не в БД: удаление строк оставляет объекты, которые добирает
cmd/reconcile. scripts/seed.pyрегистрирует по email.
Goals / Non-Goals
Goals:
- Единый идентификатор входа (email или E.164-телефон) без отправки SMS.
- Управление профилем (имя, пароль) и удаление аккаунта со всем содержимым — на backend, web и Android.
- Миграция без потери существующих email-only аккаунтов.
Non-Goals:
- Верификация телефона, смена/удаление заданных логинов, avatar-загрузка, «корзина» аккаунта.
- Мгновенная очистка блобов в транзакции удаления (остаётся на
reconcile).
Decisions
1. Схема: оба идентификатора nullable, «хотя бы один» на уровне приложения
email и phone оба NULL-допустимы и уникальны; обязательность «хотя бы одного»
проверяет сервис регистрации/добавления, а не CHECK в БД. Так существующие строки и
phone-only новые аккаунты сосуществуют одинаково в обеих СУБД, а сообщения об ошибке
остаются контролируемыми. Альтернатива с CHECK (email IS NOT NULL OR phone IS NOT NULL)
отвергнута: в SQLite её добавление всё равно требует rebuild, а выигрыш невелик.
2. E.164-нормализация в сервисном слое
Отдельная функция нормализации: обязательный ведущий +, только цифры, длина 8–15.
Одна точка нормализации для регистрации, добавления и входа — чтобы вход был точным
сравнением по каноническому значению. Определение типа идентификатора: наличие @ → email,
иначе → телефон.
3. Добавление идентификатора — отдельный add-only эндпоинт
POST /auth/me/identifiers с необязательными email и phone. Сервис разрешает ровно один
новый идентификатор, отклоняет попытку задать уже существующий/второй, проверяет
уникальность и конфликты (email_taken, phone_taken). Альтернатива «PATCH /auth/me с
опциональными полями» отвергнута: неявное смешение изменяемого (имя) и неизменяемого
(логины) в одном ресурсе.
4. Смена пароля: текущий пароль + отзыв всех refresh-токенов
POST /auth/me/password { current_password, new_password }. При успехе RevokeAllUserTokens.
Клиенты очищают токены и уходят на экран входа. Access-токен (≤15 мин) формально жив, но
refresh мёртв; для консистентности клиенты всё равно разлогиниваются.
5. Удаление аккаунта: подтверждение паролем + удаление строки
DELETE /auth/me { password }. Проверяем пароль, затем DELETE FROM users WHERE id.
Каскад FK удаляет альбомы (вместе с доступом других участников), фото, соцданные,
уведомления, share-ссылки, приглашения и токены. После удаления любой запрос с прежним
access-токеном завершается 401, потому что строки пользователя больше нет. Возврат 204.
Блобы освобождаются reconcile. Альтернативы (запрет при общих альбомах, передача
владения, grace-период) отклонены по решению: удалять всё и сразу.
6. Приватность телефона
phone добавляется в models.User, но НЕ в PublicUser, поэтому не попадает в списки
участников/загрузчиков/комментариев. Собственный профиль отдаётся отдельным ответом с
телефоном, а не через Public().
7. Миграции
- Postgres
000013_user_phone:ALTER TABLE users ALTER COLUMN email DROP NOT NULL,ADD COLUMN phone text,ADD CONSTRAINT users_phone_key UNIQUE (phone). - SQLite
000005_user_phone: rebuildusersпо 12-шаговому паттерну (создать новую таблицу безNOT NULLу email, сphone, перенести данные, пересоздать индексы), т.к. SQLite не умеет снимать ограничения черезALTER. - Существующие строки получают
phone = NULL; email сохраняется.
8. Клиенты
- Web: маршрут
/profileпод(app), вход из шапки по имени; login-страница — одно поле «Email или телефон»; при регистрации поля email/phone с проверкой «хотя бы одно». - Android:
ProfileScreenвGaloNavHost, вход изAlbumsScreen;AuthScreen— одно поле идентификатора и необязательные email/phone при регистрации; по успеху пароль/удаление → очисткаTokenStore→ на экран auth. - Оба клиента обновляются вместе с сервером: контракт login/register — BREAKING.
Risks / Trade-offs
- [Телефон-логин без верификации → занятие чужого номера] → принято осознанно для self-hosted; задел под OTP вне объёма.
- [SQLite rebuild
users] → выполнять в одной миграции с временным отключением FK и пересозданием индексов; проверить на копииgalo.db. - [Запросы сканируют
emailвstring; теперь возможен NULL] → перейти наCOALESCE(email,'')в обоих хранилищах и покрыть тестами. - [Гонка уникальности] → опираться на unique-индексы, маппить нарушение в
email_taken/phone_taken. - [Удаление каскадом уничтожает общие альбомы] → необратимо и покрыто подтверждением паролем; поведение задокументировано в спеке.
- [Access-токен живёт ≤15 мин после смены пароля/удаления] → refresh отозван, при удалении пользователь отсутствует; клиенты разлогиниваются немедленно.
- [Значение email в JWT-claim может быть пустым] → claim не обязателен для авторизации
(используется
uid); оставляем как есть.
Migration Plan
- Применить миграции: Postgres
000013, SQLite000005(на копии проверить rebuild). - Задеплоить backend с новым контрактом.
- Обновить web и Android синхронно (единый релиз) из-за breaking login/register.
- Проверки: регистрация email-only, phone-only, обоими; вход обоими; добавление второго идентификатора; смена пароля (все сессии отозваны); удаление аккаунта с общими альбомами.
- Откат: down-миграции возвращают
email NOT NULLи удаляютphone; выполним только если нет phone-only аккаунтов. Удаление аккаунтов необратимо.
Open Questions
Нет — все развилки (верификация, обязательность, иммутабельность, политика пароля,
поведение общих альбомов, подтверждение удаления) зафиксированы в proposal.md и спеках.