- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
160 lines
7.3 KiB
Go
160 lines
7.3 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/clock"
|
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
|
"git.vakhrushev.me/av/transcriber/internal/ident"
|
|
)
|
|
|
|
// UserRepository — учётные записи сервиса.
|
|
type UserRepository struct {
|
|
db *DB
|
|
}
|
|
|
|
func NewUserRepository(db *DB) *UserRepository {
|
|
return &UserRepository{db: db}
|
|
}
|
|
|
|
// EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит
|
|
// её.
|
|
//
|
|
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
|
|
// представиться — личные токены — возьмёт этот же метод; правило, уложенное
|
|
// куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском,
|
|
// либо вытаскивать задним числом.
|
|
//
|
|
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
|
|
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
|
|
// его работы.
|
|
//
|
|
// **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда,
|
|
// когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому
|
|
// пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку —
|
|
// опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы
|
|
// в очередь к единственному пишущему соединению. Ждать там нечего: заводится
|
|
// учётная запись один раз за жизнь человека.
|
|
//
|
|
// **Окно между двумя соединениями закрыто повторным поиском внутри
|
|
// транзакции.** Между поиском читающим пулом и открытием пишущей транзакции
|
|
// запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а
|
|
// уникальность ключа держит схема — не порядок обращений.
|
|
func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) {
|
|
login, ok := entity.AcceptProviderLogin(identity.Login)
|
|
if !ok {
|
|
return nil, false, contract.ErrLoginNotAcceptable
|
|
}
|
|
|
|
account, err := findUserByLogin(repo.db.Reader(), login)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if account != nil {
|
|
return account, false, nil
|
|
}
|
|
|
|
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
// Повторный поиск закрывает окно между читающим пулом и пишущей
|
|
// транзакцией: пока её ждали, запись мог завести сосед.
|
|
account, err = findUserByLogin(tx, login)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if account != nil {
|
|
return account, false, commitAccount(tx, account)
|
|
}
|
|
|
|
name := entity.AcceptDisplayName(identity.Name)
|
|
email, _ := entity.AcceptEmail(identity.Email)
|
|
|
|
account, err = insertUser(tx, login, name, email)
|
|
switch {
|
|
case err == nil:
|
|
return account, true, commitAccount(tx, account)
|
|
case !isUniqueViolation(err):
|
|
return nil, false, fmt.Errorf("failed to create user account: %w", err)
|
|
}
|
|
|
|
// **Два отказа уникальности различаются, и исход у них разный**, а какая
|
|
// колонка не сошлась, код отказа не называет. Различает их повторный поиск
|
|
// по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо
|
|
// просто взять заведённую соседом запись.
|
|
account, err = findUserByLogin(tx, login)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if account != nil {
|
|
return account, false, commitAccount(tx, account)
|
|
}
|
|
|
|
// Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от
|
|
// провайдера, занят другой учётной записью (общий ящик, семья, группа).
|
|
// Запись заводится **без почты**: она необязательна и ключом не служит. Без
|
|
// этого разреза второй человек с общим адресом не завёлся бы никогда —
|
|
// повторный поиск по логину снова ничего не находит.
|
|
account, err = insertUser(tx, login, name, "")
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("failed to create user account without email: %w", err)
|
|
}
|
|
|
|
return account, true, commitAccount(tx, account)
|
|
}
|
|
|
|
func commitAccount(tx *sql.Tx, account *contract.UserAccount) error {
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) {
|
|
id := ident.New()
|
|
now := formatTime(clock.Now())
|
|
|
|
_, err := tx.ExecContext(context.Background(),
|
|
`INSERT INTO users (id, provider_login, name, email, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
id, login, name, email, now, now,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &contract.UserAccount{ID: id, Name: name}, nil
|
|
}
|
|
|
|
// rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и
|
|
// у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним
|
|
// запросом: второй его копией они разошлись бы молча.
|
|
type rowQuerier interface {
|
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
|
}
|
|
|
|
// findUserByLogin ищет учётную запись по ключу. Значение уходит базе
|
|
// **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и
|
|
// подставленная в текст она правила бы сам запрос, а не только его аргумент.
|
|
func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) {
|
|
account := &contract.UserAccount{}
|
|
err := q.QueryRowContext(context.Background(),
|
|
"SELECT id, name FROM users WHERE provider_login = ?", login,
|
|
).Scan(&account.ID, &account.Name)
|
|
switch {
|
|
case err == nil:
|
|
return account, nil
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return nil, nil
|
|
default:
|
|
return nil, fmt.Errorf("failed to look up user account: %w", err)
|
|
}
|
|
}
|