Сборка бэк

This commit is contained in:
Халимов Рустам
2026-03-27 15:45:34 +03:00
parent 16978c423c
commit 11f2b232a3
135 changed files with 2162 additions and 725 deletions

View File

@@ -0,0 +1,22 @@
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Application.Abstractions;
/// <summary>
/// Интерфейс для хранения аватаров в S3 (MinIO).
/// Отдельный от IFileStorageService модуля Storage,
/// чтобы не создавать прямой зависимости на Storage модуль.
/// </summary>
public interface IAvatarStorageService
{
/// <summary>
/// Загружает файл в S3-бакет аватаров и возвращает fileId (ключ объекта).
/// </summary>
Task<string> UploadAsync(System.IO.Stream stream, string fileName, string contentType, CancellationToken ct = default);
/// <summary>
/// Удаляет файл аватара из S3 по fileId.
/// </summary>
Task DeleteAsync(string fileId, CancellationToken ct = default);
}

View File

@@ -1,15 +1,15 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Abstractions;
namespace Knot.Modules.Profiles.Application.Abstractions;
public interface IProfileRepository
{
Task<Profile?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task AddAsync(Profile profile, CancellationToken cancellationToken = default);
void Update(Profile profile);
Task<System.Collections.Generic.List<Profile>> SearchProfilesAsync(string query, System.Threading.CancellationToken ct = default);
Task<ProfileDocument?> GetByIdAsync(Guid userId, CancellationToken ct = default);
Task AddAsync(ProfileDocument profile, CancellationToken ct = default);
Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default);
Task<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default);
}

View File

@@ -1,11 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Application.Abstractions;
public interface IProfilesUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken ct = default);
}

View File

@@ -1,69 +1,71 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Profiles.Application.Abstractions;
namespace Knot.Modules.Profiles.Application.Profiles.Avatar;
public sealed record CropAvatarCommand(Guid ProfileId, Stream FileStream, string FileName, string ContentType, int X, int Y, int Width, int Height) : ICommand<ProfileProfileDto>;
public sealed record CropAvatarCommand(
Guid UserId,
Stream FileStream,
string FileName,
string ContentType,
int X, int Y, int Width, int Height) : ICommand<UserProfileDto>;
internal sealed class CropAvatarCommandHandler : ICommandHandler<CropAvatarCommand, ProfileProfileDto>
internal sealed class CropAvatarCommandHandler : ICommandHandler<CropAvatarCommand, UserProfileDto>
{
private readonly IProfileRepository _profileRepository;
private readonly IProfilesUnitOfWork _unitOfWork;
private readonly IFileStorageService _fileStorage;
private readonly IProfileRepository _repository;
private readonly IAvatarStorageService _avatarStorage;
public CropAvatarCommandHandler(IProfileRepository profileRepository, IProfilesUnitOfWork unitOfWork, IFileStorageService fileStorage)
public CropAvatarCommandHandler(IProfileRepository repository, IAvatarStorageService avatarStorage)
{
_profileRepository = profileRepository;
_unitOfWork = unitOfWork;
_fileStorage = fileStorage;
_repository = repository;
_avatarStorage = avatarStorage;
}
public async Task<Result<ProfileProfileDto>> Handle(CropAvatarCommand request, CancellationToken cancellationToken)
public async Task<Result<UserProfileDto>> Handle(CropAvatarCommand request, CancellationToken cancellationToken)
{
var profile = await _profileRepository.GetByIdAsync(request.ProfileId, cancellationToken);
if (profile == null)
{
return Result.Failure<ProfileProfileDto>(ProfilesErrors.ProfileNotFound);
}
var profile = await _repository.GetByIdAsync(request.UserId, cancellationToken);
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
string avatarUrl;
// Обрезать и ресайзнуть изображение до 400×400
using var ms = await CropAndResizeAsync(request, cancellationToken);
using (var image = await SixLabors.ImageSharp.Image.LoadAsync(request.FileStream, cancellationToken))
{
int startX = Math.Max(0, Math.Min(request.X, image.Width - 1));
int startY = Math.Max(0, Math.Min(request.Y, image.Height - 1));
int rectWidth = Math.Max(1, Math.Min(request.Width, image.Width - startX));
int rectHeight = Math.Max(1, Math.Min(request.Height, image.Height - startY));
// Удалить старый аватар из S3
if (!string.IsNullOrEmpty(profile.AvatarUrl))
await _avatarStorage.DeleteAsync(profile.AvatarUrl, cancellationToken);
image.Mutate(ctx => ctx.Crop(new SixLabors.ImageSharp.Rectangle(startX, startY, rectWidth, rectHeight)));
image.Mutate(ctx => ctx.Resize(400, 400));
using var outStream = new MemoryStream();
await image.SaveAsJpegAsync(outStream, cancellationToken);
outStream.Position = 0;
var id = await _fileStorage.UploadFileAsync(outStream, request.FileName, "image/jpeg");
avatarUrl = $"/api/files/{id}";
}
var fileId = await _avatarStorage.UploadAsync(ms, "avatar.jpg", "image/jpeg", cancellationToken);
var avatarUrl = $"/api/files/{fileId}";
profile.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _repository.UpdateAsync(profile, cancellationToken);
var dto = new ProfileProfileDto(
profile.Id,
profile.Profilename,
profile.DisplayName,
profile.Avatar,
profile.Bio,
profile.Birthday,
profile.CreatedAt
);
return Result.Success(UserProfileDto.FromDocument(profile));
}
return Result.Success(dto);
private static async Task<MemoryStream> CropAndResizeAsync(CropAvatarCommand request, CancellationToken ct)
{
using var image = await Image.LoadAsync(request.FileStream, ct);
var startX = Math.Clamp(request.X, 0, image.Width - 1);
var startY = Math.Clamp(request.Y, 0, image.Height - 1);
var width = Math.Clamp(request.Width, 1, image.Width - startX);
var height = Math.Clamp(request.Height, 1, image.Height - startY);
image.Mutate(ctx => ctx
.Crop(new Rectangle(startX, startY, width, height))
.Resize(400, 400));
var output = new MemoryStream();
await image.SaveAsJpegAsync(output, ct);
output.Position = 0;
return output;
}
}

