feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса

REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
2026-09-19 21:50:06 +03:00
parent 86dff94a02
commit 1b1a679827
63 changed files with 8477 additions and 187 deletions
+51 -1
View File
@@ -29,6 +29,8 @@ type User struct {
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
// OnboardingCompletedAt заполняется после первичной настройки (AGENT.md 7.2).
OnboardingCompletedAt *time.Time
}
// CreateUserParams — данные новой учётной записи: шифрование и blind index
@@ -45,7 +47,8 @@ type CreateUserParams struct {
const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id,
banner_file_id, bio, status, custom_status, custom_status_emoji, flags,
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at`
is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at,
onboarding_completed_at`
func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) {
if params.ID == 0 {
@@ -218,6 +221,47 @@ func (s *Store) SetUserBadges(ctx context.Context, id uint64, badges []string) e
return nil
}
// MarkOnboardingCompleted фиксирует завершение первичной настройки (AGENT.md 7.2).
func (s *Store) MarkOnboardingCompleted(ctx context.Context, id uint64) error {
result, err := s.writer.ExecContext(ctx,
`UPDATE users SET onboarding_completed_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
s.Now(), s.Now(), int64(id))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
// ListUsers отдаёт страницу пользователей для админ-панели инстанса (AGENT.md 6.5).
func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
rows, err := s.reader.QueryContext(ctx,
`SELECT `+userColumns+` FROM users WHERE deleted_at IS NULL ORDER BY id LIMIT ? OFFSET ?`,
limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]User, 0, limit)
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, *user)
}
return users, rows.Err()
}
func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
var (
user User
@@ -228,12 +272,14 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
createdAt string
updatedAt string
deletedAt sql.NullString
onboardingAt sql.NullString
emailEncrypted string
)
err := scanner.Scan(
&user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash,
&avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji,
&user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt,
&onboardingAt,
)
if err != nil {
return nil, mapError(err)
@@ -256,6 +302,10 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) {
value := parseTimestamp(deletedAt.String)
user.DeletedAt = &value
}
if onboardingAt.Valid {
value := parseTimestamp(onboardingAt.String)
user.OnboardingCompletedAt = &value
}
return &user, nil
}