2025-12-19 00:56:58 +00:00
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
|
|
|
|
|
|
import 'package:injectable/injectable.dart';
|
|
|
|
|
|
import 'package:minio/minio.dart';
|
|
|
|
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
|
|
import 'minio_config.dart';
|
|
|
|
|
|
|
|
|
|
|
|
/// Service for interacting with MinIO object storage
|
|
|
|
|
|
@lazySingleton
|
|
|
|
|
|
class MinioService {
|
|
|
|
|
|
final MinioConfig _config;
|
|
|
|
|
|
late final Minio _client;
|
|
|
|
|
|
final _uuid = const Uuid();
|
|
|
|
|
|
|
|
|
|
|
|
MinioService(this._config) {
|
|
|
|
|
|
_client = Minio(
|
|
|
|
|
|
endPoint: _config.endpoint,
|
|
|
|
|
|
port: _config.port,
|
|
|
|
|
|
accessKey: _config.accessKey,
|
|
|
|
|
|
secretKey: _config.secretKey,
|
|
|
|
|
|
useSSL: _config.useSSL,
|
|
|
|
|
|
region: _config.region,
|
2025-12-19 01:45:56 +00:00
|
|
|
|
enableTrace: true,
|
2025-12-19 00:56:58 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Ensures all required buckets exist, creates them if they don't
|
|
|
|
|
|
Future<void> ensureBucketsExist() async {
|
|
|
|
|
|
final buckets = [
|
|
|
|
|
|
MinioConfig.cardImagesBucket,
|
|
|
|
|
|
MinioConfig.voiceAudioBucket,
|
|
|
|
|
|
];
|
2025-12-19 01:45:56 +00:00
|
|
|
|
print('Config endpoint: ${_config.endpoint}');
|
|
|
|
|
|
print('Config port: ${_config.port}');
|
|
|
|
|
|
print('Config accessKey: ${_config.accessKey}');
|
|
|
|
|
|
print('Config secretKey: ${_config.secretKey}');
|
|
|
|
|
|
print('Config useSSL: ${_config.useSSL}');
|
|
|
|
|
|
print('Config region: ${_config.region}');
|
|
|
|
|
|
|
2025-12-19 00:56:58 +00:00
|
|
|
|
for (final bucket in buckets) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final exists = await _client.bucketExists(bucket);
|
|
|
|
|
|
if (!exists) {
|
|
|
|
|
|
await _client.makeBucket(bucket);
|
|
|
|
|
|
print('✅ Created MinIO bucket: $bucket');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
print('✅ MinIO bucket exists: $bucket');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
print('❌ Error checking/creating bucket $bucket: $e');
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Uploads a file to MinIO and returns the object ID
|
|
|
|
|
|
///
|
|
|
|
|
|
/// [bucket] - The bucket name
|
|
|
|
|
|
/// [bytes] - File content as bytes
|
|
|
|
|
|
/// [contentType] - MIME type of the file
|
|
|
|
|
|
/// [objectId] - Optional object ID (UUID). If not provided, generates a new one
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns the object ID (UUID) that can be stored in the database
|
|
|
|
|
|
Future<String> uploadFile({
|
|
|
|
|
|
required String bucket,
|
|
|
|
|
|
required Uint8List bytes,
|
|
|
|
|
|
required String contentType,
|
|
|
|
|
|
String? objectId,
|
|
|
|
|
|
}) async {
|
2026-01-24 17:24:51 +00:00
|
|
|
|
var id = objectId ?? _uuid.v4();
|
|
|
|
|
|
|
|
|
|
|
|
// Add file extension based on content type if not already present
|
|
|
|
|
|
if (!id.contains('.')) {
|
|
|
|
|
|
if (contentType.startsWith('audio/')) {
|
|
|
|
|
|
// Determine extension from content type
|
|
|
|
|
|
String extension = '.mp3'; // default for audio
|
|
|
|
|
|
if (contentType == 'audio/mpeg' || contentType == 'audio/mp3') {
|
|
|
|
|
|
extension = '.mp3';
|
|
|
|
|
|
} else if (contentType == 'audio/wav') {
|
|
|
|
|
|
extension = '.wav';
|
|
|
|
|
|
} else if (contentType == 'audio/ogg') {
|
|
|
|
|
|
extension = '.ogg';
|
|
|
|
|
|
} else if (contentType == 'audio/flac') {
|
|
|
|
|
|
extension = '.flac';
|
|
|
|
|
|
}
|
|
|
|
|
|
id = '$id$extension';
|
|
|
|
|
|
} else if (contentType.startsWith('image/')) {
|
|
|
|
|
|
// Determine extension from content type
|
|
|
|
|
|
String extension = '.png'; // default for images
|
|
|
|
|
|
if (contentType == 'image/jpeg' || contentType == 'image/jpg') {
|
|
|
|
|
|
extension = '.jpg';
|
|
|
|
|
|
} else if (contentType == 'image/png') {
|
|
|
|
|
|
extension = '.png';
|
|
|
|
|
|
} else if (contentType == 'image/webp') {
|
|
|
|
|
|
extension = '.webp';
|
|
|
|
|
|
} else if (contentType == 'image/gif') {
|
|
|
|
|
|
extension = '.gif';
|
|
|
|
|
|
}
|
|
|
|
|
|
id = '$id$extension';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-19 00:56:58 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-24 15:18:58 +00:00
|
|
|
|
// Определяем Cache-Control в зависимости от типа файла
|
|
|
|
|
|
// Для изображений: 7 дней, immutable (не изменяются)
|
|
|
|
|
|
// Для аудио: 7 дней
|
|
|
|
|
|
final cacheControl = contentType.startsWith('image/')
|
|
|
|
|
|
? 'public, max-age=604800, immutable' // 7 дней для изображений
|
|
|
|
|
|
: contentType.startsWith('audio/')
|
2026-01-24 16:07:29 +00:00
|
|
|
|
? 'public, max-age=604800' // 7 дней для аудио
|
|
|
|
|
|
: 'public, max-age=86400'; // 1 день для других
|
2026-01-24 15:18:58 +00:00
|
|
|
|
|
2025-12-19 00:56:58 +00:00
|
|
|
|
await _client.putObject(
|
|
|
|
|
|
bucket,
|
|
|
|
|
|
id,
|
|
|
|
|
|
Stream.value(bytes),
|
|
|
|
|
|
size: bytes.length,
|
2026-01-24 15:18:58 +00:00
|
|
|
|
metadata: {
|
|
|
|
|
|
'Content-Type': contentType,
|
|
|
|
|
|
// Попытка установить Cache-Control через metadata
|
|
|
|
|
|
// В некоторых SDK это может добавить префикс X-Amz-Meta-
|
|
|
|
|
|
// Но MinIO может обработать это как стандартный заголовок
|
|
|
|
|
|
'Cache-Control': cacheControl,
|
|
|
|
|
|
},
|
2025-12-19 00:56:58 +00:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-24 15:18:58 +00:00
|
|
|
|
print(
|
|
|
|
|
|
'✅ Uploaded file to MinIO: $bucket/$id (${bytes.length} bytes, Cache-Control: $cacheControl)',
|
|
|
|
|
|
);
|
2025-12-19 00:56:58 +00:00
|
|
|
|
return id;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
print('❌ Error uploading file to MinIO: $e');
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Generates a presigned URL for getting a file from MinIO
|
|
|
|
|
|
///
|
|
|
|
|
|
/// [bucket] - The bucket name
|
|
|
|
|
|
/// [objectId] - The object ID (UUID)
|
2025-12-20 11:25:32 +00:00
|
|
|
|
/// [expirySeconds] - Optional expiry time in seconds (default: 7 days)
|
2025-12-19 00:56:58 +00:00
|
|
|
|
///
|
|
|
|
|
|
/// Returns the presigned URL, or null if the object doesn't exist
|
|
|
|
|
|
Future<String?> getPresignedUrl({
|
|
|
|
|
|
required String bucket,
|
|
|
|
|
|
required String objectId,
|
|
|
|
|
|
int? expirySeconds,
|
|
|
|
|
|
}) async {
|
2025-12-20 11:25:32 +00:00
|
|
|
|
final expiry = expirySeconds ?? MinioConfig.presignedUrlExpirySeconds;
|
2025-12-19 00:56:58 +00:00
|
|
|
|
try {
|
|
|
|
|
|
// Check if object exists
|
|
|
|
|
|
await _client.statObject(bucket, objectId);
|
|
|
|
|
|
|
|
|
|
|
|
// Generate presigned URL
|
|
|
|
|
|
final url = await _client.presignedGetObject(
|
|
|
|
|
|
bucket,
|
|
|
|
|
|
objectId,
|
2025-12-20 11:25:32 +00:00
|
|
|
|
expires: expiry,
|
2025-12-19 00:56:58 +00:00
|
|
|
|
);
|
|
|
|
|
|
|
2025-12-20 18:26:15 +00:00
|
|
|
|
print(
|
|
|
|
|
|
'✅ Generated presigned URL for $bucket/$objectId (expires in ${expiry}s)',
|
|
|
|
|
|
);
|
2025-12-19 00:56:58 +00:00
|
|
|
|
return url;
|
2025-12-20 11:25:32 +00:00
|
|
|
|
} catch (e, s) {
|
2025-12-19 00:56:58 +00:00
|
|
|
|
print(
|
2025-12-20 11:25:32 +00:00
|
|
|
|
'❌ Unexpected error generating presigned URL for $bucket/$objectId: $e\n$s',
|
2025-12-19 00:56:58 +00:00
|
|
|
|
);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Deletes a file from MinIO
|
|
|
|
|
|
///
|
|
|
|
|
|
/// [bucket] - The bucket name
|
|
|
|
|
|
/// [objectId] - The object ID (UUID) to delete
|
|
|
|
|
|
Future<void> deleteFile({
|
|
|
|
|
|
required String bucket,
|
|
|
|
|
|
required String objectId,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await _client.removeObject(bucket, objectId);
|
|
|
|
|
|
print('✅ Deleted file from MinIO: $bucket/$objectId');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
print('❌ Error deleting file from MinIO: $e');
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Checks if a file exists in MinIO
|
|
|
|
|
|
///
|
|
|
|
|
|
/// [bucket] - The bucket name
|
|
|
|
|
|
/// [objectId] - The object ID (UUID) to check
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns true if the file exists, false otherwise
|
|
|
|
|
|
Future<bool> fileExists({
|
|
|
|
|
|
required String bucket,
|
|
|
|
|
|
required String objectId,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await _client.statObject(bucket, objectId);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|