View File

@@ -1,44 +0,0 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Abstractions;
namespace Knot.Modules.Profiles.Application.Profiles.Avatar;
public sealed record DeleteAvatarCommand(Guid ProfileId) : ICommand<ProfileProfileDto>;
internal sealed class DeleteAvatarCommandHandler : ICommandHandler<DeleteAvatarCommand, ProfileProfileDto>
{
private readonly IProfileRepository _profileRepository;
private readonly IProfilesUnitOfWork _unitOfWork;
public DeleteAvatarCommandHandler(IProfileRepository profileRepository, IProfilesUnitOfWork unitOfWork)
{
_profileRepository = profileRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<ProfileProfileDto>> Handle(DeleteAvatarCommand request, CancellationToken cancellationToken)
{
var profile = await _profileRepository.GetByIdAsync(request.ProfileId, cancellationToken);
if (profile == null)
{
return Result.Failure<ProfileProfileDto>(ProfilesErrors.ProfileNotFound);
}
profile.UpdateAvatar(null);
await _unitOfWork.SaveChangesAsync(cancellationToken);
var dto = new ProfileProfileDto(
profile.Id,
profile.Profilename,
profile.DisplayName,
profile.Avatar,
profile.Bio,
profile.Birthday,
profile.CreatedAt
);
return Result.Success(dto);
}
}

View File

@@ -1,50 +1,79 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Shared.Kernel.Storage;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Application.Profiles.Avatar;
public sealed record UploadAvatarCommand(Guid ProfileId, Stream FileStream, string FileName, string ContentType) : ICommand<ProfileProfileDto>;
// ─── Upload ────────────────────────────────────────────────────────────────
internal sealed class UploadAvatarCommandHandler : ICommandHandler<UploadAvatarCommand, ProfileProfileDto>
public sealed record UploadAvatarCommand(
Guid UserId,
Stream FileStream,
string FileName,
string ContentType) : ICommand<UserProfileDto>;
internal sealed class UploadAvatarCommandHandler : ICommandHandler<UploadAvatarCommand, UserProfileDto>
{
private readonly IProfileRepository _profileRepository;
private readonly IProfilesUnitOfWork _unitOfWork;
private readonly IFileStorageService _fileStorage;
private readonly IProfileRepository _repository;
private readonly IAvatarStorageService _avatarStorage;
public UploadAvatarCommandHandler(IProfileRepository profileRepository, IProfilesUnitOfWork unitOfWork, IFileStorageService fileStorage)
public UploadAvatarCommandHandler(IProfileRepository repository, IAvatarStorageService avatarStorage)
{
_profileRepository = profileRepository;
_unitOfWork = unitOfWork;
_fileStorage = fileStorage;
_repository = repository;
_avatarStorage = avatarStorage;
}
public async Task<Result<ProfileProfileDto>> Handle(UploadAvatarCommand request, CancellationToken cancellationToken)
public async Task<Result<UserProfileDto>> Handle(UploadAvatarCommand request, CancellationToken cancellationToken)
{
var profile = await _profileRepository.GetByIdAsync(request.ProfileId, cancellationToken);
if (profile == null)
{
return Result.Failure<ProfileProfileDto>(ProfilesErrors.ProfileNotFound);
}
var profile = await _repository.GetByIdAsync(request.UserId, cancellationToken);
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
var fileId = await _fileStorage.UploadFileAsync(request.FileStream, request.FileName, request.ContentType);
// Удалить старый аватар из S3, если был
if (!string.IsNullOrEmpty(profile.AvatarUrl))
await _avatarStorage.DeleteAsync(profile.AvatarUrl, cancellationToken);
var fileId = await _avatarStorage.UploadAsync(request.FileStream, request.FileName, request.ContentType, cancellationToken);
var avatarUrl = $"/api/files/{fileId}";
profile.UpdateAvatar(avatarUrl);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _repository.UpdateAsync(profile, cancellationToken);
var dto = new ProfileProfileDto(
profile.Id,
profile.Profilename,
profile.DisplayName,
profile.Avatar,
profile.Bio,
profile.Birthday,
profile.CreatedAt
);
return Result.Success(dto);
return Result.Success(UserProfileDto.FromDocument(profile));
}
}
// ─── Delete ────────────────────────────────────────────────────────────────
public sealed record DeleteAvatarCommand(Guid UserId) : ICommand<UserProfileDto>;
internal sealed class DeleteAvatarCommandHandler : ICommandHandler<DeleteAvatarCommand, UserProfileDto>
{
private readonly IProfileRepository _repository;
private readonly IAvatarStorageService _avatarStorage;
public DeleteAvatarCommandHandler(IProfileRepository repository, IAvatarStorageService avatarStorage)
{
_repository = repository;
_avatarStorage = avatarStorage;
}
public async Task<Result<UserProfileDto>> Handle(DeleteAvatarCommand request, CancellationToken cancellationToken)
{
var profile = await _repository.GetByIdAsync(request.UserId, cancellationToken);
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
if (!string.IsNullOrEmpty(profile.AvatarUrl))
await _avatarStorage.DeleteAsync(profile.AvatarUrl, cancellationToken);
profile.RemoveAvatar();
await _repository.UpdateAsync(profile, cancellationToken);
return Result.Success(UserProfileDto.FromDocument(profile));
}
}

View File

@@ -0,0 +1,6 @@
using System;
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
public record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
public record UpdateSettingsRequest(bool? HideStoryViews);

View File

@@ -1,7 +0,0 @@
using System;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
public sealed record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);

