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 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 } const fileColumns = `id, uploader_id, guild_id, channel_id, message_id, filename, content_type, size_bytes, width, height, storage_path, sha256, created_at` // CreateFile регистрирует загруженный файл. func (s *Store) CreateFile(ctx context.Context, params CreateFileParams) (*File, error) { if params.ID == 0 { params.ID = s.NextID() } _, 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) 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()) 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 } // ListOrphanFiles возвращает файлы без сообщения старше указанного времени: // их удаляет обслуживание (AGENT.md 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, ` SELECT `+fileColumns+` FROM files WHERE message_id IS NULL AND created_at < ? ORDER BY id LIMIT ?`, 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) 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 }