feat: Project peready 2.0
This commit is contained in:
8
LctMonolith/Services/Contracts/IFileStorageService.cs
Normal file
8
LctMonolith/Services/Contracts/IFileStorageService.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LctMonolith.Services.Interfaces;
|
||||
|
||||
public interface IFileStorageService
|
||||
{
|
||||
Task<string> UploadAsync(Stream content, string contentType, string keyPrefix, CancellationToken ct = default);
|
||||
Task DeleteAsync(string key, CancellationToken ct = default);
|
||||
Task<string> GetPresignedUrlAsync(string key, TimeSpan? expires = null, CancellationToken ct = default);
|
||||
}
|
||||
11
LctMonolith/Services/Contracts/IProfileService.cs
Normal file
11
LctMonolith/Services/Contracts/IProfileService.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace LctMonolith.Services.Interfaces;
|
||||
|
||||
using LctMonolith.Models.Database;
|
||||
|
||||
public interface IProfileService
|
||||
{
|
||||
Task<Profile?> GetByUserIdAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<Profile> UpsertAsync(Guid userId, string? firstName, string? lastName, DateOnly? birthDate, string? about, string? location, CancellationToken ct = default);
|
||||
Task<Profile> UpdateAvatarAsync(Guid userId, Stream fileStream, string contentType, string? fileName, CancellationToken ct = default);
|
||||
Task<bool> DeleteAvatarAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
121
LctMonolith/Services/ProfileService.cs
Normal file
121
LctMonolith/Services/ProfileService.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using LctMonolith.Database.UnitOfWork;
|
||||
using LctMonolith.Models.Database;
|
||||
using LctMonolith.Services.Interfaces;
|
||||
using Serilog;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Linq;
|
||||
|
||||
namespace LctMonolith.Services;
|
||||
|
||||
public class ProfileService : IProfileService
|
||||
{
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IFileStorageService _storage;
|
||||
|
||||
public ProfileService(IUnitOfWork uow, IFileStorageService storage)
|
||||
{
|
||||
_uow = uow;
|
||||
_storage = storage;
|
||||
}
|
||||
|
||||
public async Task<Profile?> GetByUserIdAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _uow.Profiles.Query(p => p.UserId == userId).FirstOrDefaultAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Profile get failed {UserId}", userId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Profile> UpsertAsync(Guid userId, string? firstName, string? lastName, DateOnly? birthDate, string? about, string? location, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = await _uow.Profiles.Query(p => p.UserId == userId).FirstOrDefaultAsync(ct);
|
||||
if (profile == null)
|
||||
{
|
||||
profile = new Profile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
BirthDate = birthDate,
|
||||
About = about,
|
||||
Location = location
|
||||
};
|
||||
await _uow.Profiles.AddAsync(profile, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
profile.FirstName = firstName;
|
||||
profile.LastName = lastName;
|
||||
profile.BirthDate = birthDate;
|
||||
profile.About = about;
|
||||
profile.Location = location;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
_uow.Profiles.Update(profile);
|
||||
}
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return profile;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Profile upsert failed {UserId}", userId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Profile> UpdateAvatarAsync(Guid userId, Stream fileStream, string contentType, string? fileName, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = await _uow.Profiles.Query(p => p.UserId == userId).FirstOrDefaultAsync(ct) ??
|
||||
await UpsertAsync(userId, null, null, null, null, null, ct);
|
||||
|
||||
// Delete old if exists
|
||||
if (!string.IsNullOrWhiteSpace(profile.AvatarS3Key))
|
||||
{
|
||||
await _storage.DeleteAsync(profile.AvatarS3Key!, ct);
|
||||
}
|
||||
var key = await _storage.UploadAsync(fileStream, contentType, $"avatars/{userId}", ct);
|
||||
var url = await _storage.GetPresignedUrlAsync(key, TimeSpan.FromHours(6), ct);
|
||||
profile.AvatarS3Key = key;
|
||||
profile.AvatarUrl = url;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
_uow.Profiles.Update(profile);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return profile;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Avatar update failed {UserId}", userId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAvatarAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = await _uow.Profiles.Query(p => p.UserId == userId).FirstOrDefaultAsync(ct);
|
||||
if (profile == null || string.IsNullOrWhiteSpace(profile.AvatarS3Key)) return false;
|
||||
await _storage.DeleteAsync(profile.AvatarS3Key!, ct);
|
||||
profile.AvatarS3Key = null;
|
||||
profile.AvatarUrl = null;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
_uow.Profiles.Update(profile);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Delete avatar failed {UserId}", userId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
LctMonolith/Services/S3FileStorageService.cs
Normal file
102
LctMonolith/Services/S3FileStorageService.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using Amazon;
|
||||
using Microsoft.Extensions.Options;
|
||||
using LctMonolith.Application.Options;
|
||||
using LctMonolith.Services.Interfaces;
|
||||
using Serilog;
|
||||
|
||||
namespace LctMonolith.Services;
|
||||
|
||||
public class S3FileStorageService : IFileStorageService, IDisposable
|
||||
{
|
||||
private readonly S3StorageOptions _opts;
|
||||
private readonly IAmazonS3 _client;
|
||||
private bool _bucketChecked;
|
||||
|
||||
public S3FileStorageService(IOptions<S3StorageOptions> options)
|
||||
{
|
||||
_opts = options.Value;
|
||||
var cfg = new AmazonS3Config
|
||||
{
|
||||
ServiceURL = _opts.Endpoint,
|
||||
ForcePathStyle = true,
|
||||
UseHttp = !_opts.UseSsl,
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
MaxErrorRetry = 2,
|
||||
};
|
||||
_client = new AmazonS3Client(_opts.AccessKey, _opts.SecretKey, cfg);
|
||||
}
|
||||
|
||||
private async Task EnsureBucketAsync(CancellationToken ct)
|
||||
{
|
||||
if (_bucketChecked) return;
|
||||
try
|
||||
{
|
||||
var list = await _client.ListBucketsAsync(ct);
|
||||
if (!list.Buckets.Any(b => string.Equals(b.BucketName, _opts.Bucket, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await _client.PutBucketAsync(new PutBucketRequest { BucketName = _opts.Bucket }, ct);
|
||||
}
|
||||
_bucketChecked = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed ensuring bucket {Bucket}", _opts.Bucket);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> UploadAsync(Stream content, string contentType, string keyPrefix, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureBucketAsync(ct);
|
||||
var key = $"{keyPrefix.Trim('/')}/{DateTime.UtcNow:yyyyMMdd}/{Guid.NewGuid():N}";
|
||||
var putReq = new PutObjectRequest
|
||||
{
|
||||
BucketName = _opts.Bucket,
|
||||
Key = key,
|
||||
InputStream = content,
|
||||
ContentType = contentType
|
||||
};
|
||||
await _client.PutObjectAsync(putReq, ct);
|
||||
Log.Information("Uploaded object {Key} to bucket {Bucket}", key, _opts.Bucket);
|
||||
return key;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
try
|
||||
{
|
||||
await _client.DeleteObjectAsync(_opts.Bucket, key, ct);
|
||||
Log.Information("Deleted object {Key}", key);
|
||||
}
|
||||
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
public Task<string> GetPresignedUrlAsync(string key, TimeSpan? expires = null, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key));
|
||||
if (!string.IsNullOrWhiteSpace(_opts.PublicBaseUrl))
|
||||
{
|
||||
var url = _opts.PublicBaseUrl!.TrimEnd('/') + "/" + key;
|
||||
return Task.FromResult(url);
|
||||
}
|
||||
var req = new GetPreSignedUrlRequest
|
||||
{
|
||||
BucketName = _opts.Bucket,
|
||||
Key = key,
|
||||
Expires = DateTime.UtcNow.Add(expires ?? TimeSpan.FromMinutes(_opts.PresignExpirationMinutes))
|
||||
};
|
||||
var urlSigned = _client.GetPreSignedURL(req);
|
||||
return Task.FromResult(urlSigned);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user