Initial commit: base slice (auth, roles, users, admin) scaffold
Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL + Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4 with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's conventions, scoped down to the current base feature set.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Behaviors;
|
||||
|
||||
/// <summary>Строит Result/Result<T> failure-ответ через reflection — общий хелпер для generic pipeline behaviors.</summary>
|
||||
internal static class ResultFailureFactory
|
||||
{
|
||||
public static TResponse Create<TResponse>(Error error)
|
||||
where TResponse : Result
|
||||
{
|
||||
if (typeof(TResponse) == typeof(Result))
|
||||
return (TResponse)(object)Result.Failure(error);
|
||||
|
||||
var valueType = typeof(TResponse).GetGenericArguments()[0];
|
||||
var method = typeof(Result)
|
||||
.GetMethod(nameof(Result.Failure), 1, [typeof(Error)])!
|
||||
.MakeGenericMethod(valueType);
|
||||
|
||||
return (TResponse)method.Invoke(null, [error])!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Коммитит изменения после успешного выполнения команды. Применяется автоматически только
|
||||
/// к запросам, реализующим <see cref="ICommand{TResponse}"/> — благодаря generic-ограничению
|
||||
/// DI-контейнер не сможет сконструировать это поведение для запросов (IQuery).
|
||||
/// </summary>
|
||||
public sealed class UnitOfWorkBehavior<TRequest, TResponse>(IAppDbContext dbContext)
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : ICommand<TResponse>
|
||||
{
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var response = await next();
|
||||
|
||||
if (response is not Result { IsSuccess: false })
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Behaviors;
|
||||
|
||||
public sealed class ValidationBehavior<TRequest, TResponse>(
|
||||
IEnumerable<IValidator<TRequest>> validators
|
||||
) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
where TResponse : Result
|
||||
{
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!validators.Any())
|
||||
return await next();
|
||||
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
var failures = validators
|
||||
.Select(v => v.Validate(context))
|
||||
.SelectMany(r => r.Errors)
|
||||
.ToList();
|
||||
|
||||
if (failures.Count == 0)
|
||||
return await next();
|
||||
|
||||
var error = Error.Validation(
|
||||
"Validation.Failed",
|
||||
string.Join("; ", failures.Select(f => f.ErrorMessage))
|
||||
);
|
||||
|
||||
return ResultFailureFactory.Create<TResponse>(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Domain.Auth;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public interface IAppDbContext
|
||||
{
|
||||
DbSet<RefreshToken> RefreshTokens { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public interface ICurrentUser
|
||||
{
|
||||
Guid? UserId { get; }
|
||||
string? UserName { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsBlocked);
|
||||
|
||||
public sealed record UserSummaryDto(
|
||||
Guid Id,
|
||||
string UserName,
|
||||
string Role,
|
||||
bool IsBlocked,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
public interface IIdentityService
|
||||
{
|
||||
/// <summary>Создаёт пользователя и назначает роль по умолчанию (см. Infrastructure/Identity/RoleNames).</summary>
|
||||
Task<Result<Guid>> CreateUserAsync(
|
||||
string userName,
|
||||
string password,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(
|
||||
string userName,
|
||||
string password,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<CurrentUserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> ChangePasswordAsync(
|
||||
Guid userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result> ChangeUserNameAsync(
|
||||
Guid userId,
|
||||
string newUserName,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Удаляет аккаунт (самоудаление или удаление админом).</summary>
|
||||
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> BlockUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<PagedList<UserSummaryDto>> ListUsersAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
Guid? roleId,
|
||||
bool? isBlocked,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<UserSummaryDto?> GetUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
|
||||
|
||||
public interface IJwtTokenService
|
||||
{
|
||||
(string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public sealed record IssuedRefreshToken(string RawToken, DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record RotatedRefreshToken(Guid UserId, string RawToken, DateTimeOffset ExpiresAt);
|
||||
|
||||
public interface IRefreshTokenService
|
||||
{
|
||||
Task<IssuedRefreshToken> IssueAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result<RotatedRefreshToken>> RotateAsync(
|
||||
string rawToken,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task RevokeAsync(string rawToken, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public sealed record RoleDto(Guid Id, string Name, bool IsSystem);
|
||||
|
||||
public interface IRoleService
|
||||
{
|
||||
Task<Result<RoleDto>> CreateRoleAsync(string name, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result<RoleDto>> UpdateRoleAsync(
|
||||
Guid roleId,
|
||||
string name,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<RoleDto>> ListRolesAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace TeleWave.Application.Common.Models;
|
||||
|
||||
public enum ErrorType
|
||||
{
|
||||
Failure,
|
||||
Validation,
|
||||
NotFound,
|
||||
Conflict,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure)
|
||||
{
|
||||
public static readonly Error None = new(string.Empty, string.Empty);
|
||||
|
||||
public static Error Validation(string code, string message) =>
|
||||
new(code, message, ErrorType.Validation);
|
||||
|
||||
public static Error NotFound(string code, string message) =>
|
||||
new(code, message, ErrorType.NotFound);
|
||||
|
||||
public static Error Conflict(string code, string message) =>
|
||||
new(code, message, ErrorType.Conflict);
|
||||
|
||||
public static Error Unauthorized(string code, string message) =>
|
||||
new(code, message, ErrorType.Unauthorized);
|
||||
|
||||
public static Error Forbidden(string code, string message) =>
|
||||
new(code, message, ErrorType.Forbidden);
|
||||
|
||||
public static Error Failure(string code, string message) =>
|
||||
new(code, message, ErrorType.Failure);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace TeleWave.Application.Common.Models;
|
||||
|
||||
public sealed record PagedList<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize);
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace TeleWave.Application.Common.Models;
|
||||
|
||||
public class Result
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public Error Error { get; }
|
||||
|
||||
protected Result(bool isSuccess, Error error)
|
||||
{
|
||||
if (isSuccess && error != Error.None)
|
||||
throw new InvalidOperationException("Успешный результат не может содержать ошибку.");
|
||||
if (!isSuccess && error == Error.None)
|
||||
throw new InvalidOperationException("Неуспешный результат обязан содержать ошибку.");
|
||||
|
||||
IsSuccess = isSuccess;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public static Result Success() => new(true, Error.None);
|
||||
|
||||
public static Result Failure(Error error) => new(false, error);
|
||||
|
||||
public static Result<T> Success<T>(T value) => new(value, true, Error.None);
|
||||
|
||||
public static Result<T> Failure<T>(Error error) => new(default, false, error);
|
||||
}
|
||||
|
||||
public class Result<T> : Result
|
||||
{
|
||||
private readonly T? _value;
|
||||
|
||||
internal Result(T? value, bool isSuccess, Error error)
|
||||
: base(isSuccess, error) => _value = value;
|
||||
|
||||
public T Value =>
|
||||
IsSuccess
|
||||
? _value!
|
||||
: throw new InvalidOperationException(
|
||||
"Нельзя получить значение неуспешного результата."
|
||||
);
|
||||
|
||||
public static implicit operator Result<T>(T value) => Success(value);
|
||||
}
|
||||
Reference in New Issue
Block a user