View File

@@ -1,5 +0,0 @@
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
public sealed record UpdateSettingsRequest(bool? HideStoryViews);

View File

@@ -1,14 +0,0 @@
using System;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
public record ProfileDto(
Guid Id,
string Profilename,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);

View File

@@ -1,18 +1,29 @@
using System;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
public record ProfileProfileDto(
public record UserProfileDto(
Guid Id,
string Profilename,
string UserName,
string DisplayName,
string? Avatar,
string? AvatarUrl,
string? Bio,
DateTime? Birthday,
DateTime CreatedAt,
bool? HideStoryViews = null,
bool HideStoryViews = false,
bool IsOnline = false,
DateTime? LastSeen = null
);
DateTime? LastSeen = null)
{
public static UserProfileDto FromDocument(ProfileDocument doc) =>
new(
doc.Id,
doc.Username,
doc.DisplayName,
doc.AvatarUrl,
doc.Bio,
doc.Birthday,
doc.CreatedAt,
doc.HideStoryViews
);
}

View File

@@ -1,36 +1,29 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Application.Profiles.GetProfile;
public sealed record GetProfileQuery(Guid Id) : IQuery<ProfileProfileDto>;
public sealed record GetProfileQuery(Guid UserId) : IQuery<UserProfileDto>;
internal sealed class GetProfileQueryHandler : IQueryHandler<GetProfileQuery, ProfileProfileDto>
internal sealed class GetProfileQueryHandler : IQueryHandler<GetProfileQuery, UserProfileDto>
{
private readonly IProfileRepository _profileRepository;
private readonly IProfileRepository _repository;
public GetProfileQueryHandler(IProfileRepository profileRepository)
public GetProfileQueryHandler(IProfileRepository repository)
{
_profileRepository = profileRepository;
_repository = repository;
}
public async Task<Result<ProfileProfileDto>> Handle(GetProfileQuery request, CancellationToken cancellationToken)
public async Task<Result<UserProfileDto>> Handle(GetProfileQuery request, CancellationToken cancellationToken)
{
var profile = await _profileRepository.GetByIdAsync(request.Id, cancellationToken);
if (profile == null)
{
return Result.Failure<ProfileProfileDto>(ProfilesErrors.ProfileNotFound);
}
var profile = await _repository.GetByIdAsync(request.UserId, cancellationToken);
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
var dto = new ProfileProfileDto(
profile.Id,
profile.Profilename,
profile.Name,
profile.AvatarUrl
);
return Result.Success(dto);
return Result.Success(UserProfileDto.FromDocument(profile));
}
}

