From 377ebf3c0ae984b705fa6627b9e3f98dce846d8b Mon Sep 17 00:00:00 2001 From: Aakiyaru <83872701+Aakiyaru@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:27:35 +0700 Subject: [PATCH 1/7] =?UTF-8?q?=D0=A1=D1=83=D1=89=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D0=BE=20=D0=B2=D1=8B=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Library.API/Program.cs | 1 - Library.Domain/Entities/BookLoan.cs | 77 +++++++++++++++++++++++++++++ Library.Domain/Entities/User.cs | 50 +++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 Library.Domain/Entities/BookLoan.cs create mode 100644 Library.Domain/Entities/User.cs diff --git a/Library.API/Program.cs b/Library.API/Program.cs index fb80bfb..b728a06 100644 --- a/Library.API/Program.cs +++ b/Library.API/Program.cs @@ -1,4 +1,3 @@ - using Library.Applictation.Interfaces; using Library.Applictation.Validators; using Library.Infrastructure.Data; diff --git a/Library.Domain/Entities/BookLoan.cs b/Library.Domain/Entities/BookLoan.cs new file mode 100644 index 0000000..de24900 --- /dev/null +++ b/Library.Domain/Entities/BookLoan.cs @@ -0,0 +1,77 @@ +using Microsoft.VisualBasic; + +namespace Library.Domain.Entities +{ + public class BookLoan + { + public Guid Id { get; private set; } + public Guid UserId { get; private set; } + public User User { get; private set; } + public Guid BookId { get; private set; } + public Book Book { get; private set; } + public DateTime BorrowDate { get; private set; } + public DateTime DueDate { get; private set; } //плановая дата возврата + public DateTime? ReturnDate { get; private set; } //фактическая дата возврата, null если не возвращена + public decimal? Fine { get; private set; } //штраф + + private BookLoan() { } + + public static BookLoan Create(User user, Book book, int loanDays = 14) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if(book == null) + { + throw new ArgumentNullException(nameof(book)); + } + + if(book.AvailableCopies <= 0) + { + throw new InvalidOperationException("Нет доступных экземпляров книги"); + } + + if(!user.CanBorrowMore()) + { + throw new InvalidOperationException("Пользователь уже взял максимум книг (5)"); + } + + return new BookLoan + { + Id = Guid.NewGuid(), + User = user, + UserId = user.Id, + Book = book, + BookId = book.Id, + BorrowDate = DateTime.UtcNow, + DueDate = DateTime.UtcNow.AddDays(loanDays) + }; + } + + //метод возврата с учётом штрафа + public void Return(decimal? finePerDay = null) + { + if (ReturnDate.HasValue) + { + throw new InvalidOperationException("Книга уже возвращена"); + } + + ReturnDate = DateTime.UtcNow; + Book.ReturnCopy(); + + if (ReturnDate > DueDate) + { + var daysOverude = (ReturnDate.Value - DueDate).Days; + //Если не указано, используем 10 рублей в день + var rate = finePerDay ?? 10m; + Fine = daysOverude * rate; + } + else + { + Fine = 0; + } + } + } +} diff --git a/Library.Domain/Entities/User.cs b/Library.Domain/Entities/User.cs new file mode 100644 index 0000000..55a1ce6 --- /dev/null +++ b/Library.Domain/Entities/User.cs @@ -0,0 +1,50 @@ +using System.Text.RegularExpressions; + +namespace Library.Domain.Entities +{ + public class User + { + public Guid Id { get; private set; } + public string FullName { get; private set; } + public string Email { get; private set; } + public DateTime RegistrationDate { get; private set; } + + //навигационное совйство для истории выдач + private readonly List _loans = new(); + public IReadOnlyCollection Loans => _loans.AsReadOnly(); + + private User() { } + + public static User Create(string fullName, string email) + { + if (string.IsNullOrWhiteSpace(fullName)) + { + throw new ArgumentException("Имя не может быть пустым", nameof(fullName)); + } + + if (!IsValidEmail(email)) + { + throw new ArgumentException("Неверный формат email", nameof(email)); + } + + return new User + { + Id = Guid.NewGuid(), + FullName = fullName, + Email = email, + RegistrationDate = DateTime.UtcNow, + }; + } + + private static bool IsValidEmail(string email) + { + //простая валидация, можно использовать Regex + return email.Contains('@') && email.Contains('.'); + } + + public bool CanBorrowMore() + { + return Loans.Count(l => l.ReturnDate == null) < 5; + } + } +} From c4c5bcf487c90caadeec994fe69493a4abed2df4 Mon Sep 17 00:00:00 2001 From: Aakiyaru <83872701+Aakiyaru@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:39:31 +0700 Subject: [PATCH 2/7] =?UTF-8?q?=D0=A1=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D0=BB=D0=B5?= =?UTF-8?q?=D1=80=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B8/=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BD=D0=B8=D0=B3,=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=87=D1=91=D1=82=20=D1=88=D1=82=D1=80=D0=B0=D1=84?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BF=D1=80=D0=BE=D1=81=D1=80?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Library.API/Controllers/BooksController.cs | 7 - Library.API/Controllers/LoansController.cs | 67 ++++++++ Library.API/Program.cs | 12 +- Library.API/Properties/launchSettings.json | 31 ++-- Library.API/appsettings.json | 2 +- Library.Applictation/Dtos/BookLoanDto.cs | 13 ++ Library.Applictation/Dtos/BorrowRequest.cs | 8 + Library.Applictation/Dtos/ReturnRequest.cs | 6 + .../Interfaces/ILoanService.cs | 11 ++ Library.Domain/Entities/BookLoan.cs | 115 ++++++------- Library.Infrastructure/Data/AppDbContext.cs | 33 ++++ .../20260930032133_AddUserAndLoan.Designer.cs | 153 ++++++++++++++++++ .../20260930032133_AddUserAndLoan.cs | 110 +++++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 150 +++++++++++++++++ .../Services/LoanService.cs | 96 +++++++++++ 15 files changed, 728 insertions(+), 86 deletions(-) create mode 100644 Library.API/Controllers/LoansController.cs create mode 100644 Library.Applictation/Dtos/BookLoanDto.cs create mode 100644 Library.Applictation/Dtos/BorrowRequest.cs create mode 100644 Library.Applictation/Dtos/ReturnRequest.cs create mode 100644 Library.Applictation/Interfaces/ILoanService.cs create mode 100644 Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.Designer.cs create mode 100644 Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.cs create mode 100644 Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs create mode 100644 Library.Infrastructure/Services/LoanService.cs diff --git a/Library.API/Controllers/BooksController.cs b/Library.API/Controllers/BooksController.cs index 8dc4f96..f039b24 100644 --- a/Library.API/Controllers/BooksController.cs +++ b/Library.API/Controllers/BooksController.cs @@ -16,13 +16,6 @@ public BooksController(IBookService bookService) _bookService = bookService; } - [HttpGet] - public async Task GetAll() - { - var books = await _bookService.GetAllBooksAsync(); - return Ok(books); - } - [HttpGet("{id}")] public async Task GetById(Guid id) { diff --git a/Library.API/Controllers/LoansController.cs b/Library.API/Controllers/LoansController.cs new file mode 100644 index 0000000..3ca035b --- /dev/null +++ b/Library.API/Controllers/LoansController.cs @@ -0,0 +1,67 @@ +using Library.Application.Dtos; +using Library.Application.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace Library.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class LoansController : ControllerBase +{ + private readonly ILoanService _loanService; + + public LoansController(ILoanService loanService) + { + _loanService = loanService; + } + + [HttpGet] + public async Task GetAll() + { + var loans = await _loanService.GetAllLoansAsync(); + return Ok(loans); + } + + [HttpGet("user/{userId}")] + public async Task GetUserLoans(Guid userId) + { + var loans = await _loanService.GetUserLoansAsync(userId); + return Ok(loans); + } + + [HttpPost("borrow")] + public async Task Borrow([FromBody] BorrowRequest request) + { + try + { + var result = await _loanService.BorrowBookAsync(request); + return CreatedAtAction(nameof(GetUserLoans), new { userId = request.UserId }, result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Message = ex.Message }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { Message = ex.Message }); + } + } + + [HttpPost("return")] + public async Task Return([FromBody] ReturnRequest request) + { + try + { + var result = await _loanService.ReturnBookAsync(request); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Message = ex.Message }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { Message = ex.Message }); + } + } +} \ No newline at end of file diff --git a/Library.API/Program.cs b/Library.API/Program.cs index b728a06..7a16c68 100644 --- a/Library.API/Program.cs +++ b/Library.API/Program.cs @@ -1,10 +1,11 @@ +using FluentValidation; +using Library.API.Middleware; +using Library.Application.Interfaces; using Library.Applictation.Interfaces; using Library.Applictation.Validators; using Library.Infrastructure.Data; using Library.Infrastructure.Services; using Microsoft.EntityFrameworkCore; -using FluentValidation; -using Library.API.Middleware; namespace Library.API { @@ -19,9 +20,10 @@ public static void Main(string[] args) // Ñòðîêà ïîäêëþ÷åíèÿ áóäåò ëåæàòü â appsettings.json builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); - builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); - builder.Services.AddValidatorsFromAssembly(typeof(CreateBookRequestValidator).Assembly); + builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi @@ -36,6 +38,8 @@ public static void Main(string[] args) app.UseSwaggerUI(); } + app.UseDeveloperExceptionPage(); // ïîêàæåò äåòàëè îøèáêè ïðÿìî â áðàóçåðå + app.UseHttpsRedirection(); app.UseAuthorization(); diff --git a/Library.API/Properties/launchSettings.json b/Library.API/Properties/launchSettings.json index fec597c..f76b2d4 100644 --- a/Library.API/Properties/launchSettings.json +++ b/Library.API/Properties/launchSettings.json @@ -1,23 +1,36 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", +{ "profiles": { "http": { "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5114", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - } + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5114" }, "https": { "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7237;http://localhost:5114", + "applicationUrl": "https://localhost:7237;http://localhost:5114" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } + }, + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:62415/", + "sslPort": 44307 + } } -} +} \ No newline at end of file diff --git a/Library.API/appsettings.json b/Library.API/appsettings.json index 62c1f4e..aee4674 100644 --- a/Library.API/appsettings.json +++ b/Library.API/appsettings.json @@ -7,6 +7,6 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "DefaultConnection": "Server=localhost;Database=LibraryDb;User Id=sa;Password=YourPassword;TrustServerCertificate=True;" + "DefaultConnection": "Server=itsql.elektro.ru;Database=test;Integrated Security=true;TrustServerCertificate=True;" } } diff --git a/Library.Applictation/Dtos/BookLoanDto.cs b/Library.Applictation/Dtos/BookLoanDto.cs new file mode 100644 index 0000000..771d6ba --- /dev/null +++ b/Library.Applictation/Dtos/BookLoanDto.cs @@ -0,0 +1,13 @@ +namespace Library.Application.Dtos; + +public class BookLoanDto +{ + public Guid Id { get; set; } + public string UserName { get; set; } + public string BookTitle { get; set; } + public DateTime BorrowDate { get; set; } + public DateTime DueDate { get; set; } + public DateTime? ReturnDate { get; set; } + public decimal? Fine { get; set; } + public bool IsActive => ReturnDate == null; +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/BorrowRequest.cs b/Library.Applictation/Dtos/BorrowRequest.cs new file mode 100644 index 0000000..6f8c9aa --- /dev/null +++ b/Library.Applictation/Dtos/BorrowRequest.cs @@ -0,0 +1,8 @@ +namespace Library.Application.Dtos; + +public class BorrowRequest +{ + public Guid UserId { get; set; } + public Guid BookId { get; set; } + public int? LoanDays { get; set; } // опционально, по умолчанию 14 +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/ReturnRequest.cs b/Library.Applictation/Dtos/ReturnRequest.cs new file mode 100644 index 0000000..51318a0 --- /dev/null +++ b/Library.Applictation/Dtos/ReturnRequest.cs @@ -0,0 +1,6 @@ +namespace Library.Application.Dtos; + +public class ReturnRequest +{ + public Guid LoanId { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Interfaces/ILoanService.cs b/Library.Applictation/Interfaces/ILoanService.cs new file mode 100644 index 0000000..fadea4d --- /dev/null +++ b/Library.Applictation/Interfaces/ILoanService.cs @@ -0,0 +1,11 @@ +using Library.Application.Dtos; + +namespace Library.Application.Interfaces; + +public interface ILoanService +{ + Task> GetAllLoansAsync(); + Task BorrowBookAsync(BorrowRequest request); + Task ReturnBookAsync(ReturnRequest request); + Task> GetUserLoansAsync(Guid userId); +} \ No newline at end of file diff --git a/Library.Domain/Entities/BookLoan.cs b/Library.Domain/Entities/BookLoan.cs index de24900..18a5e18 100644 --- a/Library.Domain/Entities/BookLoan.cs +++ b/Library.Domain/Entities/BookLoan.cs @@ -1,77 +1,62 @@ -using Microsoft.VisualBasic; +namespace Library.Domain.Entities; -namespace Library.Domain.Entities +public class BookLoan { - public class BookLoan - { - public Guid Id { get; private set; } - public Guid UserId { get; private set; } - public User User { get; private set; } - public Guid BookId { get; private set; } - public Book Book { get; private set; } - public DateTime BorrowDate { get; private set; } - public DateTime DueDate { get; private set; } //плановая дата возврата - public DateTime? ReturnDate { get; private set; } //фактическая дата возврата, null если не возвращена - public decimal? Fine { get; private set; } //штраф + public Guid Id { get; private set; } + public Guid UserId { get; private set; } + public User User { get; private set; } - private BookLoan() { } + public Guid BookId { get; private set; } + public Book Book { get; private set; } - public static BookLoan Create(User user, Book book, int loanDays = 14) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } + public DateTime BorrowDate { get; private set; } + public DateTime DueDate { get; private set; } // плановая дата возврата + public DateTime? ReturnDate { get; private set; } // фактическая дата возврата (null, если не возвращена) + public decimal? Fine { get; private set; } // штраф (если есть) - if(book == null) - { - throw new ArgumentNullException(nameof(book)); - } + private BookLoan() { } - if(book.AvailableCopies <= 0) - { - throw new InvalidOperationException("Нет доступных экземпляров книги"); - } + public static BookLoan Create(User user, Book book, int loanDays = 14) + { + if (user == null) throw new ArgumentNullException(nameof(user)); + if (book == null) throw new ArgumentNullException(nameof(book)); + if (book.AvailableCopies <= 0) + throw new InvalidOperationException("Нет доступных экземпляров книги"); + if (!user.CanBorrowMore()) + throw new InvalidOperationException("Пользователь уже взял максимум книг (5)"); + + return new BookLoan + { + Id = Guid.NewGuid(), + User = user, + UserId = user.Id, + Book = book, + BookId = book.Id, + BorrowDate = DateTime.UtcNow, + DueDate = DateTime.UtcNow.AddDays(loanDays) + }; + } - if(!user.CanBorrowMore()) - { - throw new InvalidOperationException("Пользователь уже взял максимум книг (5)"); - } + // Метод возврата книги с расчётом штрафа + public void Return(decimal? finePerDay = null) + { + if (ReturnDate.HasValue) + throw new InvalidOperationException("Книга уже возвращена"); - return new BookLoan - { - Id = Guid.NewGuid(), - User = user, - UserId = user.Id, - Book = book, - BookId = book.Id, - BorrowDate = DateTime.UtcNow, - DueDate = DateTime.UtcNow.AddDays(loanDays) - }; - } + ReturnDate = DateTime.UtcNow; + Book.ReturnCopy(); // увеличиваем AvailableCopies - //метод возврата с учётом штрафа - public void Return(decimal? finePerDay = null) + // Расчёт штрафа, если просрочка + if (ReturnDate > DueDate) { - if (ReturnDate.HasValue) - { - throw new InvalidOperationException("Книга уже возвращена"); - } - - ReturnDate = DateTime.UtcNow; - Book.ReturnCopy(); - - if (ReturnDate > DueDate) - { - var daysOverude = (ReturnDate.Value - DueDate).Days; - //Если не указано, используем 10 рублей в день - var rate = finePerDay ?? 10m; - Fine = daysOverude * rate; - } - else - { - Fine = 0; - } + var daysOverdue = (ReturnDate.Value - DueDate).Days; + // Если не указано, используем 10 рублей в день + var rate = finePerDay ?? 10m; + Fine = daysOverdue * rate; + } + else + { + Fine = 0; } } -} +} \ No newline at end of file diff --git a/Library.Infrastructure/Data/AppDbContext.cs b/Library.Infrastructure/Data/AppDbContext.cs index 9b1c15b..5ad6399 100644 --- a/Library.Infrastructure/Data/AppDbContext.cs +++ b/Library.Infrastructure/Data/AppDbContext.cs @@ -8,6 +8,8 @@ public class AppDbContext : DbContext public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Books { get; set; } + public DbSet Users { get; set; } + public DbSet Loans { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -34,5 +36,36 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(b => b.Genre) .HasMaxLength(100); }); + + // Настройка User + modelBuilder.Entity(entity => + { + entity.ToTable("Users"); + entity.HasKey(u => u.Id); + entity.Property(u => u.FullName).IsRequired().HasMaxLength(100); + entity.Property(u => u.Email).IsRequired().HasMaxLength(100); + entity.HasIndex(u => u.Email).IsUnique(); + }); + + // Настройка BookLoan + modelBuilder.Entity(entity => + { + entity.ToTable("BookLoans"); + entity.HasKey(l => l.Id); + + entity.Property(l => l.Fine).HasPrecision(18, 2); + + // Связь с User (один ко многим) + entity.HasOne(l => l.User) + .WithMany(u => u.Loans) + .HasForeignKey(l => l.UserId) + .OnDelete(DeleteBehavior.Restrict); + + // Связь с Book (один ко многим) + entity.HasOne(l => l.Book) + .WithMany() // у Book пока нет коллекции, можем добавить позже + .HasForeignKey(l => l.BookId) + .OnDelete(DeleteBehavior.Restrict); + }); } } \ No newline at end of file diff --git a/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.Designer.cs b/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.Designer.cs new file mode 100644 index 0000000..4a68d54 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.Designer.cs @@ -0,0 +1,153 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930032133_AddUserAndLoan")] + partial class AddUserAndLoan + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.cs b/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.cs new file mode 100644 index 0000000..6e590c5 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930032133_AddUserAndLoan.cs @@ -0,0 +1,110 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + /// + public partial class AddUserAndLoan : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Books", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Title = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + ISBN = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + Genre = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + PublicationYear = table.Column(type: "int", nullable: false), + TotalCopies = table.Column(type: "int", nullable: false), + AvailableCopies = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Books", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + FullName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Email = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + RegistrationDate = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "BookLoans", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + BookId = table.Column(type: "uniqueidentifier", nullable: false), + BorrowDate = table.Column(type: "datetime2", nullable: false), + DueDate = table.Column(type: "datetime2", nullable: false), + ReturnDate = table.Column(type: "datetime2", nullable: true), + Fine = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BookLoans", x => x.Id); + table.ForeignKey( + name: "FK_BookLoans_Books_BookId", + column: x => x.BookId, + principalTable: "Books", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_BookLoans_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_BookLoans_BookId", + table: "BookLoans", + column: "BookId"); + + migrationBuilder.CreateIndex( + name: "IX_BookLoans_UserId", + table: "BookLoans", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Books_ISBN", + table: "Books", + column: "ISBN", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BookLoans"); + + migrationBuilder.DropTable( + name: "Books"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..36935bf --- /dev/null +++ b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,150 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Services/LoanService.cs b/Library.Infrastructure/Services/LoanService.cs new file mode 100644 index 0000000..eb8a685 --- /dev/null +++ b/Library.Infrastructure/Services/LoanService.cs @@ -0,0 +1,96 @@ +using Library.Application.Dtos; +using Library.Application.Interfaces; +using Library.Domain.Entities; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace Library.Infrastructure.Services; + +public class LoanService : ILoanService +{ + private readonly AppDbContext _context; + + public LoanService(AppDbContext context) + { + _context = context; + } + + public async Task> GetAllLoansAsync() + { + var loans = await _context.Loans + .Include(l => l.User) + .Include(l => l.Book) + .ToListAsync(); + + return loans.Select(MapToDto); + } + + public async Task> GetUserLoansAsync(Guid userId) + { + var loans = await _context.Loans + .Include(l => l.User) + .Include(l => l.Book) + .Where(l => l.UserId == userId) + .ToListAsync(); + + return loans.Select(MapToDto); + } + + public async Task BorrowBookAsync(BorrowRequest request) + { + // Получаем пользователя и книгу с проверкой существования + var user = await _context.Users.FindAsync(request.UserId); + if (user == null) + throw new KeyNotFoundException("Пользователь не найден"); + + var book = await _context.Books.FindAsync(request.BookId); + if (book == null) + throw new KeyNotFoundException("Книга не найдена"); + + // Создаём запись о выдаче через фабричный метод (вся логика внутри) + var loanDays = request.LoanDays ?? 14; + var loan = BookLoan.Create(user, book, loanDays); + + // Уменьшаем количество доступных копий (это делает BookLoan.Create? + // Нет, мы вызываем BorrowCopy вручную, так как Create только создаёт запись, + // а уменьшение копий — это отдельная операция. + book.BorrowCopy(); // уменьшаем AvailableCopies + + _context.Loans.Add(loan); + await _context.SaveChangesAsync(); + + return MapToDto(loan); + } + + public async Task ReturnBookAsync(ReturnRequest request) + { + var loan = await _context.Loans + .Include(l => l.User) + .Include(l => l.Book) + .FirstOrDefaultAsync(l => l.Id == request.LoanId); + + if (loan == null) + throw new KeyNotFoundException("Запись о выдаче не найдена"); + + // Если уже возвращена — ошибка (обработается внутри метода Return) + loan.Return(); // вызываем метод, который увеличит AvailableCopies и рассчитает штраф + + await _context.SaveChangesAsync(); + + return MapToDto(loan); + } + + private static BookLoanDto MapToDto(BookLoan loan) + { + return new BookLoanDto + { + Id = loan.Id, + UserName = loan.User?.FullName ?? "Unknown", + BookTitle = loan.Book?.Title ?? "Unknown", + BorrowDate = loan.BorrowDate, + DueDate = loan.DueDate, + ReturnDate = loan.ReturnDate, + Fine = loan.Fine + }; + } +} \ No newline at end of file From 416a517125a8983563faf75b945901946a4fa853 Mon Sep 17 00:00:00 2001 From: Aakiyaru <83872701+Aakiyaru@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:02:22 +0700 Subject: [PATCH 3/7] =?UTF-8?q?JWT=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Library.API/Controllers/AuthController.cs | 45 +++++ Library.API/Controllers/BooksController.cs | 2 + Library.API/Controllers/LoansController.cs | 3 + Library.API/Library.API.csproj | 1 + Library.API/Program.cs | 65 ++++++- Library.API/appsettings.json | 6 + Library.Applictation/Dtos/AuthResponse.cs | 10 ++ Library.Applictation/Dtos/LoginRequest.cs | 7 + Library.Applictation/Dtos/RegisterRequest.cs | 8 + .../Interfaces/IAuthService.cs | 9 + .../Library.Applictation.csproj | 2 + Library.Domain/Entities/User.cs | 15 +- .../Library.Infrastructure.csproj | 2 + ...34549_AddPasswordAndRoleToUser.Designer.cs | 161 ++++++++++++++++++ ...20260930034549_AddPasswordAndRoleToUser.cs | 40 +++++ .../Migrations/AppDbContextModelSnapshot.cs | 8 + .../Services/AuthService.cs | 101 +++++++++++ 17 files changed, 479 insertions(+), 6 deletions(-) create mode 100644 Library.API/Controllers/AuthController.cs create mode 100644 Library.Applictation/Dtos/AuthResponse.cs create mode 100644 Library.Applictation/Dtos/LoginRequest.cs create mode 100644 Library.Applictation/Dtos/RegisterRequest.cs create mode 100644 Library.Applictation/Interfaces/IAuthService.cs create mode 100644 Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.Designer.cs create mode 100644 Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.cs create mode 100644 Library.Infrastructure/Services/AuthService.cs diff --git a/Library.API/Controllers/AuthController.cs b/Library.API/Controllers/AuthController.cs new file mode 100644 index 0000000..772968b --- /dev/null +++ b/Library.API/Controllers/AuthController.cs @@ -0,0 +1,45 @@ +using Library.Application.Dtos; +using Library.Application.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace Library.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("register")] + public async Task Register([FromBody] RegisterRequest request) + { + try + { + var result = await _authService.RegisterAsync(request); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Message = ex.Message }); + } + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + try + { + var result = await _authService.LoginAsync(request); + return Ok(result); + } + catch (UnauthorizedAccessException ex) + { + return Unauthorized(new { Message = ex.Message }); + } + } +} \ No newline at end of file diff --git a/Library.API/Controllers/BooksController.cs b/Library.API/Controllers/BooksController.cs index f039b24..ece779e 100644 --- a/Library.API/Controllers/BooksController.cs +++ b/Library.API/Controllers/BooksController.cs @@ -1,6 +1,7 @@ using Library.Application.Dtos; using Library.Applictation.Dtos; using Library.Applictation.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Library.API.Controllers @@ -30,6 +31,7 @@ public async Task GetById(Guid id) } [HttpPost] + [Authorize(Roles = "Admin,Librarian")] public async Task Create([FromBody] CreateBookRequest request) { var created = await _bookService.CreateBookAsync(request); diff --git a/Library.API/Controllers/LoansController.cs b/Library.API/Controllers/LoansController.cs index 3ca035b..9c47a76 100644 --- a/Library.API/Controllers/LoansController.cs +++ b/Library.API/Controllers/LoansController.cs @@ -1,11 +1,13 @@ using Library.Application.Dtos; using Library.Application.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Library.API.Controllers; [ApiController] [Route("api/[controller]")] +[Authorize] public class LoansController : ControllerBase { private readonly ILoanService _loanService; @@ -16,6 +18,7 @@ public LoansController(ILoanService loanService) } [HttpGet] + [Authorize(Roles = "Admin,Librarian")] public async Task GetAll() { var loans = await _loanService.GetAllLoansAsync(); diff --git a/Library.API/Library.API.csproj b/Library.API/Library.API.csproj index e4c693c..37c4ff2 100644 --- a/Library.API/Library.API.csproj +++ b/Library.API/Library.API.csproj @@ -8,6 +8,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Library.API/Program.cs b/Library.API/Program.cs index 7a16c68..0c987b3 100644 --- a/Library.API/Program.cs +++ b/Library.API/Program.cs @@ -5,7 +5,11 @@ using Library.Applictation.Validators; using Library.Infrastructure.Data; using Library.Infrastructure.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Text; namespace Library.API { @@ -22,12 +26,66 @@ public static void Main(string[] args) builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddValidatorsFromAssemblyContaining(); + var jwtSettings = builder.Configuration.GetSection("JwtSettings"); + var secretKey = jwtSettings["SecretKey"]!; + + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings["Issuer"], + ValidAudience = jwtSettings["Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + ClockSkew = TimeSpan.Zero // áåç äåôîëòíûõ 5 ìèíóò çàäåðæêè + }; + }); + + builder.Services.AddAuthorization(); + builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi - builder.Services.AddSwaggerGen(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new OpenApiInfo { Title = "Library API", Version = "v1" }); + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Ââåäèòå JWT-òîêåí" + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); + }); var app = builder.Build(); @@ -38,12 +96,13 @@ public static void Main(string[] args) app.UseSwaggerUI(); } + app.UseAuthentication(); + app.UseAuthorization(); + app.UseDeveloperExceptionPage(); // ïîêàæåò äåòàëè îøèáêè ïðÿìî â áðàóçåðå app.UseHttpsRedirection(); - app.UseAuthorization(); - app.UseMiddleware(); app.MapControllers(); diff --git a/Library.API/appsettings.json b/Library.API/appsettings.json index aee4674..e8a0079 100644 --- a/Library.API/appsettings.json +++ b/Library.API/appsettings.json @@ -8,5 +8,11 @@ "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "Server=itsql.elektro.ru;Database=test;Integrated Security=true;TrustServerCertificate=True;" + }, + "JwtSettings": { + "SecretKey": "super_secret_key_minimum_32_characters_long_1234567890", + "Issuer": "LibraryAPI", + "Audience": "LibraryAPIClient", + "ExpiryMinutes": 60 } } diff --git a/Library.Applictation/Dtos/AuthResponse.cs b/Library.Applictation/Dtos/AuthResponse.cs new file mode 100644 index 0000000..d527531 --- /dev/null +++ b/Library.Applictation/Dtos/AuthResponse.cs @@ -0,0 +1,10 @@ +namespace Library.Application.Dtos; + +public class AuthResponse +{ + public string Token { get; set; } + public string Email { get; set; } + public string FullName { get; set; } + public string Role { get; set; } + public DateTime ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/LoginRequest.cs b/Library.Applictation/Dtos/LoginRequest.cs new file mode 100644 index 0000000..5ca7784 --- /dev/null +++ b/Library.Applictation/Dtos/LoginRequest.cs @@ -0,0 +1,7 @@ +namespace Library.Application.Dtos; + +public class LoginRequest +{ + public string Email { get; set; } + public string Password { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/RegisterRequest.cs b/Library.Applictation/Dtos/RegisterRequest.cs new file mode 100644 index 0000000..3fc317f --- /dev/null +++ b/Library.Applictation/Dtos/RegisterRequest.cs @@ -0,0 +1,8 @@ +namespace Library.Application.Dtos; + +public class RegisterRequest +{ + public string FullName { get; set; } + public string Email { get; set; } + public string Password { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Interfaces/IAuthService.cs b/Library.Applictation/Interfaces/IAuthService.cs new file mode 100644 index 0000000..2098c6a --- /dev/null +++ b/Library.Applictation/Interfaces/IAuthService.cs @@ -0,0 +1,9 @@ +using Library.Application.Dtos; + +namespace Library.Application.Interfaces; + +public interface IAuthService +{ + Task RegisterAsync(RegisterRequest request); + Task LoginAsync(LoginRequest request); +} \ No newline at end of file diff --git a/Library.Applictation/Library.Applictation.csproj b/Library.Applictation/Library.Applictation.csproj index daaf19a..5981ae0 100644 --- a/Library.Applictation/Library.Applictation.csproj +++ b/Library.Applictation/Library.Applictation.csproj @@ -8,6 +8,8 @@ + + diff --git a/Library.Domain/Entities/User.cs b/Library.Domain/Entities/User.cs index 55a1ce6..d17595f 100644 --- a/Library.Domain/Entities/User.cs +++ b/Library.Domain/Entities/User.cs @@ -7,6 +7,8 @@ public class User public Guid Id { get; private set; } public string FullName { get; private set; } public string Email { get; private set; } + public string PasswordHash { get; private set; } + public string Role { get; private set; } public DateTime RegistrationDate { get; private set; } //навигационное совйство для истории выдач @@ -15,7 +17,7 @@ public class User private User() { } - public static User Create(string fullName, string email) + public static User Create(string fullName, string email, string passwordHash, string role = "User") { if (string.IsNullOrWhiteSpace(fullName)) { @@ -27,12 +29,19 @@ public static User Create(string fullName, string email) throw new ArgumentException("Неверный формат email", nameof(email)); } + if (string.IsNullOrWhiteSpace(passwordHash)) + { + throw new ArgumentException("Пароль не может быть пустым", nameof(passwordHash)); + } + return new User { Id = Guid.NewGuid(), FullName = fullName, - Email = email, - RegistrationDate = DateTime.UtcNow, + Email = email.ToLowerInvariant(), // нормализуем, чтобы избежать дублей типа Ivan@ vs ivan@ + PasswordHash = passwordHash, + Role = role, + RegistrationDate = DateTime.UtcNow }; } diff --git a/Library.Infrastructure/Library.Infrastructure.csproj b/Library.Infrastructure/Library.Infrastructure.csproj index 2107bea..c936dd9 100644 --- a/Library.Infrastructure/Library.Infrastructure.csproj +++ b/Library.Infrastructure/Library.Infrastructure.csproj @@ -7,6 +7,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.Designer.cs b/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.Designer.cs new file mode 100644 index 0000000..abfd2ec --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.Designer.cs @@ -0,0 +1,161 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930034549_AddPasswordAndRoleToUser")] + partial class AddPasswordAndRoleToUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.cs b/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.cs new file mode 100644 index 0000000..a4e2660 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930034549_AddPasswordAndRoleToUser.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + /// + public partial class AddPasswordAndRoleToUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordHash", + table: "Users", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Role", + table: "Users", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordHash", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Role", + table: "Users"); + } + } +} diff --git a/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 36935bf..71d9256 100644 --- a/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -110,9 +110,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("RegistrationDate") .HasColumnType("datetime2"); + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.HasKey("Id"); b.HasIndex("Email") diff --git a/Library.Infrastructure/Services/AuthService.cs b/Library.Infrastructure/Services/AuthService.cs new file mode 100644 index 0000000..2fddb37 --- /dev/null +++ b/Library.Infrastructure/Services/AuthService.cs @@ -0,0 +1,101 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Library.Application.Dtos; +using Library.Application.Interfaces; +using Library.Domain.Entities; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Library.Infrastructure.Services; + +public class AuthService : IAuthService +{ + private readonly AppDbContext _context; + private readonly IConfiguration _configuration; + + public AuthService(AppDbContext context, IConfiguration configuration) + { + _context = context; + _configuration = configuration; + } + + public async Task RegisterAsync(RegisterRequest request) + { + // Проверяем, что пользователь с таким email не существует + var exists = await _context.Users.AnyAsync(u => u.Email == request.Email.ToLowerInvariant()); + if (exists) + throw new InvalidOperationException("Пользователь с таким email уже зарегистрирован"); + + // Хешируем пароль через BCrypt + var passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); + + // Создаём пользователя через фабричный метод + var user = User.Create(request.FullName, request.Email, passwordHash, role: "User"); + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + // Сразу выдаём токен, чтобы клиенту не нужно было логиниться отдельно + return GenerateAuthResponse(user); + } + + public async Task LoginAsync(LoginRequest request) + { + var user = await _context.Users + .FirstOrDefaultAsync(u => u.Email == request.Email.ToLowerInvariant()); + + if (user == null) + throw new UnauthorizedAccessException("Неверный email или пароль"); + + // Проверяем пароль + var isValid = BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash); + if (!isValid) + throw new UnauthorizedAccessException("Неверный email или пароль"); + + return GenerateAuthResponse(user); + } + + private AuthResponse GenerateAuthResponse(User user) + { + var jwtSettings = _configuration.GetSection("JwtSettings"); + var secretKey = jwtSettings["SecretKey"]!; + var issuer = jwtSettings["Issuer"]!; + var audience = jwtSettings["Audience"]!; + var expiryMinutes = int.Parse(jwtSettings["ExpiryMinutes"] ?? "60"); + + var expiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes); + + // Claims — это данные, которые будут храниться в токене + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(ClaimTypes.Name, user.FullName), + new Claim(ClaimTypes.Role, user.Role), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: expiresAt, + signingCredentials: credentials + ); + + return new AuthResponse + { + Token = new JwtSecurityTokenHandler().WriteToken(token), + Email = user.Email, + FullName = user.FullName, + Role = user.Role, + ExpiresAt = expiresAt + }; + } +} \ No newline at end of file From 76139de8e734a60a6710512999dfb35cefde3863 Mon Sep 17 00:00:00 2001 From: Aakiyaru <83872701+Aakiyaru@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:12:04 +0700 Subject: [PATCH 4/7] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20RefreshToken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Library.API/Controllers/AuthController.cs | 23 ++ Library.Applictation/Dtos/AuthResponse.cs | 5 +- Library.Applictation/Dtos/RefreshRequest.cs | 6 + .../Interfaces/IAuthService.cs | 2 + Library.Domain/Entities/RefreshToken.cs | 44 ++++ Library.Infrastructure/Data/AppDbContext.cs | 14 ++ ...0260930040529_AddRefreshTokens.Designer.cs | 205 ++++++++++++++++++ .../20260930040529_AddRefreshTokens.cs | 55 +++++ .../Migrations/AppDbContextModelSnapshot.cs | 44 ++++ .../Services/AuthService.cs | 62 ++++-- 10 files changed, 439 insertions(+), 21 deletions(-) create mode 100644 Library.Applictation/Dtos/RefreshRequest.cs create mode 100644 Library.Domain/Entities/RefreshToken.cs create mode 100644 Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.Designer.cs create mode 100644 Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.cs diff --git a/Library.API/Controllers/AuthController.cs b/Library.API/Controllers/AuthController.cs index 772968b..7e884d9 100644 --- a/Library.API/Controllers/AuthController.cs +++ b/Library.API/Controllers/AuthController.cs @@ -1,5 +1,6 @@ using Library.Application.Dtos; using Library.Application.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Library.API.Controllers; @@ -42,4 +43,26 @@ public async Task Login([FromBody] LoginRequest request) return Unauthorized(new { Message = ex.Message }); } } + + [HttpPost("refresh")] + public async Task Refresh([FromBody] RefreshRequest request) + { + try + { + var result = await _authService.RefreshAsync(request); + return Ok(result); + } + catch (UnauthorizedAccessException ex) + { + return Unauthorized(new { Message = ex.Message }); + } + } + + [HttpPost("revoke")] + [Authorize] + public async Task Revoke([FromBody] RefreshRequest request) + { + await _authService.RevokeAsync(request.RefreshToken); + return NoContent(); + } } \ No newline at end of file diff --git a/Library.Applictation/Dtos/AuthResponse.cs b/Library.Applictation/Dtos/AuthResponse.cs index d527531..d4f6c14 100644 --- a/Library.Applictation/Dtos/AuthResponse.cs +++ b/Library.Applictation/Dtos/AuthResponse.cs @@ -2,9 +2,10 @@ public class AuthResponse { - public string Token { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } public string Email { get; set; } public string FullName { get; set; } public string Role { get; set; } - public DateTime ExpiresAt { get; set; } + public DateTime AccessTokenExpiresAt { get; set; } } \ No newline at end of file diff --git a/Library.Applictation/Dtos/RefreshRequest.cs b/Library.Applictation/Dtos/RefreshRequest.cs new file mode 100644 index 0000000..88a90b8 --- /dev/null +++ b/Library.Applictation/Dtos/RefreshRequest.cs @@ -0,0 +1,6 @@ +namespace Library.Application.Dtos; + +public class RefreshRequest +{ + public string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Interfaces/IAuthService.cs b/Library.Applictation/Interfaces/IAuthService.cs index 2098c6a..9e538d5 100644 --- a/Library.Applictation/Interfaces/IAuthService.cs +++ b/Library.Applictation/Interfaces/IAuthService.cs @@ -6,4 +6,6 @@ public interface IAuthService { Task RegisterAsync(RegisterRequest request); Task LoginAsync(LoginRequest request); + Task RefreshAsync(RefreshRequest request); + Task RevokeAsync(string refreshToken); } \ No newline at end of file diff --git a/Library.Domain/Entities/RefreshToken.cs b/Library.Domain/Entities/RefreshToken.cs new file mode 100644 index 0000000..996c567 --- /dev/null +++ b/Library.Domain/Entities/RefreshToken.cs @@ -0,0 +1,44 @@ +namespace Library.Domain.Entities; + +public class RefreshToken +{ + public Guid Id { get; private set; } + public string Token { get; private set; } // сам токен (случайная строка) + public Guid UserId { get; private set; } + public User User { get; private set; } + public DateTime ExpiresAt { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? RevokedAt { get; private set; } // null = активен + + private RefreshToken() { } + + public static RefreshToken Create(Guid userId, int daysValid = 7) + { + return new RefreshToken + { + Id = Guid.NewGuid(), + Token = GenerateSecureToken(), + UserId = userId, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(daysValid) + }; + } + + public bool IsActive => RevokedAt == null && ExpiresAt > DateTime.UtcNow; + + public void Revoke() + { + if (RevokedAt != null) + throw new InvalidOperationException("Токен уже отозван"); + RevokedAt = DateTime.UtcNow; + } + + private static string GenerateSecureToken() + { + // 64 байта случайных данных → 88 символов Base64 + var randomBytes = new byte[64]; + using var rng = System.Security.Cryptography.RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + return Convert.ToBase64String(randomBytes); + } +} \ No newline at end of file diff --git a/Library.Infrastructure/Data/AppDbContext.cs b/Library.Infrastructure/Data/AppDbContext.cs index 5ad6399..f6afb75 100644 --- a/Library.Infrastructure/Data/AppDbContext.cs +++ b/Library.Infrastructure/Data/AppDbContext.cs @@ -10,6 +10,7 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Books { get; set; } public DbSet Users { get; set; } public DbSet Loans { get; set; } + public DbSet RefreshTokens { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -67,5 +68,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(l => l.BookId) .OnDelete(DeleteBehavior.Restrict); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("RefreshTokens"); + entity.HasKey(t => t.Id); + entity.Property(t => t.Token).IsRequired().HasMaxLength(200); + entity.HasIndex(t => t.Token).IsUnique(); + + entity.HasOne(t => t.User) + .WithMany() + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); } } \ No newline at end of file diff --git a/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.Designer.cs b/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.Designer.cs new file mode 100644 index 0000000..b67fc46 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.Designer.cs @@ -0,0 +1,205 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930040529_AddRefreshTokens")] + partial class AddRefreshTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.cs b/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.cs new file mode 100644 index 0000000..ea8ac63 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930040529_AddRefreshTokens.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + /// + public partial class AddRefreshTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Token = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + RevokedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_Token", + table: "RefreshTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + } + } +} diff --git a/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 71d9256..950a42a 100644 --- a/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -94,6 +94,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BookLoans", (string)null); }); + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + modelBuilder.Entity("Library.Domain.Entities.User", b => { b.Property("Id") @@ -148,6 +181,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Library.Domain.Entities.User", b => { b.Navigation("Loans"); diff --git a/Library.Infrastructure/Services/AuthService.cs b/Library.Infrastructure/Services/AuthService.cs index 2fddb37..dbb9194 100644 --- a/Library.Infrastructure/Services/AuthService.cs +++ b/Library.Infrastructure/Services/AuthService.cs @@ -24,22 +24,17 @@ public AuthService(AppDbContext context, IConfiguration configuration) public async Task RegisterAsync(RegisterRequest request) { - // Проверяем, что пользователь с таким email не существует var exists = await _context.Users.AnyAsync(u => u.Email == request.Email.ToLowerInvariant()); if (exists) throw new InvalidOperationException("Пользователь с таким email уже зарегистрирован"); - // Хешируем пароль через BCrypt var passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); - - // Создаём пользователя через фабричный метод var user = User.Create(request.FullName, request.Email, passwordHash, role: "User"); _context.Users.Add(user); await _context.SaveChangesAsync(); - // Сразу выдаём токен, чтобы клиенту не нужно было логиниться отдельно - return GenerateAuthResponse(user); + return await GenerateAuthResponseAsync(user); } public async Task LoginAsync(LoginRequest request) @@ -47,28 +42,51 @@ public async Task LoginAsync(LoginRequest request) var user = await _context.Users .FirstOrDefaultAsync(u => u.Email == request.Email.ToLowerInvariant()); - if (user == null) + if (user == null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) throw new UnauthorizedAccessException("Неверный email или пароль"); - // Проверяем пароль - var isValid = BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash); - if (!isValid) - throw new UnauthorizedAccessException("Неверный email или пароль"); + return await GenerateAuthResponseAsync(user); + } + + public async Task RefreshAsync(RefreshRequest request) + { + var storedToken = await _context.RefreshTokens + .Include(t => t.User) + .FirstOrDefaultAsync(t => t.Token == request.RefreshToken); + + if (storedToken == null || !storedToken.IsActive) + throw new UnauthorizedAccessException("Недействительный или истёкший refresh-токен"); + + // Отзываем старый токен (rotation) — так безопаснее + storedToken.Revoke(); + await _context.SaveChangesAsync(); - return GenerateAuthResponse(user); + // Генерируем новую пару + return await GenerateAuthResponseAsync(storedToken.User); } - private AuthResponse GenerateAuthResponse(User user) + public async Task RevokeAsync(string refreshToken) + { + var token = await _context.RefreshTokens + .FirstOrDefaultAsync(t => t.Token == refreshToken); + + if (token == null || !token.IsActive) + return; // тихо игнорируем — не выдаём, что токена не было + + token.Revoke(); + await _context.SaveChangesAsync(); + } + + private async Task GenerateAuthResponseAsync(User user) { var jwtSettings = _configuration.GetSection("JwtSettings"); var secretKey = jwtSettings["SecretKey"]!; var issuer = jwtSettings["Issuer"]!; var audience = jwtSettings["Audience"]!; - var expiryMinutes = int.Parse(jwtSettings["ExpiryMinutes"] ?? "60"); + var expiryMinutes = int.Parse(jwtSettings["ExpiryMinutes"] ?? "15"); - var expiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes); + var accessTokenExpiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes); - // Claims — это данные, которые будут храниться в токене var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), @@ -85,17 +103,23 @@ private AuthResponse GenerateAuthResponse(User user) issuer: issuer, audience: audience, claims: claims, - expires: expiresAt, + expires: accessTokenExpiresAt, signingCredentials: credentials ); + // Создаём refresh-токен и сохраняем в БД + var refreshToken = RefreshToken.Create(user.Id); + _context.RefreshTokens.Add(refreshToken); + await _context.SaveChangesAsync(); + return new AuthResponse { - Token = new JwtSecurityTokenHandler().WriteToken(token), + AccessToken = new JwtSecurityTokenHandler().WriteToken(token), + RefreshToken = refreshToken.Token, Email = user.Email, FullName = user.FullName, Role = user.Role, - ExpiresAt = expiresAt + AccessTokenExpiresAt = accessTokenExpiresAt }; } } \ No newline at end of file From 4484b9ec1dc14129577b1fd3b805fe908bf2a028 Mon Sep 17 00:00:00 2001 From: Aakiyaru <83872701+Aakiyaru@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:21:40 +0700 Subject: [PATCH 5/7] =?UTF-8?q?CRUD=20=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=B8=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Library.API/Controllers/BooksController.cs | 99 ++++---- Library.API/Program.cs | 1 - .../Dtos/UpdateBookRequest.cs | 10 + .../Interfaces/IBookService.cs | 19 +- Library.Domain/Entities/Book.cs | 43 ++++ Library.Infrastructure/Data/AppDbContext.cs | 4 + ...0930041417_AddSoftDeleteToBook.Designer.cs | 213 ++++++++++++++++++ .../20260930041417_AddSoftDeleteToBook.cs | 40 ++++ .../Migrations/AppDbContextModelSnapshot.cs | 8 + .../Services/BookService.cs | 64 +++++- 10 files changed, 449 insertions(+), 52 deletions(-) create mode 100644 Library.Applictation/Dtos/UpdateBookRequest.cs create mode 100644 Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.Designer.cs create mode 100644 Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.cs diff --git a/Library.API/Controllers/BooksController.cs b/Library.API/Controllers/BooksController.cs index ece779e..9e688ea 100644 --- a/Library.API/Controllers/BooksController.cs +++ b/Library.API/Controllers/BooksController.cs @@ -1,49 +1,66 @@ using Library.Application.Dtos; +using Library.Application.Interfaces; using Library.Applictation.Dtos; -using Library.Applictation.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Library.API.Controllers +namespace Library.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class BooksController : ControllerBase { - [Route("api/[controller]")] - [ApiController] - public class BooksController : ControllerBase + private readonly IBookService _bookService; + + public BooksController(IBookService bookService) + { + _bookService = bookService; + } + + [HttpGet] + public async Task GetBooks([FromQuery] BookQueryParameters parameters) + { + var result = await _bookService.GetBooksAsync(parameters); + return Ok(result); + } + + [HttpGet("{id}")] + public async Task GetById(Guid id) + { + var book = await _bookService.GetBookByIdAsync(id); + if (book == null) return NotFound(); + return Ok(book); + } + + [HttpPost] + [Authorize(Roles = "Admin,Librarian")] + public async Task Create([FromBody] CreateBookRequest request) + { + var created = await _bookService.CreateBookAsync(request); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + [HttpPut("{id}")] + [Authorize(Roles = "Admin,Librarian")] + public async Task Update(Guid id, [FromBody] UpdateBookRequest request) + { + var updated = await _bookService.UpdateBookAsync(id, request); + return Ok(updated); + } + + [HttpDelete("{id}")] + [Authorize(Roles = "Admin")] + public async Task Delete(Guid id) + { + await _bookService.DeleteBookAsync(id); + return NoContent(); // 204 — успешно, тела нет + } + + [HttpPost("{id}/restore")] + [Authorize(Roles = "Admin")] + public async Task Restore(Guid id) { - private readonly IBookService _bookService; - - public BooksController(IBookService bookService) - { - _bookService = bookService; - } - - [HttpGet("{id}")] - public async Task GetById(Guid id) - { - var book = await _bookService.GetBookByIdAsync(id); - - if (book == null) - { - return NotFound(); - } - - return Ok(book); - } - - [HttpPost] - [Authorize(Roles = "Admin,Librarian")] - public async Task Create([FromBody] CreateBookRequest request) - { - var created = await _bookService.CreateBookAsync(request); - - return CreatedAtAction(nameof(GetById), new {id = created.Id}, created); - } - - [HttpGet] - public async Task GetBooks([FromQuery] BookQueryParameters parameters) - { - var result = await _bookService.GetBooksAsync(parameters); - return Ok(result); - } + await _bookService.RestoreBookAsync(id); + return NoContent(); } -} +} \ No newline at end of file diff --git a/Library.API/Program.cs b/Library.API/Program.cs index 0c987b3..029c5d3 100644 --- a/Library.API/Program.cs +++ b/Library.API/Program.cs @@ -1,7 +1,6 @@ using FluentValidation; using Library.API.Middleware; using Library.Application.Interfaces; -using Library.Applictation.Interfaces; using Library.Applictation.Validators; using Library.Infrastructure.Data; using Library.Infrastructure.Services; diff --git a/Library.Applictation/Dtos/UpdateBookRequest.cs b/Library.Applictation/Dtos/UpdateBookRequest.cs new file mode 100644 index 0000000..8cfafbf --- /dev/null +++ b/Library.Applictation/Dtos/UpdateBookRequest.cs @@ -0,0 +1,10 @@ +namespace Library.Application.Dtos; + +public class UpdateBookRequest +{ + public string Title { get; set; } + public string ISBN { get; set; } + public string Genre { get; set; } + public int PublicationYear { get; set; } + public int TotalCopies { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Interfaces/IBookService.cs b/Library.Applictation/Interfaces/IBookService.cs index c220220..651e62e 100644 --- a/Library.Applictation/Interfaces/IBookService.cs +++ b/Library.Applictation/Interfaces/IBookService.cs @@ -1,13 +1,14 @@ using Library.Application.Dtos; using Library.Applictation.Dtos; -namespace Library.Applictation.Interfaces +namespace Library.Application.Interfaces; + +public interface IBookService { - public interface IBookService - { - Task> GetAllBooksAsync(); - Task GetBookByIdAsync(Guid id); - Task CreateBookAsync(CreateBookRequest request); - Task> GetBooksAsync(BookQueryParameters parameters); - } -} + Task> GetBooksAsync(BookQueryParameters parameters); + Task GetBookByIdAsync(Guid id); + Task CreateBookAsync(CreateBookRequest request); + Task UpdateBookAsync(Guid id, UpdateBookRequest request); + Task DeleteBookAsync(Guid id); + Task RestoreBookAsync(Guid id); +} \ No newline at end of file diff --git a/Library.Domain/Entities/Book.cs b/Library.Domain/Entities/Book.cs index 31f9b3f..95a59da 100644 --- a/Library.Domain/Entities/Book.cs +++ b/Library.Domain/Entities/Book.cs @@ -9,6 +9,8 @@ public class Book public int PublicationYear { get; private set; } public int TotalCopies { get; private set; } // Всего экземпляров в библиотеке public int AvailableCopies { get; private set; } // Сколько свободно сейчас + public bool IsDeleted { get; private set; } + public DateTime? DeletedAt { get; private set; } private Book() { } // Для EF @@ -58,4 +60,45 @@ public void ReturnCopy() AvailableCopies++; } + + public void UpdateDetails(string title, string isbn, string genre, int year, int totalCopies) + { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Название не может быть пустым", nameof(title)); + if (totalCopies < AvailableCopies) + throw new InvalidOperationException( + "Нельзя уменьшить общее количество ниже уже выданных экземпляров"); + + Title = title; + ISBN = isbn; + Genre = genre; + PublicationYear = year; + + // Если увеличили общее количество — увеличиваем и доступное + if (totalCopies > TotalCopies) + { + AvailableCopies += totalCopies - TotalCopies; + } + TotalCopies = totalCopies; + } + + public void MarkAsDeleted() + { + if (IsDeleted) + throw new InvalidOperationException("Книга уже удалена"); + if (AvailableCopies < TotalCopies) + throw new InvalidOperationException( + "Нельзя удалить книгу, пока не возвращены все выданные экземпляры"); + + IsDeleted = true; + DeletedAt = DateTime.UtcNow; + } + + public void Restore() + { + if (!IsDeleted) + throw new InvalidOperationException("Книга не была удалена"); + IsDeleted = false; + DeletedAt = null; + } } \ No newline at end of file diff --git a/Library.Infrastructure/Data/AppDbContext.cs b/Library.Infrastructure/Data/AppDbContext.cs index f6afb75..b9c6f7f 100644 --- a/Library.Infrastructure/Data/AppDbContext.cs +++ b/Library.Infrastructure/Data/AppDbContext.cs @@ -36,6 +36,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(b => b.Genre) .HasMaxLength(100); + + entity.HasQueryFilter(b => !b.IsDeleted); + + entity.Property(b => b.IsDeleted).HasDefaultValue(false); }); // Настройка User diff --git a/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.Designer.cs b/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.Designer.cs new file mode 100644 index 0000000..40535d9 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.Designer.cs @@ -0,0 +1,213 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930041417_AddSoftDeleteToBook")] + partial class AddSoftDeleteToBook + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.cs b/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.cs new file mode 100644 index 0000000..b8e8e6f --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930041417_AddSoftDeleteToBook.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + /// + public partial class AddSoftDeleteToBook : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "Books", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Books", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "Books"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Books"); + } + } +} diff --git a/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 950a42a..8cd8ffb 100644 --- a/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -31,6 +31,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AvailableCopies") .HasColumnType("int"); + b.Property("DeletedAt") + .HasColumnType("datetime2"); + b.Property("Genre") .IsRequired() .HasMaxLength(100) @@ -41,6 +44,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(20) .HasColumnType("nvarchar(20)"); + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + b.Property("PublicationYear") .HasColumnType("int"); diff --git a/Library.Infrastructure/Services/BookService.cs b/Library.Infrastructure/Services/BookService.cs index e41c8b5..75bad34 100644 --- a/Library.Infrastructure/Services/BookService.cs +++ b/Library.Infrastructure/Services/BookService.cs @@ -1,7 +1,7 @@ using FluentValidation; using Library.Application.Dtos; +using Library.Application.Interfaces; using Library.Applictation.Dtos; -using Library.Applictation.Interfaces; using Library.Domain.Entities; using Library.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -152,5 +152,67 @@ public async Task> GetBooksAsync(BookQueryParameters parame PageSize = parameters.PageSize }; } + + public async Task UpdateBookAsync(Guid id, UpdateBookRequest request) + { + var book = await _context.Books.FindAsync(id); + if (book == null) + throw new KeyNotFoundException("Книга не найдена"); + + // Проверяем уникальность ISBN (кроме самой обновляемой книги) + var isbnTaken = await _context.Books + .AnyAsync(b => b.ISBN == request.ISBN && b.Id != id); + if (isbnTaken) + throw new InvalidOperationException("Книга с таким ISBN уже существует"); + + book.UpdateDetails( + request.Title, + request.ISBN, + request.Genre, + request.PublicationYear, + request.TotalCopies + ); + + await _context.SaveChangesAsync(); + return MapToDto(book); + } + + public async Task DeleteBookAsync(Guid id) + { + var book = await _context.Books.FindAsync(id); + if (book == null) + throw new KeyNotFoundException("Книга не найдена"); + + book.MarkAsDeleted(); + await _context.SaveChangesAsync(); + } + + public async Task RestoreBookAsync(Guid id) + { + // Ищем среди удалённых — обходим query filter + var book = await _context.Books + .IgnoreQueryFilters() + .FirstOrDefaultAsync(b => b.Id == id); + + if (book == null) + throw new KeyNotFoundException("Книга не найдена"); + if (!book.IsDeleted) + throw new InvalidOperationException("Книга не была удалена"); + + book.Restore(); + await _context.SaveChangesAsync(); + } + + // Вспомогательный метод (вынесите, если ещё не сделали) + private static BookDto MapToDto(Book book) => new() + { + Id = book.Id, + Title = book.Title, + ISBN = book.ISBN, + Genre = book.Genre, + PublicationYear = book.PublicationYear, + TotalCopies = book.TotalCopies, + AvailableCopies = book.AvailableCopies + }; } } From 1d995d2439137d84f41202e7e12583f5842e7dc1 Mon Sep 17 00:00:00 2001 From: Aakiyaru <83872701+Aakiyaru@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:15:42 +0700 Subject: [PATCH 6/7] =?UTF-8?q?CRUD=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Library.API/Controllers/UserController.cs | 90 +++++++ Library.API/Program.cs | 5 + .../Dtos/ChangePasswordRequest.cs | 7 + .../Dtos/UpdateUserRequest.cs | 6 + .../Dtos/UpdateUserRoleRequest.cs | 6 + Library.Applictation/Dtos/UserDto.cs | 12 + .../Interfaces/IUserService.cs | 16 ++ Library.Domain/Entities/User.cs | 61 +++++ Library.Infrastructure/Data/AppDbContext.cs | 2 + ...0930043425_AddSoftDeleteToUser.Designer.cs | 221 ++++++++++++++++++ .../20260930043425_AddSoftDeleteToUser.cs | 40 ++++ .../Migrations/AppDbContextModelSnapshot.cs | 8 + .../Services/UserService.cs | 136 +++++++++++ 13 files changed, 610 insertions(+) create mode 100644 Library.API/Controllers/UserController.cs create mode 100644 Library.Applictation/Dtos/ChangePasswordRequest.cs create mode 100644 Library.Applictation/Dtos/UpdateUserRequest.cs create mode 100644 Library.Applictation/Dtos/UpdateUserRoleRequest.cs create mode 100644 Library.Applictation/Dtos/UserDto.cs create mode 100644 Library.Applictation/Interfaces/IUserService.cs create mode 100644 Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.Designer.cs create mode 100644 Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.cs create mode 100644 Library.Infrastructure/Services/UserService.cs diff --git a/Library.API/Controllers/UserController.cs b/Library.API/Controllers/UserController.cs new file mode 100644 index 0000000..8e96847 --- /dev/null +++ b/Library.API/Controllers/UserController.cs @@ -0,0 +1,90 @@ +using Library.Application.Dtos; +using Library.Applictation.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Library.API.Controllers +{ + [ApiController] + [Route("api/[controller]")] + [Authorize] + public class UserController : ControllerBase + { + private readonly IUserService _userService; + + public UserController(IUserService userService) + { + _userService = userService; + } + + [HttpGet] + [Authorize(Roles = "Admin, Librarian")] + public async Task GetAll() + { + return Ok(await _userService.GetAllUsersAsync()); + } + + [HttpGet("{id}")] + public async Task GetById(Guid id) + { + var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var isStaff = User.IsInRole("Admin") || User.IsInRole("Librarian"); + + if (!isStaff && currentUserId != id.ToString()) + { + return Forbid(); + } + + return Ok(await _userService.GetUserByIdAsync(id)); + } + + [HttpPut("{id}")] + public async Task Update(Guid id, [FromBody] UpdateUserRequest request) + { + var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var isStaff = User.IsInRole("Admin") || User.IsInRole("Librarian"); + + if (!isStaff && currentUserId == id.ToString()) + { + return Forbid(); + } + + return Ok(await _userService.UpdateUserAsync(id, request)); + } + + [HttpPost("{id}/change-password")] + public async Task ChangePassword(Guid id, [FromBody] ChangePasswordRequest request) + { + var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (currentUserId != id.ToString()) + return Forbid(); // только сам пользователь + + await _userService.ChangePasswordAsync(id, request); + return NoContent(); + } + + [HttpPatch("{id}/role")] + [Authorize(Roles = "Admin")] + public async Task UpdateRole(Guid id, [FromBody] UpdateUserRoleRequest request) + { + return Ok(await _userService.UpdateRoleAsync(id, request)); + } + + [HttpDelete("{id}")] + [Authorize(Roles = "Admin")] + public async Task Delete(Guid id) + { + await _userService.DeleteUserAsync(id); + return NoContent(); + } + + [HttpPost("{id}/restore")] + [Authorize(Roles = "Admin")] + public async Task Restore(Guid id) + { + await _userService.RestoreUserAsync(id); + return NoContent(); + } + } +} diff --git a/Library.API/Program.cs b/Library.API/Program.cs index 029c5d3..213dc5d 100644 --- a/Library.API/Program.cs +++ b/Library.API/Program.cs @@ -1,6 +1,7 @@ using FluentValidation; using Library.API.Middleware; using Library.Application.Interfaces; +using Library.Applictation.Interfaces; using Library.Applictation.Validators; using Library.Infrastructure.Data; using Library.Infrastructure.Services; @@ -8,6 +9,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; +using System.Security.Claims; using System.Text; namespace Library.API @@ -26,6 +28,7 @@ public static void Main(string[] args) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddValidatorsFromAssemblyContaining(); @@ -47,6 +50,8 @@ public static void Main(string[] args) ValidateIssuerSigningKey = true, ValidIssuer = jwtSettings["Issuer"], ValidAudience = jwtSettings["Audience"], + NameClaimType = ClaimTypes.Name, + RoleClaimType = ClaimTypes.Role, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), ClockSkew = TimeSpan.Zero // áåç äåôîëòíûõ 5 ìèíóò çàäåðæêè }; diff --git a/Library.Applictation/Dtos/ChangePasswordRequest.cs b/Library.Applictation/Dtos/ChangePasswordRequest.cs new file mode 100644 index 0000000..5bf4c73 --- /dev/null +++ b/Library.Applictation/Dtos/ChangePasswordRequest.cs @@ -0,0 +1,7 @@ +namespace Library.Application.Dtos; + +public class ChangePasswordRequest +{ + public string CurrentPassword { get; set; } + public string NewPassword { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/UpdateUserRequest.cs b/Library.Applictation/Dtos/UpdateUserRequest.cs new file mode 100644 index 0000000..a7d5b44 --- /dev/null +++ b/Library.Applictation/Dtos/UpdateUserRequest.cs @@ -0,0 +1,6 @@ +namespace Library.Application.Dtos; + +public class UpdateUserRequest +{ + public string FullName { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/UpdateUserRoleRequest.cs b/Library.Applictation/Dtos/UpdateUserRoleRequest.cs new file mode 100644 index 0000000..be016c0 --- /dev/null +++ b/Library.Applictation/Dtos/UpdateUserRoleRequest.cs @@ -0,0 +1,6 @@ +namespace Library.Application.Dtos; + +public class UpdateUserRoleRequest +{ + public string Role { get; set; } +} \ No newline at end of file diff --git a/Library.Applictation/Dtos/UserDto.cs b/Library.Applictation/Dtos/UserDto.cs new file mode 100644 index 0000000..beb2d48 --- /dev/null +++ b/Library.Applictation/Dtos/UserDto.cs @@ -0,0 +1,12 @@ +namespace Library.Applictation.Dtos +{ + public class UserDto + { + public Guid Id { get; set; } + public string FullName { get; set; } + public string Email { get; set; } + public string Role { get; set; } + public DateTime RegistrationDate { get; set; } + public int ActiveLoansCount { get; set; } + } +} diff --git a/Library.Applictation/Interfaces/IUserService.cs b/Library.Applictation/Interfaces/IUserService.cs new file mode 100644 index 0000000..c0a6fad --- /dev/null +++ b/Library.Applictation/Interfaces/IUserService.cs @@ -0,0 +1,16 @@ +using Library.Application.Dtos; +using Library.Applictation.Dtos; + +namespace Library.Applictation.Interfaces +{ + public interface IUserService + { + Task> GetAllUsersAsync(); + Task GetUserByIdAsync(Guid id); + Task UpdateUserAsync(Guid id, UpdateUserRequest request); + Task ChangePasswordAsync(Guid id, ChangePasswordRequest request); + Task UpdateRoleAsync (Guid id, UpdateUserRoleRequest request); + Task DeleteUserAsync (Guid id); + Task RestoreUserAsync (Guid id); + } +} diff --git a/Library.Domain/Entities/User.cs b/Library.Domain/Entities/User.cs index d17595f..fa45ed6 100644 --- a/Library.Domain/Entities/User.cs +++ b/Library.Domain/Entities/User.cs @@ -10,6 +10,8 @@ public class User public string PasswordHash { get; private set; } public string Role { get; private set; } public DateTime RegistrationDate { get; private set; } + public bool IsDeleted { get; private set; } + public DateTime? DeletedAt { get; private set; } //навигационное совйство для истории выдач private readonly List _loans = new(); @@ -55,5 +57,64 @@ public bool CanBorrowMore() { return Loans.Count(l => l.ReturnDate == null) < 5; } + + public void UpdateProfile(string fullName) + { + if (string.IsNullOrWhiteSpace(fullName)) + { + throw new ArgumentException("Имя не может быть пустым", nameof(fullName)); + } + + FullName = fullName; + } + + public void ChangePassword(string newPasswordHash) + { + if (string.IsNullOrWhiteSpace(newPasswordHash)) + { + throw new ArgumentException("Хеш пароля не может быть пустым", nameof(newPasswordHash)); + } + + PasswordHash = newPasswordHash; + } + + public void UpdateRole(string newRole) + { + var allowed = new[] { "User", "Librarian", "Admin" }; + + if (!allowed.Contains(newRole)) + { + throw new ArgumentException($"Роль должна быть одной из: {string.Join(", ", allowed)}"); + } + + Role = newRole; + } + + public void MarkAsDeleted() + { + if (IsDeleted) + { + throw new InvalidOperationException("Пользователь уже удалён"); + } + + if (Loans.Any(l => l.ReturnDate == null)) + { + throw new InvalidOperationException("Нельзя удалить пользователя с активными выдачами"); + } + + IsDeleted = true; + DeletedAt = DateTime.UtcNow; + } + + public void Restore() + { + if(!IsDeleted) + { + throw new InvalidOperationException("Пользователь не был удалён"); + } + + IsDeleted = false; + DeletedAt = null; + } } } diff --git a/Library.Infrastructure/Data/AppDbContext.cs b/Library.Infrastructure/Data/AppDbContext.cs index b9c6f7f..cb48cfa 100644 --- a/Library.Infrastructure/Data/AppDbContext.cs +++ b/Library.Infrastructure/Data/AppDbContext.cs @@ -50,6 +50,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(u => u.FullName).IsRequired().HasMaxLength(100); entity.Property(u => u.Email).IsRequired().HasMaxLength(100); entity.HasIndex(u => u.Email).IsUnique(); + entity.HasQueryFilter(u => !u.IsDeleted); + entity.Property(u => u.IsDeleted).HasDefaultValue(false); }); // Настройка BookLoan diff --git a/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.Designer.cs b/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.Designer.cs new file mode 100644 index 0000000..1781578 --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.Designer.cs @@ -0,0 +1,221 @@ +// +using System; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260930043425_AddSoftDeleteToUser")] + partial class AddSoftDeleteToUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Library.Domain.Entities.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableCopies") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Genre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ISBN") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("PublicationYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TotalCopies") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ISBN") + .IsUnique(); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BookId") + .HasColumnType("uniqueidentifier"); + + b.Property("BorrowDate") + .HasColumnType("datetime2"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("Fine") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ReturnDate") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookId"); + + b.HasIndex("UserId"); + + b.ToTable("BookLoans", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationDate") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Library.Domain.Entities.BookLoan", b => + { + b.HasOne("Library.Domain.Entities.Book", "Book") + .WithMany() + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany("Loans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.RefreshToken", b => + { + b.HasOne("Library.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Library.Domain.Entities.User", b => + { + b.Navigation("Loans"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.cs b/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.cs new file mode 100644 index 0000000..16bbb5e --- /dev/null +++ b/Library.Infrastructure/Migrations/20260930043425_AddSoftDeleteToUser.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Library.Infrastructure.Migrations +{ + /// + public partial class AddSoftDeleteToUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "Users", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Users", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "Users"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Users"); + } + } +} diff --git a/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 8cd8ffb..a30e781 100644 --- a/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/Library.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -141,6 +141,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("DeletedAt") + .HasColumnType("datetime2"); + b.Property("Email") .IsRequired() .HasMaxLength(100) @@ -151,6 +154,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + b.Property("PasswordHash") .IsRequired() .HasColumnType("nvarchar(max)"); diff --git a/Library.Infrastructure/Services/UserService.cs b/Library.Infrastructure/Services/UserService.cs new file mode 100644 index 0000000..1dadc96 --- /dev/null +++ b/Library.Infrastructure/Services/UserService.cs @@ -0,0 +1,136 @@ +using Library.Application.Dtos; +using Library.Application.Interfaces; +using Library.Applictation.Dtos; +using Library.Applictation.Interfaces; +using Library.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace Library.Infrastructure.Services +{ + public class UserService : IUserService + { + private readonly AppDbContext _context; + + public UserService(AppDbContext context) + { + _context = context; + } + + public async Task ChangePasswordAsync(Guid id, ChangePasswordRequest request) + { + var user = await _context.Users + .Include(u => u.Loans) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + throw new InvalidOperationException("Пользователь не найден"); + } + + if(!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash)) + { + throw new UnauthorizedAccessException("Текущий пароль указан неверно"); + } + + var newHash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword); + user.ChangePassword(newHash); + + await _context.SaveChangesAsync(); + } + + public async Task DeleteUserAsync(Guid id) + { + var user = await _context.Users + .Include(u => u.Loans) + .FirstOrDefaultAsync (u => u.Id == id); + + if (user == null) + { + throw new KeyNotFoundException("Пользователь не найден"); + } + + user.MarkAsDeleted(); + await _context.SaveChangesAsync(); + } + + public async Task> GetAllUsersAsync() + { + var users = await _context.Users + .Include(u => u.Loans) + .ToListAsync(); + return users.Select(MapToDto); + } + + public async Task GetUserByIdAsync(Guid id) + { + var user = await _context.Users + .Include(u => u.Loans) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + throw new KeyNotFoundException("Пользователь не найден"); + } + + return MapToDto(user); + } + + public async Task RestoreUserAsync(Guid id) + { + var user = await _context.Users + .IgnoreQueryFilters() + .Include(u => u.Loans) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + throw new KeyNotFoundException("Пользователь не найден"); + } + + user.Restore(); + await _context.SaveChangesAsync(); + } + + public async Task UpdateRoleAsync(Guid id, UpdateUserRoleRequest request) + { + var user = await _context.Users + .Include (u => u.Loans) + .FirstOrDefaultAsync (u => u.Id == id); + + if(user == null) + { + throw new KeyNotFoundException("Пользователь не найден"); + } + + user.UpdateRole(request.Role); + await _context.SaveChangesAsync(); + return MapToDto(user); + } + + public async Task UpdateUserAsync(Guid id, UpdateUserRequest request) + { + var user = await _context.Users + .Include (u => u.Loans) + .FirstOrDefaultAsync(u => u.Id == id); + + if (user == null) + { + throw new KeyNotFoundException("Пользователь не найден"); + } + + user.UpdateProfile(request.FullName); + await _context.SaveChangesAsync(); + return MapToDto(user); + } + + private static UserDto MapToDto(Domain.Entities.User user) => new() + { + Id = user.Id, + FullName = user.FullName, + Email = user.Email, + Role = user.Role, + RegistrationDate = user.RegistrationDate, + ActiveLoansCount = user.Loans.Count(l => l.ReturnDate == null) + }; + } +} From 9a4d75082a6d38784d297bc7fe576a88e42e4f2d Mon Sep 17 00:00:00 2001 From: Aakiyaru <83872701+Aakiyaru@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:51:33 +0700 Subject: [PATCH 7/7] =?UTF-8?q?=D0=AE=D0=BD=D0=B8=D1=82-=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet.yml | 27 ++++ Library.Domain/Entities/Book.cs | 12 +- Library.Domain/Entities/BookLoan.cs | 9 +- .../Services/LoanService.cs | 1 - Library.Tests/Domain/BookLoanTests.cs | 67 +++++++++ Library.Tests/Domain/BookTests.cs | 138 ++++++++++++++++++ Library.Tests/Library.Tests.csproj | 28 ++++ Library.sln | 52 ++++++- 8 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/dotnet.yml create mode 100644 Library.Tests/Domain/BookLoanTests.cs create mode 100644 Library.Tests/Domain/BookTests.cs create mode 100644 Library.Tests/Library.Tests.csproj diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..149c441 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,27 @@ +name: .NET CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release --verbosity normal \ No newline at end of file diff --git a/Library.Domain/Entities/Book.cs b/Library.Domain/Entities/Book.cs index 95a59da..21c98a1 100644 --- a/Library.Domain/Entities/Book.cs +++ b/Library.Domain/Entities/Book.cs @@ -69,16 +69,18 @@ public void UpdateDetails(string title, string isbn, string genre, int year, int throw new InvalidOperationException( "Нельзя уменьшить общее количество ниже уже выданных экземпляров"); + var borrowed = TotalCopies - AvailableCopies; + if (totalCopies < borrowed) + throw new InvalidOperationException( + $"Нельзя установить общее количество {totalCopies}: " + + $"у читателей уже {borrowed} экземпляров"); + Title = title; ISBN = isbn; Genre = genre; PublicationYear = year; - // Если увеличили общее количество — увеличиваем и доступное - if (totalCopies > TotalCopies) - { - AvailableCopies += totalCopies - TotalCopies; - } + AvailableCopies += totalCopies - TotalCopies; TotalCopies = totalCopies; } diff --git a/Library.Domain/Entities/BookLoan.cs b/Library.Domain/Entities/BookLoan.cs index 18a5e18..685320e 100644 --- a/Library.Domain/Entities/BookLoan.cs +++ b/Library.Domain/Entities/BookLoan.cs @@ -25,6 +25,8 @@ public static BookLoan Create(User user, Book book, int loanDays = 14) if (!user.CanBorrowMore()) throw new InvalidOperationException("Пользователь уже взял максимум книг (5)"); + book.BorrowCopy(); + return new BookLoan { Id = Guid.NewGuid(), @@ -44,13 +46,12 @@ public void Return(decimal? finePerDay = null) throw new InvalidOperationException("Книга уже возвращена"); ReturnDate = DateTime.UtcNow; - Book.ReturnCopy(); // увеличиваем AvailableCopies + Book.ReturnCopy(); - // Расчёт штрафа, если просрочка if (ReturnDate > DueDate) { - var daysOverdue = (ReturnDate.Value - DueDate).Days; - // Если не указано, используем 10 рублей в день + // Считаем каждый НАЧАТЫЙ день просрочки + var daysOverdue = (int)Math.Ceiling((ReturnDate.Value - DueDate).TotalDays); var rate = finePerDay ?? 10m; Fine = daysOverdue * rate; } diff --git a/Library.Infrastructure/Services/LoanService.cs b/Library.Infrastructure/Services/LoanService.cs index eb8a685..4b7eb27 100644 --- a/Library.Infrastructure/Services/LoanService.cs +++ b/Library.Infrastructure/Services/LoanService.cs @@ -54,7 +54,6 @@ public async Task BorrowBookAsync(BorrowRequest request) // Уменьшаем количество доступных копий (это делает BookLoan.Create? // Нет, мы вызываем BorrowCopy вручную, так как Create только создаёт запись, // а уменьшение копий — это отдельная операция. - book.BorrowCopy(); // уменьшаем AvailableCopies _context.Loans.Add(loan); await _context.SaveChangesAsync(); diff --git a/Library.Tests/Domain/BookLoanTests.cs b/Library.Tests/Domain/BookLoanTests.cs new file mode 100644 index 0000000..6c02812 --- /dev/null +++ b/Library.Tests/Domain/BookLoanTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using Library.Domain.Entities; + +namespace Library.Tests.Domain; + +public class BookLoanTests +{ + private static (User user, Book book) CreateValidPair(int copies = 3) + { + var user = User.Create("Иван", "ivan@test.com", "hash"); + var book = Book.Create("Война и мир", "isbn", "Роман", 1869, copies); + return (user, book); + } + + [Fact] + public void Create_WithValidData_ShouldSetDueDate() + { + var (user, book) = CreateValidPair(); + + var loan = BookLoan.Create(user, book, loanDays: 14); + + loan.BorrowDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(2)); + loan.DueDate.Should().BeCloseTo(DateTime.UtcNow.AddDays(14), TimeSpan.FromSeconds(2)); + loan.ReturnDate.Should().BeNull(); + loan.Fine.Should().BeNull(); + } + + [Fact] + public void Return_WithoutOverdue_ShouldSetZeroFine() + { + var (user, book) = CreateValidPair(); + var loan = BookLoan.Create(user, book, loanDays: 14); + + loan.Return(); + + loan.ReturnDate.Should().NotBeNull(); + loan.Fine.Should().Be(0); + book.AvailableCopies.Should().Be(3); // не забыл вернуть копию + } + + [Fact] + public void Return_WithOverdue_ShouldCalculateFine() + { + var (user, book) = CreateValidPair(); + var loan = BookLoan.Create(user, book, loanDays: 0); // просрочка сразу + + // Ждём немного, чтобы ReturnDate > DueDate + Thread.Sleep(1100); + + loan.Return(finePerDay: 10m); + + loan.Fine.Should().BeGreaterThan(0); + book.AvailableCopies.Should().Be(3); + } + + [Fact] + public void Return_Twice_ShouldThrow() + { + var (user, book) = CreateValidPair(); + var loan = BookLoan.Create(user, book); + loan.Return(); + + var act = () => loan.Return(); + act.Should().Throw() + .WithMessage("*уже возвращена*"); + } +} \ No newline at end of file diff --git a/Library.Tests/Domain/BookTests.cs b/Library.Tests/Domain/BookTests.cs new file mode 100644 index 0000000..cac294a --- /dev/null +++ b/Library.Tests/Domain/BookTests.cs @@ -0,0 +1,138 @@ +using FluentAssertions; +using Library.Domain.Entities; + +namespace Library.Tests.Domain; + +public class BookTests +{ + [Fact] + public void Create_WithValidData_ShouldReturnBook() + { + // Act + var book = Book.Create("Война и мир", "978-5-17-118636-4", "Роман", 1869, 5); + + // Assert + book.Title.Should().Be("Война и мир"); + book.TotalCopies.Should().Be(5); + book.AvailableCopies.Should().Be(5); // все доступны изначально + book.IsDeleted.Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Create_WithEmptyTitle_ShouldThrow(string title) + { + var act = () => Book.Create(title, "isbn", "genre", 2000, 1); + act.Should().Throw(); + } + + [Fact] + public void Create_WithZeroCopies_ShouldThrow() + { + var act = () => Book.Create("Title", "isbn", "genre", 2000, 0); + act.Should().Throw(); + } + + [Fact] + public void BorrowCopy_WhenAvailable_ShouldDecrementAvailable() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + + book.BorrowCopy(); + + book.AvailableCopies.Should().Be(2); + book.TotalCopies.Should().Be(3); // Total не меняется + } + + [Fact] + public void BorrowCopy_WhenNoAvailable_ShouldThrow() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 1); + book.BorrowCopy(); // теперь 0 + + var act = () => book.BorrowCopy(); + act.Should().Throw() + .WithMessage("*доступных экземпляров*"); + } + + [Fact] + public void ReturnCopy_ShouldIncrementAvailable() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + book.BorrowCopy(); + + book.ReturnCopy(); + + book.AvailableCopies.Should().Be(3); + } + + [Fact] + public void ReturnCopy_WhenAllReturned_ShouldThrow() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + // ничего не брали, но пытаемся вернуть + + var act = () => book.ReturnCopy(); + act.Should().Throw(); + } + + [Fact] + public void UpdateDetails_WithNewTotalCopies_ShouldRecomputeAvailable() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + book.BorrowCopy(); // Available = 2 + + book.UpdateDetails("New Title", "new-isbn", "new-genre", 2001, 5); + + book.TotalCopies.Should().Be(5); + book.AvailableCopies.Should().Be(4); // 2 + (5 - 3) + } + + [Fact] + public void UpdateDetails_WhenNewTotalLowerThanBorrowed_ShouldThrow() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 5); + book.BorrowCopy(); + book.BorrowCopy(); + book.BorrowCopy(); + book.BorrowCopy(); // Available = 1, borrowed = 4 + + var act = () => book.UpdateDetails("T", "i", "g", 2000, 2); // 2 < 4 + act.Should().Throw(); + } + + [Fact] + public void MarkAsDeleted_WhenBooksOut_ShouldThrow() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + book.BorrowCopy(); + + var act = () => book.MarkAsDeleted(); + act.Should().Throw(); + } + + [Fact] + public void MarkAsDeleted_WhenAllReturned_ShouldSetFlag() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + + book.MarkAsDeleted(); + + book.IsDeleted.Should().BeTrue(); + book.DeletedAt.Should().NotBeNull(); + } + + [Fact] + public void Restore_AfterDelete_ShouldClearFlag() + { + var book = Book.Create("Title", "isbn", "genre", 2000, 3); + book.MarkAsDeleted(); + + book.Restore(); + + book.IsDeleted.Should().BeFalse(); + book.DeletedAt.Should().BeNull(); + } +} \ No newline at end of file diff --git a/Library.Tests/Library.Tests.csproj b/Library.Tests/Library.Tests.csproj new file mode 100644 index 0000000..16a07bc --- /dev/null +++ b/Library.Tests/Library.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/Library.sln b/Library.sln index dc13dda..dee27b4 100644 --- a/Library.sln +++ b/Library.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.37502.11 d17.14 +VisualStudioVersion = 17.14.37502.11 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library.API", "Library.API\Library.API.csproj", "{D21279C6-32E9-44D5-87B8-D15C61866E23}" EndProject @@ -11,28 +11,78 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library.Applictation", "Lib EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library.Infrastructure", "Library.Infrastructure\Library.Infrastructure.csproj", "{0D228A6D-F6F4-4AD5-BA67-E260E9448710}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Library.Tests", "Library.Tests\Library.Tests.csproj", "{7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|x64.ActiveCfg = Debug|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|x64.Build.0 = Debug|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|x86.ActiveCfg = Debug|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Debug|x86.Build.0 = Debug|Any CPU {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|Any CPU.ActiveCfg = Release|Any CPU {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|Any CPU.Build.0 = Release|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|x64.ActiveCfg = Release|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|x64.Build.0 = Release|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|x86.ActiveCfg = Release|Any CPU + {D21279C6-32E9-44D5-87B8-D15C61866E23}.Release|x86.Build.0 = Release|Any CPU {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|x64.ActiveCfg = Debug|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|x64.Build.0 = Debug|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|x86.ActiveCfg = Debug|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Debug|x86.Build.0 = Debug|Any CPU {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|Any CPU.ActiveCfg = Release|Any CPU {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|Any CPU.Build.0 = Release|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|x64.ActiveCfg = Release|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|x64.Build.0 = Release|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|x86.ActiveCfg = Release|Any CPU + {55079441-4263-472E-BD28-4CA1FAE93D20}.Release|x86.Build.0 = Release|Any CPU {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|x64.ActiveCfg = Debug|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|x64.Build.0 = Debug|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|x86.ActiveCfg = Debug|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Debug|x86.Build.0 = Debug|Any CPU {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|Any CPU.ActiveCfg = Release|Any CPU {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|Any CPU.Build.0 = Release|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|x64.ActiveCfg = Release|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|x64.Build.0 = Release|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|x86.ActiveCfg = Release|Any CPU + {46BDDBF5-324A-4ACA-9B64-888F7BFFA577}.Release|x86.Build.0 = Release|Any CPU {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|x64.ActiveCfg = Debug|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|x64.Build.0 = Debug|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|x86.ActiveCfg = Debug|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Debug|x86.Build.0 = Debug|Any CPU {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|Any CPU.ActiveCfg = Release|Any CPU {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|Any CPU.Build.0 = Release|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|x64.ActiveCfg = Release|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|x64.Build.0 = Release|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|x86.ActiveCfg = Release|Any CPU + {0D228A6D-F6F4-4AD5-BA67-E260E9448710}.Release|x86.Build.0 = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|x64.ActiveCfg = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|x64.Build.0 = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|x86.ActiveCfg = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Debug|x86.Build.0 = Debug|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|Any CPU.Build.0 = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|x64.ActiveCfg = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|x64.Build.0 = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|x86.ActiveCfg = Release|Any CPU + {7F89F6CD-FDB8-462D-AB20-B9DD5D58F0AD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE