package store import ( "context" "database/sql" "time" ) // File — загруженный файл: метаданные в БД, содержимое на диске (AGENT.md 7.7). type File struct { ID uint64 UploaderID *uint64 GuildID *uint64 ChannelID *uint64 MessageID *uint64 Filename string ContentType string SizeBytes int64 Width int Height int StoragePath string SHA256 string // Purpose: attachment (по умолчанию) | avatar | banner — от него зависит // проверка доступа при выдаче файла. Purpose string CreatedAt time.Time } // CreateFileParams — параметры регистрации файла. type CreateFileParams struct { ID uint64 UploaderID uint64 GuildID *uint64 ChannelID *uint64 Filename string ContentType string SizeBytes int64 Width int Height int StoragePath string SHA256 string Purpose string } const fileColumns = `id, uploader_id, guild_id, channel_id, message_id, filename, content_type, size_bytes, width, height, storage_path, sha256, created_at, purpose` // CreateFile регистрирует загруженный файл. func (s *Store) CreateFile(ctx context.Context, params CreateFileParams) (*File, error) { if params.ID == 0 { params.ID = s.NextID() } if params.Purpose == "" { params.Purpose = "attachment" } _, err := s.writer.ExecContext(ctx, ` INSERT INTO files (id, uploader_id, guild_id, channel_id, message_id, filename, content_type, size_bytes, width, height, storage_path, sha256, created_at, purpose) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, int64(params.ID), int64(params.UploaderID), nullableID(params.GuildID), nullableID(params.ChannelID), params.Filename, params.ContentType, params.SizeBytes, params.Width, params.Height, params.StoragePath, params.SHA256, s.Now(), params.Purpose) if err != nil { return nil, mapError(err) } return s.GetFile(ctx, params.ID) } func (s *Store) GetFile(ctx context.Context, id uint64) (*File, error) { row := s.reader.QueryRowContext(ctx, `SELECT `+fileColumns+` FROM files WHERE id = ?`, int64(id)) return scanFile(row) } // AttachFileToMessage связывает файл с сообщением после отправки. func (s *Store) AttachFileToMessage(ctx context.Context, fileID, messageID uint64) error { result, err := s.writer.ExecContext(ctx, `UPDATE files SET message_id = ? WHERE id = ?`, int64(messageID), int64(fileID)) if err != nil { return err } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return ErrNotFound } return nil } // DeleteFile удаляет запись о файле (содержимое чистит вызывающий код). func (s *Store) DeleteFile(ctx context.Context, id uint64) error { result, err := s.writer.ExecContext(ctx, `DELETE FROM files WHERE id = ?`, int64(id)) if err != nil { return err } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return ErrNotFound } return nil } // orphanFilesQuery — файлы, на которые никто не ссылается: вложения сообщений, // аватары и баннеры пользователей, оформление сервера, роли и комнаты, эмодзи, // звуки, вебхуки и приглашения (AGENT.md 6.4, 7.7). const orphanFilesQuery = ` SELECT ` + fileColumns + ` FROM files f WHERE f.created_at <= ? AND f.message_id IS NULL AND NOT EXISTS (SELECT 1 FROM users u WHERE u.avatar_file_id = f.id OR u.banner_file_id = f.id) AND NOT EXISTS (SELECT 1 FROM guilds g WHERE g.icon_file_id = f.id OR g.banner_file_id = f.id OR g.splash_file_id = f.id) AND NOT EXISTS (SELECT 1 FROM channels c WHERE c.background_file_id = f.id OR c.icon_file_id = f.id) AND NOT EXISTS (SELECT 1 FROM invites i WHERE i.background_file_id = f.id) AND NOT EXISTS (SELECT 1 FROM guild_cosmetics gc WHERE gc.file_id = f.id) AND NOT EXISTS (SELECT 1 FROM webhooks w WHERE w.avatar_file_id = f.id) AND NOT EXISTS (SELECT 1 FROM guild_emojis ge WHERE ge.file_id = f.id) AND NOT EXISTS (SELECT 1 FROM guild_sounds gs WHERE gs.file_id = f.id) AND NOT EXISTS ( SELECT 1 FROM messages m, json_each(m.attachments_json) a WHERE json_extract(a.value, '$.file_id') = f.id ) ORDER BY f.id LIMIT ?` // ListOrphanFiles возвращает файлы без ссылок старше указанного времени: их // удаляет обслуживание (AGENT.md 6.4, 7.7). func (s *Store) ListOrphanFiles(ctx context.Context, olderThan time.Time, limit int) ([]File, error) { if limit <= 0 || limit > 500 { limit = 100 } rows, err := s.reader.QueryContext(ctx, orphanFilesQuery, s.Timestamp(olderThan), limit) if err != nil { return nil, err } defer rows.Close() files := make([]File, 0, limit) for rows.Next() { file, err := scanFile(rows) if err != nil { return nil, err } files = append(files, *file) } return files, rows.Err() } func scanFile(scanner interface{ Scan(...any) error }) (*File, error) { var ( file File uploaderID sql.NullInt64 guildID sql.NullInt64 channelID sql.NullInt64 messageID sql.NullInt64 createdAt string ) err := scanner.Scan(&file.ID, &uploaderID, &guildID, &channelID, &messageID, &file.Filename, &file.ContentType, &file.SizeBytes, &file.Width, &file.Height, &file.StoragePath, &file.SHA256, &createdAt, &file.Purpose) if err != nil { return nil, mapError(err) } file.UploaderID = optionalID(uploaderID) file.GuildID = optionalID(guildID) file.ChannelID = optionalID(channelID) file.MessageID = optionalID(messageID) file.CreatedAt = parseTimestamp(createdAt) return &file, nil } // FileStoragePaths возвращает пути всех файлов, известных базе: по ним // обслуживание понимает, какие файлы на диске остались без записи. func (s *Store) FileStoragePaths(ctx context.Context) (map[string]bool, error) { rows, err := s.reader.QueryContext(ctx, `SELECT storage_path FROM files WHERE storage_path <> ''`) if err != nil { return nil, mapError(err) } defer rows.Close() paths := map[string]bool{} for rows.Next() { var path string if err := rows.Scan(&path); err != nil { return nil, mapError(err) } paths[path] = true } return paths, rows.Err() } // GuildStoragePaths возвращает пути файлов сервера: их нужно удалить с диска // перед удалением сервера, иначе содержимое останется мусором (AGENT.md 6.4). func (s *Store) GuildStoragePaths(ctx context.Context, guildID uint64) ([]string, error) { rows, err := s.reader.QueryContext(ctx, `SELECT storage_path FROM files WHERE guild_id = ? AND storage_path <> ''`, int64(guildID)) if err != nil { return nil, mapError(err) } defer rows.Close() paths := make([]string, 0, 8) for rows.Next() { var path string if err := rows.Scan(&path); err != nil { return nil, err } paths = append(paths, path) } return paths, rows.Err() }