View File

@@ -0,0 +1,38 @@
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Domain;
using Knot.Shared.Kernel.Events;
using MediatR;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Application.Profiles.Integration;
/// <summary>
/// Реакция модуля Profiles на создание пользователя в модуле Auth.
/// Создает соответствующий документ в MongoDB.
/// </summary>
internal sealed class UserRegisteredDomainEventHandler : INotificationHandler<UserRegisteredDomainEvent>
{
private readonly IProfileRepository _repository;
public UserRegisteredDomainEventHandler(IProfileRepository repository)
{
_repository = repository;
}
public async Task Handle(UserRegisteredDomainEvent notification, CancellationToken cancellationToken)
{
// Проверяем, существует ли уже профиль (защита от дублей)
var existing = await _repository.GetByIdAsync(notification.UserId, cancellationToken);
if (existing is not null) return;
var profile = ProfileDocument.Create(
notification.UserId,
notification.Username,
notification.DisplayName,
notification.Bio
);
await _repository.AddAsync(profile, cancellationToken);
}
}

View File

@@ -1,34 +1,28 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Application.Profiles.Search;
public sealed record SearchProfilesQuery(string Query) : IQuery<List<ProfileDto>>;
public sealed record SearchProfilesQuery(string Query) : IQuery<List<UserProfileDto>>;
internal sealed class SearchProfilesQueryHandler : IQueryHandler<SearchProfilesQuery, List<ProfileDto>>
internal sealed class SearchProfilesQueryHandler : IQueryHandler<SearchProfilesQuery, List<UserProfileDto>>
{
private readonly IProfileRepository _profileRepository;
private readonly IProfileRepository _repository;
public SearchProfilesQueryHandler(IProfileRepository profileRepository)
public SearchProfilesQueryHandler(IProfileRepository repository)
{
_profileRepository = profileRepository;
_repository = repository;
}
public async Task<Result<List<ProfileDto>>> Handle(SearchProfilesQuery request, CancellationToken cancellationToken)
public async Task<Result<List<UserProfileDto>>> Handle(SearchProfilesQuery request, CancellationToken cancellationToken)
{
var profiles = await _profileRepository.SearchProfilesAsync(request.Query, cancellationToken);
var result = profiles.Select(profile => new ProfileDto(
profile.Id,
profile.Profilename,
profile.DisplayName,
profile.Avatar,
false,
DateTime.UtcNow
)).ToList();
return Result.Success(result);
var profiles = await _repository.SearchAsync(request.Query, cancellationToken);
var dtos = profiles.Select(UserProfileDto.FromDocument).ToList();
return Result.Success(dtos);
}
}

