Миграции, сборка

This commit is contained in:
Халимов Рустам
2026-03-27 23:09:16 +03:00
parent 030ae1e4e4
commit ba5c5210d4
43 changed files with 406 additions and 194 deletions

View File

@@ -79,13 +79,13 @@ public sealed class AdminEndpoints : ICarterModule
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound("User not found");
});
group.MapGet("clean/dry-run", async (IFileStorageService fileStorage, AuthDbContext identityDb, ISender sender, CancellationToken ct) =>
group.MapGet("clean/dry-run", async ([FromServices] IFileStorageService fileStorage, [FromServices] AuthDbContext identityDb, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct);
return Results.Ok(result.Value);
});
group.MapPost("clean/run", async (IFileStorageService fileStorage, AuthDbContext identityDb, ISender sender, CancellationToken ct) =>
group.MapPost("clean/run", async ([FromServices] IFileStorageService fileStorage, [FromServices] AuthDbContext identityDb, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new CleanRunCommand(fileStorage, identityDb), ct);
return Results.Ok(result.Value);

View File

@@ -0,0 +1,92 @@
// <auto-generated />
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
[DbContext(typeof(AuthDbContext))]
[Migration("20270327140400_AddUserExtendedFields")]
partial class AddUserExtendedFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("identity")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddUserExtendedFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsExternal",
schema: "identity",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "Domain",
schema: "identity",
table: "Users",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsOnline",
schema: "identity",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "LastSeen",
schema: "identity",
table: "Users",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "HideStatus",
schema: "identity",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsExternal",
schema: "identity",
table: "Users");
migrationBuilder.DropColumn(
name: "Domain",
schema: "identity",
table: "Users");
migrationBuilder.DropColumn(
name: "IsOnline",
schema: "identity",
table: "Users");
migrationBuilder.DropColumn(
name: "LastSeen",
schema: "identity",
table: "Users");
migrationBuilder.DropColumn(
name: "HideStatus",
schema: "identity",
table: "Users");
}
}
}

View File

@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -45,12 +45,27 @@ namespace Knot.Modules.Auth.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");

View File

@@ -41,7 +41,7 @@ public sealed class FederationEndpoints : ICarterModule
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(new { error = result.Error.Description });
});
group.MapGet("/proxy/{id}", async (string id, [FromHeader(Name = "X-Knot-Signature")] string signature, [FromHeader(Name = "X-Knot-Domain")] string senderDomain, IFileStorageService storage, ISettingsService settingsService, CancellationToken ct) =>
group.MapGet("/proxy/{id}", async (string id, [FromHeader(Name = "X-Knot-Signature")] string signature, [FromHeader(Name = "X-Knot-Domain")] string senderDomain, [FromServices] IFileStorageService storage, [FromServices] ISettingsService settingsService, CancellationToken ct) =>
{
// 1. Валидация подписи (упрощенно)
var settings = await settingsService.GetSettingsAsync(ct);

View File

@@ -1,22 +0,0 @@
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,14 +0,0 @@
using Knot.Modules.Profiles.Domain;
using Microsoft.EntityFrameworkCore;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Profiles.Application.Abstractions;
namespace Knot.Modules.Profiles.Application.Abstractions;
public interface IProfilesDbContext
{
DbSet<Profile> Profiles { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,5 +1,5 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
@@ -34,10 +34,10 @@ internal sealed class CropAvatarCommandHandler : ICommandHandler<CropAvatarComma
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
// Обрезать и ресайзнуть изображение до 400×400
// Обрезать и ресайзнуть изображение до 400×400
using var ms = await CropAndResizeAsync(request, cancellationToken);
// Удалить старый аватар из S3
// Удалить старый аватар из S3
if (!string.IsNullOrEmpty(profile.AvatarUrl))
await _avatarStorage.DeleteAsync(profile.AvatarUrl, cancellationToken);

View File

@@ -1,5 +1,5 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using System;
using System.IO;
@@ -8,7 +8,7 @@ using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Application.Profiles.Avatar;
// ─── Upload ────────────────────────────────────────────────────────────────
// в”Ђв”Ђв”Ђ Upload в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
public sealed record UploadAvatarCommand(
Guid UserId,
@@ -33,7 +33,7 @@ internal sealed class UploadAvatarCommandHandler : ICommandHandler<UploadAvatarC
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
// Удалить старый аватар из S3, если был
// Удалить старый аватар из S3, если был
if (!string.IsNullOrEmpty(profile.AvatarUrl))
await _avatarStorage.DeleteAsync(profile.AvatarUrl, cancellationToken);
@@ -47,7 +47,7 @@ internal sealed class UploadAvatarCommandHandler : ICommandHandler<UploadAvatarC
}
}
// ─── Delete ────────────────────────────────────────────────────────────────
// в”Ђв”Ђв”Ђ Delete в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
public sealed record DeleteAvatarCommand(Guid UserId) : ICommand<UserProfileDto>;

View File

@@ -1,4 +1,4 @@
using System;
using System;
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;

View File

@@ -1,4 +1,4 @@
using System;
using System;
using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;

View File

@@ -1,5 +1,5 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using System;
using System.Threading;

View File

@@ -1,8 +1,14 @@
using MediatR;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Profiles.Domain;
using Knot.Shared.Kernel.Events;
namespace Knot.Modules.Profiles.Application.Profiles.Integration;
/// <summary>
/// Обработчик события из модуля Auth.
/// Создаёт пустой профиль при регистрации пользователя.
/// Обработчик события из модуля Auth.
/// Создаёт пустой профиль при регистрации пользователя.
/// </summary>
public class UserRegisteredDomainEventHandler : INotificationHandler<UserRegisteredDomainEvent>
{

View File

@@ -1,5 +1,5 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using System.Collections.Generic;
using System.Linq;

View File

@@ -1,5 +1,5 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using System;
using System.Threading;

View File

@@ -1,5 +1,5 @@
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Application.Abstractions;
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Application.Profiles.DTOs;
using System;
using System.Threading;

View File

@@ -1,3 +1,9 @@
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Infrastructure.Database;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
namespace Knot.Modules.Profiles;
public static class DependencyInjection
@@ -11,10 +17,11 @@ public static class DependencyInjection
// MongoDB Registration
var mongoConnection = configuration.GetConnectionString("MongoConnection")
?? configuration["MONGO_URL"]
?? "mongodb://localhost:27017";
?? "mongodb://mongo:27017";
var mongoClient = new MongoClient(mongoConnection);
var database = mongoClient.GetDatabase("knot_messager");
var databaseName = new MongoUrl(mongoConnection).DatabaseName ?? "knot_messager";
var database = mongoClient.GetDatabase(databaseName);
services.AddSingleton<IMongoDatabase>(database);
services.AddMediatR(config =>

View File

@@ -1,11 +1,11 @@
namespace Knot.Modules.Profiles.Domain.Events;
namespace Knot.Modules.Profiles.Domain.Events;
/// <summary>
/// Профиль пользователя создан.
/// Профиль пользователя создан.
/// </summary>
public record ProfileCreatedDomainEvent(Guid ProfileId) : IDomainEvent;
/// <summary>
/// Профиль пользователя обновлен.
/// Профиль пользователя обновлен.
/// </summary>
public record ProfileUpdatedDomainEvent(Guid ProfileId) : IDomainEvent;

View File

@@ -0,0 +1,11 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Domain;
public interface IAvatarStorageService
{
Task<string> UploadAsync(Stream content, string fileName, string contentType, CancellationToken ct = default);
Task DeleteAsync(string fileId, CancellationToken ct = default);
}

View File

@@ -1,8 +1,13 @@
namespace Knot.Modules.Profiles.Application.Abstractions;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Domain;
public interface IProfileRepository
{
Task<ProfileDocument?> GetByIdAsync(Guid userId, CancellationToken ct = default);
Task<ProfileDocument?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<ProfileDocument?> GetByUsernameAsync(string username, CancellationToken ct = default);
Task AddAsync(ProfileDocument profile, CancellationToken ct = default);
Task UpdateAsync(ProfileDocument profile, CancellationToken ct = default);

View File

@@ -1,7 +1,7 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Knot.Modules.Profiles.Application.Abstractions;
namespace Knot.Modules.Profiles.Domain;
public interface IProfilesUnitOfWork
{

View File

@@ -1,4 +1,4 @@
namespace Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Domain;
public sealed class Profile : AggregateRoot<Guid>
{

View File

@@ -1,8 +1,11 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Knot.Modules.Profiles.Domain;
/// <summary>
/// MongoDB-документ профиля пользователя.
/// Id совпадает с UserId из модуля Auth (Postgres).
/// MongoDB-документ профиля пользователя.
/// Id совпадает с UserId из модуля Auth (Postgres).
/// </summary>
public sealed class ProfileDocument
{

View File

@@ -1,4 +1,4 @@
namespace Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Domain;
using Knot.Shared.Kernel;
public static class ProfilesErrors
{

View File

@@ -1,7 +1,6 @@
global using Knot.Shared.Kernel;
global using Knot.Shared.Kernel.Events;
global using Knot.Shared.Kernel.Storage;
global using Knot.Modules.Profiles.Application.Abstractions;
global using Knot.Modules.Profiles.Domain;
global using Knot.Modules.Profiles.Domain.Events;
global using Knot.Modules.Profiles.Infrastructure.Database;

View File

@@ -1,3 +1,9 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Profiles.Domain;
using Knot.Shared.Kernel.Storage;
namespace Knot.Modules.Profiles.Infrastructure.Database;
public class AvatarStorageService : IAvatarStorageService
@@ -11,13 +17,13 @@ public class AvatarStorageService : IAvatarStorageService
public async Task<string> UploadAsync(Stream content, string fileName, string contentType, CancellationToken ct = default)
{
// IFileStorageService не принимает CancellationToken в UploadFileAsync
// IFileStorageService не принимает CancellationToken в UploadFileAsync
return await _fileStorage.UploadFileAsync(content, fileName, contentType);
}
public async Task DeleteAsync(string fileId, CancellationToken ct = default)
{
// IFileStorageService не принимает CancellationToken в DeleteFileAsync
// IFileStorageService не принимает CancellationToken в DeleteFileAsync
await _fileStorage.DeleteFileAsync(fileId);
}
}

View File

@@ -1,3 +1,11 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Profiles.Domain;
using MongoDB.Bson;
using MongoDB.Driver;
namespace Knot.Modules.Profiles.Infrastructure.Database;
public class ProfileRepository : IProfileRepository

View File

@@ -1,8 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Infrastructure.Database;
public class ProfilesUnitOfWork : IProfilesUnitOfWork
{
// MongoDB не поддерживает транзакции без репликации в простом виде,
// поэтому UnitOfWork здесь формальный для соответствия интерфейсу.
public Task SaveChangesAsync(CancellationToken ct = default) => Task.CompletedTask;
}

View File

@@ -1,4 +1,4 @@
using Carter;
using Carter;
using Knot.Shared.Kernel;
using Knot.Modules.Profiles.Application.Profiles.Avatar;
using Knot.Modules.Profiles.Application.Profiles.GetProfile;

View File

@@ -19,7 +19,7 @@ public sealed class FilesEndpoints : ICarterModule
{
var group = app.MapGroup("api/files");
group.MapGet("{id}", async (string id, [FromQuery] bool download, IFileStorageService fileStorage, IMemoryCache cache, CancellationToken ct) =>
group.MapGet("{id}", async (string id, [FromQuery] bool download, [FromServices] IFileStorageService fileStorage, [FromServices] IMemoryCache cache, CancellationToken ct) =>
{
// (Текущая логика локальных файлов остается без изменений)
try