chore(openspec): archive add-user-profile and sync main specs
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
# 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` → `users` ON 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`: rebuild `users` по 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
|
||||
|
||||
1. Применить миграции: Postgres `000013`, SQLite `000005` (на копии проверить rebuild).
|
||||
2. Задеплоить backend с новым контрактом.
|
||||
3. Обновить web и Android синхронно (единый релиз) из-за breaking login/register.
|
||||
4. Проверки: регистрация email-only, phone-only, обоими; вход обоими; добавление второго
|
||||
идентификатора; смена пароля (все сессии отозваны); удаление аккаунта с общими альбомами.
|
||||
5. Откат: down-миграции возвращают `email NOT NULL` и удаляют `phone`; выполним только если
|
||||
нет phone-only аккаунтов. Удаление аккаунтов необратимо.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет — все развилки (верификация, обязательность, иммутабельность, политика пароля,
|
||||
поведение общих альбомов, подтверждение удаления) зафиксированы в `proposal.md` и спеках.
|
||||
Reference in New Issue
Block a user