View File

@@ -1,43 +1,40 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
public sealed record UpdateProfileCommand(Guid UserId, string? DisplayName, string? Bio, DateTime? Birthday) : ICommand<UserProfileDto>;
public sealed record UpdateProfileCommand(
Guid UserId,
string? DisplayName,
string? Bio,
DateTime? Birthday) : ICommand<UserProfileDto>;
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
{
private readonly IProfileRepository _userRepository;
private readonly IProfilesUnitOfWork _unitOfWork;
private readonly IProfileRepository _repository;
public UpdateProfileCommandHandler(IProfileRepository userRepository, IProfilesUnitOfWork unitOfWork)
public UpdateProfileCommandHandler(IProfileRepository repository)
{
_userRepository = userRepository;
_unitOfWork = unitOfWork;
_repository = repository;
}
public async Task<Result<UserProfileDto>> Handle(UpdateProfileCommand request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
{
return Result.Failure<UserProfileDto>(IdentityErrors.UserNotFound);
}
var profile = await _repository.GetByIdAsync(request.UserId, cancellationToken);
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
user.UpdateProfile(request.DisplayName ?? user.DisplayName, request.Bio, request.Birthday);
await _unitOfWork.SaveChangesAsync(cancellationToken);
profile.UpdateProfile(
request.DisplayName ?? profile.DisplayName,
request.Bio,
request.Birthday);
var dto = new UserProfileDto(
user.Id,
user.Username,
user.DisplayName,
user.Avatar,
user.Bio,
user.Birthday,
user.CreatedAt
);
await _repository.UpdateAsync(profile, cancellationToken);
return Result.Success(dto);
return Result.Success(UserProfileDto.FromDocument(profile));
}
}

View File

@@ -1,41 +1,32 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Application.Profiles.UpdateSettings;
public sealed record UpdateSettingsCommand(Guid ProfileId, bool? HideStoryViews) : ICommand<ProfileProfileDto>;
public sealed record UpdateSettingsCommand(Guid UserId, bool? HideStoryViews) : ICommand<UserProfileDto>;
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, ProfileProfileDto>
internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSettingsCommand, UserProfileDto>
{
private readonly IProfileRepository _profileRepository;
private readonly IProfilesUnitOfWork _unitOfWork;
private readonly IProfileRepository _repository;
public UpdateSettingsCommandHandler(IProfileRepository profileRepository, IProfilesUnitOfWork unitOfWork)
public UpdateSettingsCommandHandler(IProfileRepository repository)
{
_profileRepository = profileRepository;
_unitOfWork = unitOfWork;
_repository = repository;
}
public async Task<Result<ProfileProfileDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
public async Task<Result<UserProfileDto>> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken)
{
var profile = await _profileRepository.GetByIdAsync(request.ProfileId, cancellationToken);
if (profile == null)
{
return Result.Failure<ProfileProfileDto>(ProfilesErrors.ProfileNotFound);
}
var profile = await _repository.GetByIdAsync(request.UserId, cancellationToken);
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
profile.UpdateSettings(request.HideStoryViews ?? profile.HideStoryViews);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _repository.UpdateAsync(profile, cancellationToken);
var dto = new ProfileProfileDto(
profile.Id,
profile.Profilename,
profile.Name,
profile.AvatarUrl
);
return Result.Success(dto);
return Result.Success(UserProfileDto.FromDocument(profile));
}
}

