package store import ( "context" "database/sql" "encoding/json" "strings" "time" ) // User — аккаунт пользователя. Email в БД лежит зашифрованным (AGENT.md 9.2), // поэтому в модели он заполняется только тогда, когда реально расшифрован. type User struct { ID uint64 Username string DisplayName string Email string PasswordHash string AvatarFileID *uint64 BannerFileID *uint64 Bio string Status string CustomStatus string CustomStatusEmoji string Flags int64 IsInstanceAdmin bool Badges []string Locale string CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time // OnboardingCompletedAt заполняется после первичной настройки (AGENT.md 7.2). OnboardingCompletedAt *time.Time } // CreateUserParams — данные новой учётной записи: шифрование и blind index // выполняет вызывающий код (internal/auth), чтобы store не знал про ключи. type CreateUserParams struct { ID uint64 Username string DisplayName string EmailEnc string EmailIndex string PasswordHash string Locale string } 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, onboarding_completed_at` func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) { if params.ID == 0 { params.ID = s.NextID() } if params.DisplayName == "" { params.DisplayName = params.Username } if params.Locale == "" { params.Locale = "ru" } ts := s.Now() _, err := s.writer.ExecContext(ctx, ` INSERT INTO users (id, username, username_lower, display_name, email_enc, email_index, password_hash, locale, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, int64(params.ID), params.Username, strings.ToLower(params.Username), params.DisplayName, params.EmailEnc, params.EmailIndex, params.PasswordHash, params.Locale, ts, ts, ) if err != nil { if isUniqueViolation(err) { return nil, ErrConflict } return nil, err } return s.GetUser(ctx, params.ID) } func (s *Store) GetUser(ctx context.Context, id uint64) (*User, error) { row := s.reader.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = ? AND deleted_at IS NULL`, int64(id)) return scanUser(row) } func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) { row := s.reader.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE username_lower = ? AND deleted_at IS NULL`, strings.ToLower(username)) return scanUser(row) } // GetUserByEmailIndex ищет пользователя по blind index: сам email в запросе // не участвует, поэтому поиск не требует расшифровки (AGENT.md 9.2). func (s *Store) GetUserByEmailIndex(ctx context.Context, emailIndex string) (*User, error) { row := s.reader.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE email_index = ? AND deleted_at IS NULL`, emailIndex) return scanUser(row) } // EncryptedEmail возвращает сохранённый шифротекст email для расшифровки. func (s *Store) EncryptedEmail(ctx context.Context, id uint64) (string, error) { var encrypted string err := s.reader.QueryRowContext(ctx, `SELECT email_enc FROM users WHERE id = ?`, int64(id)).Scan(&encrypted) if err != nil { return "", mapError(err) } return encrypted, nil } func (s *Store) CountUsers(ctx context.Context) (int, error) { var count int if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`).Scan(&count); err != nil { return 0, err } return count, nil } type UpdateUserParams struct { DisplayName *string Bio *string Status *string CustomStatus *string CustomStatusEmoji *string AvatarFileID *uint64 BannerFileID *uint64 Locale *string } func (s *Store) UpdateUser(ctx context.Context, id uint64, params UpdateUserParams) (*User, error) { var ( sets []string args []any ) add := func(column string, value any) { sets = append(sets, column+" = ?") args = append(args, value) } if params.DisplayName != nil { add("display_name", *params.DisplayName) } if params.Bio != nil { add("bio", *params.Bio) } if params.Status != nil { add("status", *params.Status) } if params.CustomStatus != nil { add("custom_status", *params.CustomStatus) } if params.CustomStatusEmoji != nil { add("custom_status_emoji", *params.CustomStatusEmoji) } if params.AvatarFileID != nil { add("avatar_file_id", int64(*params.AvatarFileID)) } if params.BannerFileID != nil { add("banner_file_id", int64(*params.BannerFileID)) } if params.Locale != nil { add("locale", *params.Locale) } if len(sets) == 0 { return s.GetUser(ctx, id) } add("updated_at", s.Now()) args = append(args, int64(id)) result, err := s.writer.ExecContext(ctx, `UPDATE users SET `+strings.Join(sets, ", ")+` WHERE id = ?`, args...) if err != nil { return nil, err } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return nil, ErrNotFound } return s.GetUser(ctx, id) } // UpdateUserPassword меняет хэш пароля и возвращает прежнее значение, чтобы // вызывающий код мог отозвать все сессии (AGENT.md 7.1). func (s *Store) UpdateUserPassword(ctx context.Context, id uint64, passwordHash string) error { result, err := s.writer.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`, passwordHash, s.Now(), int64(id)) if err != nil { return err } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return ErrNotFound } return nil } func (s *Store) SetInstanceAdmin(ctx context.Context, id uint64, admin bool) error { result, err := s.writer.ExecContext(ctx, `UPDATE users SET is_instance_admin = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`, boolToInt(admin), s.Now(), int64(id)) if err != nil { return err } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return ErrNotFound } return nil } func (s *Store) SetUserBadges(ctx context.Context, id uint64, badges []string) error { encoded, err := json.Marshal(badges) if err != nil { return err } result, err := s.writer.ExecContext(ctx, `UPDATE users SET badges_json = ?, updated_at = ? WHERE id = ?`, string(encoded), s.Now(), int64(id)) if err != nil { return err } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return ErrNotFound } 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 avatarID sql.NullInt64 bannerID sql.NullInt64 isAdmin int badges string 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) } if avatarID.Valid { value := uint64(avatarID.Int64) user.AvatarFileID = &value } if bannerID.Valid { value := uint64(bannerID.Int64) user.BannerFileID = &value } user.IsInstanceAdmin = isAdmin == 1 if err := json.Unmarshal([]byte(badges), &user.Badges); err != nil { user.Badges = nil } user.CreatedAt = parseTimestamp(createdAt) user.UpdatedAt = parseTimestamp(updatedAt) if deletedAt.Valid { value := parseTimestamp(deletedAt.String) user.DeletedAt = &value } if onboardingAt.Valid { value := parseTimestamp(onboardingAt.String) user.OnboardingCompletedAt = &value } return &user, nil } func parseTimestamp(value string) time.Time { for _, layout := range []string{"2006-01-02T15:04:05.000Z", time.RFC3339Nano, time.RFC3339} { if parsed, err := time.Parse(layout, value); err == nil { return parsed.UTC() } } return time.Time{} } func isUniqueViolation(err error) bool { return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint") }