Files
glchat/internal/store/users.go
T
grendervill 33dc1fec96 feat(social): друзья, личные беседы, присутствие, аватары и часовой пояс
По запросу пользователя (вне очереди AGENT.md §13):

- главного сервера как точки входа больше нет: новичок начинает с пустым
  списком серверов, вход — только по приглашению или созданием своего;
- друзья: поиск по логину с экранированием LIKE, заявки (POST
  /users/@me/relationships), принятие, удаление/отклонение, списки friends/
  incoming/outgoing/blocked;
- личные беседы: POST /users/@me/channels (идемпотентно), GET
  /users/@me/channels со собеседником, статусом и последним сообщением;
  сообщения в DM работают через общие ручки комнат, доступ — только участникам
  (посторонний получает 404, события в Gateway тоже фильтруются);
- присутствие: last_seen_at обновляется при активности, «невидимка» и простой
  дольше двух минут выглядят как офлайн, смена статуса рассылает
  PRESENCE_UPDATE друзьям;
- READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение);
- профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me,
  загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том
  числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл
  удаляется с диска;
- тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой
  пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
2026-09-20 01:11:04 +03:00

344 lines
11 KiB
Go

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
// LastSeenAt — время последней активности (для статусов друзей).
LastSeenAt *time.Time
// Timezone — часовой пояс пользователя (IANA, по умолчанию Europe/Moscow).
Timezone string
}
// 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, last_seen_at, timezone`
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
}
// ClearAvatar убирает ссылку на файл аватара.
func (s *Store) ClearAvatar(ctx context.Context, id uint64) error {
_, err := s.writer.ExecContext(ctx,
`UPDATE users SET avatar_file_id = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
s.Now(), int64(id))
return err
}
// 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
lastSeenAt 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, &lastSeenAt, &user.Timezone,
)
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
}
if lastSeenAt.Valid {
value := parseTimestamp(lastSeenAt.String)
user.LastSeenAt = &value
}
if user.Timezone == "" {
user.Timezone = "Europe/Moscow"
}
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")
}