View File

@@ -1,14 +0,0 @@
using System;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Application.Profiles;
public record ProfileDto(
Guid Id,
string Profilename,
string DisplayName,
string? Avatar,
bool IsOnline,
DateTime LastSeen
);

View File

@@ -1,12 +0,0 @@
namespace Knot.Modules.Profiles.Application.Profiles;
using System;
public record UserProfileDto(Guid Id, string UserName, string Name, string AvatarUrl)
{
public UserProfileDto(Guid a, string b, string c, string d, string e, bool f, int g, int h, int i, string j = "") : this(a,b,c,e) {}
public UserProfileDto(Guid a, string b, string c, string d, string e, DateTime? f, DateTime g, string h="", string i="", string j="") : this(a,b,c,e) {}
}
public record ProfileProfileDto(Guid Id, string UserName, string Name, string AvatarUrl)
{
public ProfileProfileDto(Guid a, string b, string c, string d, string e, bool f, int g, int h, int i, string j = "") : this(a,b,c,e) {}
public ProfileProfileDto(Guid a, string b, string c, string d, string e, DateTime? f, DateTime g, string h="", string i="", string j="") : this(a,b,c,e) {}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Infrastructure.Database;
using MongoDB.Driver;
namespace Knot.Modules.Profiles;
public static class DependencyInjection
{
public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<IProfileRepository, ProfileRepository>();
services.AddScoped<IProfilesUnitOfWork, ProfilesUnitOfWork>();
services.AddScoped<IAvatarStorageService, AvatarStorageService>();
// MongoDB Registration
var mongoConnection = configuration.GetConnectionString("MongoConnection")
?? configuration["MONGO_URL"]
?? "mongodb://localhost:27017";
var mongoClient = new MongoClient(mongoConnection);
var database = mongoClient.GetDatabase("knot_messager");
services.AddSingleton<IMongoDatabase>(database);
services.AddMediatR(config =>
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
return services;
}
}

View File

@@ -0,0 +1,14 @@
using Knot.Shared.Kernel;
using System;
namespace Knot.Modules.Profiles.Domain.Events;
/// <summary>
/// Доменное событие: профиль пользователя создан при регистрации.
/// </summary>
public sealed record ProfileCreatedDomainEvent(Guid UserId, string Username) : IDomainEvent;
/// <summary>
/// Доменное событие: аватар профиля был изменён (для возможной инвалидации CDN-кэша).
/// </summary>
public sealed record ProfileAvatarChangedDomainEvent(Guid UserId, string? OldAvatarFileId, string? NewAvatarFileId) : IDomainEvent;

View File

@@ -0,0 +1,69 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System;
namespace Knot.Modules.Profiles.Domain;
/// <summary>
/// MongoDB-документ профиля пользователя.
/// Id совпадает с UserId из модуля Auth (Postgres).
/// Аватар хранится в S3 — здесь лежит только ссылка.
/// </summary>
public sealed class ProfileDocument
{
[BsonId]
[BsonRepresentation(BsonType.String)]
public Guid Id { get; private set; }
public string Username { get; private set; }
public string DisplayName { get; private set; }
public string? Bio { get; private set; }
/// <summary>
/// URL или ключ объекта в S3-хранилище (MinIO).
/// Пример: "/api/files/{fileId}"
/// </summary>
public string? AvatarUrl { get; private set; }
public DateTime? Birthday { get; private set; }
/// <summary>
/// Скрывать ли просмотры сторис от других пользователей.
/// </summary>
public bool HideStoryViews { get; private set; }
public DateTime CreatedAt { get; private set; }
// Для MongoDB — protected-конструктор через BSON-десериализацию
protected ProfileDocument() { }
private ProfileDocument(Guid id, string username, string displayName, string? bio)
{
Id = id;
Username = username;
DisplayName = displayName;
Bio = bio;
CreatedAt = DateTime.UtcNow;
}
public static ProfileDocument Create(Guid userId, string username, string displayName, string? bio = null)
=> new(userId, username, displayName, bio);
public void UpdateProfile(string displayName, string? bio, DateTime? birthday)
{
DisplayName = displayName;
Bio = bio;
Birthday = birthday;
}
public void UpdateAvatar(string? avatarUrl)
=> AvatarUrl = avatarUrl;
public void RemoveAvatar()
=> AvatarUrl = null;
public void UpdateSettings(bool hideStoryViews)
=> HideStoryViews = hideStoryViews;
}

View File

@@ -0,0 +1,33 @@
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Infrastructure.Database;
/// <summary>
/// Адаптер к S3-хранилищу для аватаров.
/// Инжектирует общий IFileStorageService и передает ему управление.
/// </summary>
internal sealed class AvatarStorageService : IAvatarStorageService
{
private readonly IFileStorageService _storage;
public AvatarStorageService(IFileStorageService storage)
{
_storage = storage;
}
public async Task<string> UploadAsync(Stream stream, string fileName, string contentType, CancellationToken ct = default)
{
return await _storage.UploadFileAsync(stream, fileName, contentType);
}
public async Task DeleteAsync(string fileId, CancellationToken ct = default)
{
// Если fileId содержит "/api/files/", обрезаем его до чистого ID
var cleanId = fileId.Replace("/api/files/", "");
await _storage.DeleteFileAsync(cleanId);
}
}

View File

@@ -0,0 +1,48 @@
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Modules.Profiles.Domain;
using MongoDB.Driver;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Infrastructure.Database;
internal sealed class ProfileRepository : IProfileRepository
{
private readonly IMongoCollection<ProfileDocument> _profiles;
public ProfileRepository(IMongoDatabase database)
{
_profiles = database.GetCollection<ProfileDocument>("profiles");
}
public async Task<ProfileDocument?> GetByIdAsync(Guid userId, CancellationToken ct = default)
{
return await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct);
}
public async Task AddAsync(ProfileDocument profile, CancellationToken ct = default)
{
await _profiles.InsertOneAsync(profile, null, ct);
}
public async Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default)
{
await _profiles.ReplaceOneAsync(p => p.Id == profile.Id, profile, new ReplaceOptions { IsUpsert = false }, ct);
}
public async Task<List<ProfileDocument>> SearchAsync(string query, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(query))
return new List<ProfileDocument>();
// Простой регистронезависимый поиск по Regex (в реальной системе лучше использовать Text Index)
var filter = Builders<ProfileDocument>.Filter.Or(
Builders<ProfileDocument>.Filter.Regex(p => p.Username, new MongoDB.Bson.BsonRegularExpression(query, "i")),
Builders<ProfileDocument>.Filter.Regex(p => p.DisplayName, new MongoDB.Bson.BsonRegularExpression(query, "i"))
);
return await _profiles.Find(filter).Limit(20).ToListAsync(ct);
}
}

View File

@@ -0,0 +1,12 @@
using Knot.Modules.Profiles.Application.Abstractions;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Infrastructure.Database;
internal sealed class ProfilesUnitOfWork : IProfilesUnitOfWork
{
// MongoDB updates are atomic per document by default in the driver,
// so for simple ProfileDocument updates, we don't need distributed transactions.
public Task SaveChangesAsync(CancellationToken ct = default) => Task.CompletedTask;
}

View File

@@ -15,12 +15,22 @@
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.2.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
<ProjectReference Include="..\Settings\Knot.Modules.Settings.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Knot.Modules.Profiles.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>