diff --git a/351004/Kuchko/ServerApp/.dockerignore b/351004/Kuchko/ServerApp/.dockerignore
new file mode 100644
index 000000000..cd967fc3a
--- /dev/null
+++ b/351004/Kuchko/ServerApp/.dockerignore
@@ -0,0 +1,25 @@
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/.idea
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/.gitattributes b/351004/Kuchko/ServerApp/.gitattributes
new file mode 100644
index 000000000..d5df80166
--- /dev/null
+++ b/351004/Kuchko/ServerApp/.gitattributes
@@ -0,0 +1,2 @@
+*.sh text eol=lf
+*.cmd text eol=crlf
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/.gitignore b/351004/Kuchko/ServerApp/.gitignore
new file mode 100644
index 000000000..82700fa31
--- /dev/null
+++ b/351004/Kuchko/ServerApp/.gitignore
@@ -0,0 +1,71 @@
+## =====================
+## Build results
+## =====================
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+artifacts/
+
+## =====================
+## .NET Core
+## =====================
+project.lock.json
+project.fragment.lock.json
+
+## =====================
+## ASP.NET Scaffolding
+## =====================
+ScaffoldingReadMe.txt
+
+## =====================
+## NuGet Packages
+## =====================
+*.nupkg
+*.snupkg
+*.symbols.nupkg
+
+## =====================
+## Environment / secrets
+## =====================
+# dotenv
+.env
+
+# Local appsettings with secrets (Cloudinary keys, DB passwords)
+appsettings.Development.json
+secrets.json
+
+# User Secrets folder (optional, depending on setup)
+%APPDATA%\Microsoft\UserSecrets/
+~/.microsoft/usersecrets/
+
+## =====================
+## Others
+## =====================
+~$*
+*~
+CodeCoverage/
+
+## =====================
+## Logs and test results
+## =====================
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+## =====================
+## MSBuild Binary and Structured Log
+## =====================
+*.binlog
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/DiscussionApp/Controllers/DiscussionController.cs b/351004/Kuchko/ServerApp/DiscussionApp/Controllers/DiscussionController.cs
new file mode 100644
index 000000000..f46ed9865
--- /dev/null
+++ b/351004/Kuchko/ServerApp/DiscussionApp/Controllers/DiscussionController.cs
@@ -0,0 +1,49 @@
+using DiscussionApp.Repositories;
+using Microsoft.AspNetCore.Mvc;
+using SharedModels;
+
+namespace DiscussionApp.Controllers;
+
+[ApiController]
+[Route("messages")]
+public class DiscussionController(MessageRepository repo) : ControllerBase
+{
+ [HttpPost]
+ public IActionResult Create([FromBody] MessageRequestTo request)
+ {
+ var newId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ var msg = new MessageResponseTo(newId, request.ArticleId, request.Content, MessageState.Approve);
+ repo.Create(msg);
+ return Ok(msg);
+ }
+
+ [HttpGet]
+ public IActionResult GetAll()
+ {
+ return Ok(repo.GetAll());
+ }
+
+ [HttpGet("{id:long}")]
+ public IActionResult GetById(long id)
+ {
+ var msg = repo.GetById(id);
+ return msg == null ? NotFound() : Ok(msg);
+ }
+
+ [HttpPut("{id:long}")]
+ public IActionResult Update(long id, [FromBody] MessageRequestTo request)
+ {
+ var msg = new MessageResponseTo(id, request.ArticleId, request.Content, MessageState.Approve);
+ repo.Update(msg);
+ return Ok(msg);
+ }
+
+ [HttpDelete("{id:long}")]
+ public IActionResult Delete(long id)
+ {
+ var existing = repo.GetById(id);
+ if (existing == null) return NotFound();
+ repo.Delete(id, existing.ArticleId);
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/DiscussionApp/DiscussionApp.csproj b/351004/Kuchko/ServerApp/DiscussionApp/DiscussionApp.csproj
new file mode 100644
index 000000000..518bf075a
--- /dev/null
+++ b/351004/Kuchko/ServerApp/DiscussionApp/DiscussionApp.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net9.0
+ enable
+ enable
+ Linux
+
+
+
+
+
+
+
+
+
+
+ .dockerignore
+
+
+
+
+
+
+
+
diff --git a/351004/Kuchko/ServerApp/DiscussionApp/DiscussionApp.http b/351004/Kuchko/ServerApp/DiscussionApp/DiscussionApp.http
new file mode 100644
index 000000000..a54718c04
--- /dev/null
+++ b/351004/Kuchko/ServerApp/DiscussionApp/DiscussionApp.http
@@ -0,0 +1,6 @@
+@DiscussionApp_HostAddress = http://localhost:5206
+
+GET {{DiscussionApp_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/351004/Kuchko/ServerApp/DiscussionApp/Dockerfile b/351004/Kuchko/ServerApp/DiscussionApp/Dockerfile
new file mode 100644
index 000000000..7a85e9850
--- /dev/null
+++ b/351004/Kuchko/ServerApp/DiscussionApp/Dockerfile
@@ -0,0 +1,33 @@
+# 1. Базовый образ
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
+WORKDIR /app
+# ИСПРАВЛЕНО: порт по ТЗ
+EXPOSE 24130
+ENV ASPNETCORE_URLS=http://+:24130
+ENV ASPNETCORE_ENVIRONMENT=Development
+
+# 2. Сборка
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+
+# ВАЖНО: Сначала копируем проекты
+COPY ["SharedModels/SharedModels.csproj", "SharedModels/"]
+# ИСПРАВЛЕНО: Правильные пути с учетом контекста "." (корень решения)
+COPY ["DiscussionApp/DiscussionApp.csproj", "DiscussionApp/"]
+RUN dotnet restore "DiscussionApp/DiscussionApp.csproj"
+
+COPY . .
+# Переходим в папку проекта перед сборкой
+WORKDIR "/src/DiscussionApp"
+RUN dotnet build "DiscussionApp.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+# 3. Публикация
+FROM build AS publish
+RUN dotnet publish "DiscussionApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+# 4. Финальный запуск
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "DiscussionApp.dll"]
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/DiscussionApp/Program.cs b/351004/Kuchko/ServerApp/DiscussionApp/Program.cs
new file mode 100644
index 000000000..09bf35add
--- /dev/null
+++ b/351004/Kuchko/ServerApp/DiscussionApp/Program.cs
@@ -0,0 +1,48 @@
+using DiscussionApp.Repositories;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ApplicationModels;
+using Microsoft.AspNetCore.Mvc.Routing;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddSingleton();
+
+builder.Services.AddControllers(options =>
+{
+ // Обязательное требование ТЗ: префикс /api/v1.0/
+
+ options.Conventions.Add(new ApiPrefixConvention(new RouteAttribute("api/v1.0")));
+});
+
+// --- 3. НАСТРОЙКА СВОБОДНОГО CORS (Для локального тестирования) ---
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy => { policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); });
+});
+
+builder.Services.AddOpenApi();
+
+var app = builder.Build();
+
+if (app.Environment.IsDevelopment()) app.MapOpenApi();
+
+app.UseCors();
+app.UseAuthorization();
+app.MapControllers();
+
+app.Run();
+
+public class ApiPrefixConvention(IRouteTemplateProvider route) : IApplicationModelConvention
+{
+ private readonly AttributeRouteModel _routePrefix = new(route);
+
+ public void Apply(ApplicationModel application)
+ {
+ foreach (var selector in application.Controllers.SelectMany(c => c.Selectors))
+ if (selector.AttributeRouteModel != null)
+ selector.AttributeRouteModel =
+ AttributeRouteModel.CombineAttributeRouteModel(_routePrefix, selector.AttributeRouteModel);
+ else
+ selector.AttributeRouteModel = _routePrefix;
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/DiscussionApp/Properties/launchSettings.json b/351004/Kuchko/ServerApp/DiscussionApp/Properties/launchSettings.json
new file mode 100644
index 000000000..79539f788
--- /dev/null
+++ b/351004/Kuchko/ServerApp/DiscussionApp/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5206",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7293;http://localhost:5206",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/351004/Kuchko/ServerApp/DiscussionApp/Repositories/MessageRepository.cs b/351004/Kuchko/ServerApp/DiscussionApp/Repositories/MessageRepository.cs
new file mode 100644
index 000000000..b993f4400
--- /dev/null
+++ b/351004/Kuchko/ServerApp/DiscussionApp/Repositories/MessageRepository.cs
@@ -0,0 +1,67 @@
+using Cassandra;
+using SharedModels;
+using ISession = Cassandra.ISession;
+
+namespace DiscussionApp.Repositories;
+
+public class MessageRepository
+{
+ private readonly ISession _session;
+
+ public MessageRepository(IConfiguration config)
+ {
+ var contactPoint = config["Cassandra:ContactPoint"] ?? "localhost";
+ var cluster = Cluster.Builder().AddContactPoint(contactPoint).Build();
+ _session = cluster.Connect();
+ InitializeDatabase();
+ }
+
+ private void InitializeDatabase()
+ {
+ _session.Execute(
+ "CREATE KEYSPACE IF NOT EXISTS distcomp WITH replication = {'class':'SimpleStrategy', 'replication_factor':1};");
+ _session.ChangeKeyspace("distcomp");
+ _session.Execute(@"CREATE TABLE IF NOT EXISTS tbl_message (
+ article_id bigint, id bigint, content text, state text, country text,
+ PRIMARY KEY ((article_id), id));");
+ }
+
+ public IEnumerable GetAll()
+ {
+ var rows = _session.Execute("SELECT * FROM tbl_message ALLOW FILTERING");
+ return rows.Select(MapRow);
+ }
+
+ public MessageResponseTo? GetById(long id)
+ {
+ var ps = _session.Prepare("SELECT * FROM tbl_message WHERE id = ? ALLOW FILTERING");
+ var row = _session.Execute(ps.Bind(id)).FirstOrDefault();
+ return row != null ? MapRow(row) : null;
+ }
+
+ public void Create(MessageResponseTo msg)
+ {
+ var ps = _session.Prepare(
+ "INSERT INTO tbl_message (article_id, id, content, state, country) VALUES (?, ?, ?, ?, ?)");
+ _session.Execute(ps.Bind(msg.ArticleId, msg.Id, msg.Content, msg.State, "Unknown"));
+ }
+
+ public void Update(MessageResponseTo msg)
+ {
+ var ps = _session.Prepare("UPDATE tbl_message SET content = ?, state = ? WHERE article_id = ? AND id = ?");
+ _session.Execute(ps.Bind(msg.Content, msg.State, msg.ArticleId, msg.Id));
+ }
+
+ public void Delete(long id, long articleId)
+ {
+ var ps = _session.Prepare("DELETE FROM tbl_message WHERE article_id = ? AND id = ?");
+ _session.Execute(ps.Bind(articleId, id));
+ }
+
+ private MessageResponseTo MapRow(Row r)
+ {
+ return new MessageResponseTo(
+ r.GetValue("id"), r.GetValue("article_id"),
+ r.GetValue("content"), r.GetValue("state"));
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/DiscussionApp/Services/KafkaWorker.cs b/351004/Kuchko/ServerApp/DiscussionApp/Services/KafkaWorker.cs
new file mode 100644
index 000000000..4f80ed403
--- /dev/null
+++ b/351004/Kuchko/ServerApp/DiscussionApp/Services/KafkaWorker.cs
@@ -0,0 +1,66 @@
+using System.Text.Json;
+using Confluent.Kafka;
+using DiscussionApp.Repositories;
+using SharedModels;
+
+namespace DiscussionApp.Services;
+
+public class KafkaWorker(MessageRepository repo, IConfiguration config) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var bootstrap = config["Kafka:BootstrapServers"] ?? "kafka:29092";
+ var cConf = new ConsumerConfig
+ { BootstrapServers = bootstrap, GroupId = "dis-group", AutoOffsetReset = AutoOffsetReset.Earliest };
+ var pConf = new ProducerConfig { BootstrapServers = bootstrap };
+
+ using var consumer = new ConsumerBuilder(cConf).Build();
+ using var producer = new ProducerBuilder(pConf).Build();
+ consumer.Subscribe("InTopic");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var result = consumer.Consume(stoppingToken);
+ var @event = JsonSerializer.Deserialize(result.Message.Value);
+ if (@event == null) continue;
+
+ try
+ {
+ switch (@event.Action)
+ {
+ case "GET_ALL":
+ @event.Payload = JsonSerializer.Serialize(repo.GetAll());
+ break;
+ case "GET":
+ var gMsg = repo.GetById(long.Parse(@event.Payload));
+ @event.Payload = gMsg != null ? JsonSerializer.Serialize(gMsg) : "";
+ if (gMsg == null) @event.ErrorMessage = "Not Found";
+ break;
+ case "CREATE":
+ var cDto = JsonSerializer.Deserialize(@event.Payload)!;
+ var state = cDto.Content.Contains("spam") ? MessageState.Decline : MessageState.Approve;
+ var finalMsg = cDto with { State = state };
+ repo.Create(finalMsg);
+ @event.Payload = JsonSerializer.Serialize(finalMsg);
+ break;
+ case "UPDATE":
+ var uDto = JsonSerializer.Deserialize(@event.Payload)!;
+ repo.Update(uDto);
+ break;
+ case "DELETE":
+ repo.Delete(long.Parse(@event.Payload), @event.ArticleId);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ @event.ErrorMessage = ex.Message;
+ }
+
+ await producer.ProduceAsync("OutTopic", new Message
+ {
+ Key = @event.ArticleId.ToString(), Value = JsonSerializer.Serialize(@event)
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/DiscussionApp/appsettings.json b/351004/Kuchko/ServerApp/DiscussionApp/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/351004/Kuchko/ServerApp/DiscussionApp/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/351004/Kuchko/ServerApp/ServerApp.sln b/351004/Kuchko/ServerApp/ServerApp.sln
new file mode 100644
index 000000000..6d55bf5b8
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp.sln
@@ -0,0 +1,28 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServerApp", "ServerApp\ServerApp.csproj", "{F2EFEDB0-911A-4C64-8CE1-CEC4D2887887}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscussionApp", "DiscussionApp\DiscussionApp.csproj", "{46462AFC-56FE-480A-84FC-488D4DB0C32E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedModels", "SharedModels\SharedModels.csproj", "{6DAA6601-8D50-45D6-A710-55448D8DCF0E}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {F2EFEDB0-911A-4C64-8CE1-CEC4D2887887}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2EFEDB0-911A-4C64-8CE1-CEC4D2887887}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F2EFEDB0-911A-4C64-8CE1-CEC4D2887887}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F2EFEDB0-911A-4C64-8CE1-CEC4D2887887}.Release|Any CPU.Build.0 = Release|Any CPU
+ {46462AFC-56FE-480A-84FC-488D4DB0C32E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {46462AFC-56FE-480A-84FC-488D4DB0C32E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {46462AFC-56FE-480A-84FC-488D4DB0C32E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {46462AFC-56FE-480A-84FC-488D4DB0C32E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6DAA6601-8D50-45D6-A710-55448D8DCF0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6DAA6601-8D50-45D6-A710-55448D8DCF0E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6DAA6601-8D50-45D6-A710-55448D8DCF0E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6DAA6601-8D50-45D6-A710-55448D8DCF0E}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/351004/Kuchko/ServerApp/ServerApp/Controllers/ArticleController.cs b/351004/Kuchko/ServerApp/ServerApp/Controllers/ArticleController.cs
new file mode 100644
index 000000000..77a504178
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Controllers/ArticleController.cs
@@ -0,0 +1,51 @@
+using Microsoft.AspNetCore.Mvc;
+using ServerApp.Models;
+using ServerApp.Models.DTOs;
+using ServerApp.Models.DTOs.Requests;
+using ServerApp.Models.DTOs.Responses;
+using ServerApp.Services.Interfaces;
+
+namespace ServerApp.Controllers;
+
+[ApiController]
+[Route("articles")]
+public class ArticleController(IArticleService articleService) : ControllerBase
+{
+ [HttpGet]
+ [HttpGet("{parameters}")]
+ public ActionResult> GetPaged([FromQuery] QueryParams parameters)
+ {
+ return Ok(articleService.GetPaged(parameters));
+ }
+
+ [HttpGet("{id:long}")]
+ public ActionResult GetById(long id)
+ {
+ return Ok(articleService.GetById(id));
+ }
+
+ [HttpPost]
+ public ActionResult Create([FromBody] ArticleRequestTo request)
+ {
+ var response = articleService.Create(request);
+ return CreatedAtAction(nameof(GetById), new { id = response.Id }, response);
+ }
+
+ [HttpPut("{id:long}")]
+ [HttpPut]
+ public ActionResult Update(long? id, [FromBody] ArticleRequestTo request)
+ {
+ var finalId = id ?? (request.Id ?? 0);
+
+ if (finalId == 0) return BadRequest(new ErrorResponse("ID must be provided in URL or body", 40002));
+
+ return Ok(articleService.Update(finalId, request));
+ }
+
+ [HttpDelete("{id:long}")]
+ public IActionResult Delete(long id)
+ {
+ articleService.Delete(id);
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Controllers/AuthorController.cs b/351004/Kuchko/ServerApp/ServerApp/Controllers/AuthorController.cs
new file mode 100644
index 000000000..5ff98e260
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Controllers/AuthorController.cs
@@ -0,0 +1,53 @@
+using Microsoft.AspNetCore.Mvc;
+using ServerApp.Models;
+using ServerApp.Models.DTOs;
+using ServerApp.Models.DTOs.Requests;
+using ServerApp.Models.DTOs.Responses;
+using ServerApp.Services.Interfaces;
+
+namespace ServerApp.Controllers;
+
+[ApiController]
+[Route("authors")]
+public class AuthorController(IAuthorService authorService) : ControllerBase
+{
+ [HttpGet]
+ [HttpGet("{parameters}")]
+ public ActionResult> GetPaged([FromQuery] QueryParams parameters)
+ {
+ return Ok(authorService.GetPaged(parameters));
+ }
+
+ [HttpGet("{id:long}")]
+ public ActionResult GetById(long id)
+ {
+ return Ok(authorService.GetById(id));
+ }
+
+ [HttpPost]
+ public ActionResult Create([FromBody] AuthorRequestTo request)
+ {
+ var response = authorService.Create(request);
+ // Возвращаем статус 201 Created и ссылку на получение созданного объекта
+ return CreatedAtAction(nameof(GetById), new { id = response.Id }, response);
+ }
+
+ [HttpPut("{id:long}")] // Поддержка /api/v1.0/authors/{id}
+ [HttpPut] // Поддержка /api/v1.0/authors (ID внутри JSON)
+ public ActionResult Update(long? id, [FromBody] AuthorRequestTo request)
+ {
+ var finalId = id ?? (request.Id ?? 0);
+
+ if (finalId == 0) return BadRequest(new ErrorResponse("ID must be provided in URL or body", 40002));
+
+ // Вызываем сервис с найденным ID
+ return Ok(authorService.Update(finalId, request));
+ }
+
+ [HttpDelete("{id:long}")]
+ public IActionResult Delete(long id)
+ {
+ authorService.Delete(id);
+ return NoContent(); // Статус 204 No Content по ТЗ
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Controllers/MessageController.cs b/351004/Kuchko/ServerApp/ServerApp/Controllers/MessageController.cs
new file mode 100644
index 000000000..7fcab4181
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Controllers/MessageController.cs
@@ -0,0 +1,42 @@
+using Microsoft.AspNetCore.Mvc;
+using ServerApp.Services.Interfaces;
+using SharedModels;
+
+namespace ServerApp.Controllers;
+
+[ApiController]
+[Route("messages")]
+public class MessageController(IMessageService messageService) : ControllerBase
+{
+ [HttpGet]
+ public ActionResult> GetAll()
+ {
+ return Ok(messageService.GetAll());
+ }
+
+ [HttpGet("{id:long}")]
+ public ActionResult GetById(long id)
+ {
+ return Ok(messageService.GetById(id));
+ }
+
+ [HttpPost]
+ public ActionResult Create([FromBody] MessageRequestTo request)
+ {
+ var response = messageService.Create(request);
+ return CreatedAtAction(nameof(GetById), new { id = response.Id }, response);
+ }
+
+ [HttpPut("{id:long}")]
+ public ActionResult Update(long id, [FromBody] MessageRequestTo request)
+ {
+ return Ok(messageService.Update(id, request));
+ }
+
+ [HttpDelete("{id:long}")]
+ public IActionResult Delete(long id)
+ {
+ messageService.Delete(id);
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Controllers/StickerController.cs b/351004/Kuchko/ServerApp/ServerApp/Controllers/StickerController.cs
new file mode 100644
index 000000000..c72bad80e
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Controllers/StickerController.cs
@@ -0,0 +1,43 @@
+using Microsoft.AspNetCore.Mvc;
+using ServerApp.Models.DTOs.Requests;
+using ServerApp.Models.DTOs.Responses;
+using ServerApp.Services.Interfaces;
+
+namespace ServerApp.Controllers;
+
+[ApiController]
+[Route("stickers")]
+public class StickerController(IStickerService stickerService) : ControllerBase
+{
+ [HttpGet]
+ public ActionResult> GetAll()
+ {
+ return Ok(stickerService.GetAll());
+ }
+
+ [HttpGet("{id:long}")]
+ public ActionResult GetById(long id)
+ {
+ return Ok(stickerService.GetById(id));
+ }
+
+ [HttpPost]
+ public ActionResult Create([FromBody] StickerRequestTo request)
+ {
+ var response = stickerService.Create(request);
+ return CreatedAtAction(nameof(GetById), new { id = response.Id }, response);
+ }
+
+ [HttpPut("{id:long}")]
+ public ActionResult Update(long id, [FromBody] StickerRequestTo request)
+ {
+ return Ok(stickerService.Update(id, request));
+ }
+
+ [HttpDelete("{id:long}")]
+ public IActionResult Delete(long id)
+ {
+ stickerService.Delete(id);
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Dockerfile b/351004/Kuchko/ServerApp/ServerApp/Dockerfile
new file mode 100644
index 000000000..4e6b18058
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Dockerfile
@@ -0,0 +1,36 @@
+# 1. Слой выполнения (Runtime)
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
+WORKDIR /app
+# ИСПРАВЛЕНО: Порт из ТЗ для Publisher
+EXPOSE 24110
+
+# Настройка портов и среды внутри контейнера
+ENV ASPNETCORE_URLS=http://+:24110
+ENV ASPNETCORE_ENVIRONMENT=Development
+
+# 2. Слой сборки (SDK)
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+
+# ВАЖНО: Сначала копируем проекты
+COPY ["SharedModels/SharedModels.csproj", "SharedModels/"]
+COPY ["ServerApp/ServerApp.csproj", "ServerApp/"]
+RUN dotnet restore "ServerApp/ServerApp.csproj"
+
+# Копируем всё остальное
+COPY . .
+
+# ИСПРАВЛЕНО: Переходим в папку ServerApp перед сборкой
+WORKDIR "/src/ServerApp"
+RUN dotnet build "ServerApp.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+# 3. Слой публикации
+FROM build AS publish
+RUN dotnet publish "ServerApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+# 4. Финальный образ
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "ServerApp.dll"]
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Infrastructure/AppDbContext.cs b/351004/Kuchko/ServerApp/ServerApp/Infrastructure/AppDbContext.cs
new file mode 100644
index 000000000..53aa6c6cb
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Infrastructure/AppDbContext.cs
@@ -0,0 +1,45 @@
+using Microsoft.EntityFrameworkCore;
+using ServerApp.Models.Entities;
+
+namespace ServerApp.Infrastructure;
+
+public class AppDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Authors { get; set; }
+ public DbSet Articles { get; set; }
+ public DbSet Messages { get; set; }
+ public DbSet Stickers { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // 1. Уникальный индекс для логина автора
+ modelBuilder.Entity()
+ .HasIndex(a => a.Login)
+ .IsUnique();
+
+ // 2. КАСКАДНОЕ УДАЛЕНИЕ: Author -> Articles
+ modelBuilder.Entity()
+ .HasOne(a => a.Author)
+ .WithMany(a => a.Articles)
+ .HasForeignKey(a => a.AuthorId)
+ .OnDelete(DeleteBehavior.Cascade); // Если удален автор, удаляются его статьи
+
+ // 3. КАСКАДНОЕ УДАЛЕНИЕ: Article -> Messages
+ modelBuilder.Entity()
+ .HasOne(m => m.Article)
+ .WithMany(a => a.Messages)
+ .HasForeignKey(m => m.ArticleId)
+ .OnDelete(DeleteBehavior.Cascade); // Если удалена статья, удаляются ее сообщения
+
+ modelBuilder.Entity()
+ .HasMany(s => s.Stickers)
+ .WithMany(a => a.Articles)
+ .UsingEntity>(
+ "tbl_article_sticker",
+ j => j.HasOne().WithMany().HasForeignKey("sticker_id"),
+ j => j.HasOne().WithMany().HasForeignKey("article_id")
+ );
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Infrastructure/GlobalExceptionFilter.cs b/351004/Kuchko/ServerApp/ServerApp/Infrastructure/GlobalExceptionFilter.cs
new file mode 100644
index 000000000..c37708b66
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Infrastructure/GlobalExceptionFilter.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using ServerApp.Models.DTOs;
+
+namespace ServerApp.Infrastructure;
+
+public class GlobalExceptionFilter : IExceptionFilter
+{
+ public void OnException(ExceptionContext context)
+ {
+ // Логика определения кодов
+ var (statusCode, customSubCode) = context.Exception switch
+ {
+ KeyNotFoundException => (StatusCodes.Status404NotFound, 01),
+ ArgumentException => (StatusCodes.Status400BadRequest, 01),
+ InvalidOperationException => (StatusCodes.Status403Forbidden, 01),
+
+ _ => (StatusCodes.Status500InternalServerError, 01)
+ };
+
+ var finalErrorCode = statusCode * 100 + customSubCode;
+ var response = new ErrorResponse(context.Exception.Message, finalErrorCode);
+
+ context.Result = new ObjectResult(response)
+ {
+ StatusCode = statusCode
+ };
+
+ context.ExceptionHandled = true;
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Infrastructure/RoutePrefixConvention.cs b/351004/Kuchko/ServerApp/ServerApp/Infrastructure/RoutePrefixConvention.cs
new file mode 100644
index 000000000..7dbfd452b
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Infrastructure/RoutePrefixConvention.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Mvc.ApplicationModels;
+using Microsoft.AspNetCore.Mvc.Routing;
+
+namespace ServerApp.Infrastructure;
+
+public class ApiPrefixConvention : IApplicationModelConvention
+{
+ private readonly AttributeRouteModel _routePrefix;
+
+ public ApiPrefixConvention(IRouteTemplateProvider routeTemplateProvider)
+ {
+ _routePrefix = new AttributeRouteModel(routeTemplateProvider);
+ }
+
+ public void Apply(ApplicationModel application)
+ {
+ foreach (var selector in application.Controllers.SelectMany(c => c.Selectors))
+ if (selector.AttributeRouteModel != null)
+ selector.AttributeRouteModel =
+ AttributeRouteModel.CombineAttributeRouteModel(_routePrefix, selector.AttributeRouteModel);
+ else
+ selector.AttributeRouteModel = _routePrefix;
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Migrations/20260503235416_InitialCreate.Designer.cs b/351004/Kuchko/ServerApp/ServerApp/Migrations/20260503235416_InitialCreate.Designer.cs
new file mode 100644
index 000000000..d359b8b37
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Migrations/20260503235416_InitialCreate.Designer.cs
@@ -0,0 +1,208 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using ServerApp.Infrastructure;
+
+#nullable disable
+
+namespace ServerApp.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260503235416_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Article", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AuthorId")
+ .HasColumnType("bigint")
+ .HasColumnName("author_id");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorId");
+
+ b.ToTable("tbl_article");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Author", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Firstname")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("firstname");
+
+ b.Property("Lastname")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("lastname");
+
+ b.Property("Login")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("login");
+
+ b.Property("Password")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.HasKey("Id");
+
+ b.ToTable("tbl_author");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Message", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ArticleId")
+ .HasColumnType("bigint")
+ .HasColumnName("article_id");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArticleId");
+
+ b.ToTable("tbl_message");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Sticker", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id");
+
+ b.ToTable("tbl_sticker");
+ });
+
+ modelBuilder.Entity("tbl_article_sticker", b =>
+ {
+ b.Property("article_id")
+ .HasColumnType("bigint");
+
+ b.Property("sticker_id")
+ .HasColumnType("bigint");
+
+ b.HasKey("article_id", "sticker_id");
+
+ b.HasIndex("sticker_id");
+
+ b.ToTable("tbl_article_sticker");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Article", b =>
+ {
+ b.HasOne("ServerApp.Models.Entities.Author", "Author")
+ .WithMany("Articles")
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Author");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Message", b =>
+ {
+ b.HasOne("ServerApp.Models.Entities.Article", "Article")
+ .WithMany("Messages")
+ .HasForeignKey("ArticleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Article");
+ });
+
+ modelBuilder.Entity("tbl_article_sticker", b =>
+ {
+ b.HasOne("ServerApp.Models.Entities.Article", null)
+ .WithMany()
+ .HasForeignKey("article_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ServerApp.Models.Entities.Sticker", null)
+ .WithMany()
+ .HasForeignKey("sticker_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Article", b =>
+ {
+ b.Navigation("Messages");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Author", b =>
+ {
+ b.Navigation("Articles");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/351004/Kuchko/ServerApp/ServerApp/Migrations/20260503235416_InitialCreate.cs b/351004/Kuchko/ServerApp/ServerApp/Migrations/20260503235416_InitialCreate.cs
new file mode 100644
index 000000000..62de10519
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Migrations/20260503235416_InitialCreate.cs
@@ -0,0 +1,146 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ServerApp.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "tbl_author",
+ columns: table => new
+ {
+ id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ login = table.Column(type: "text", nullable: false),
+ password = table.Column(type: "text", nullable: false),
+ firstname = table.Column(type: "text", nullable: false),
+ lastname = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_tbl_author", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "tbl_sticker",
+ columns: table => new
+ {
+ id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ name = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_tbl_sticker", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "tbl_article",
+ columns: table => new
+ {
+ id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ author_id = table.Column(type: "bigint", nullable: false),
+ title = table.Column(type: "text", nullable: false),
+ content = table.Column(type: "text", nullable: false),
+ created = table.Column(type: "timestamp with time zone", nullable: false),
+ modified = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_tbl_article", x => x.id);
+ table.ForeignKey(
+ name: "FK_tbl_article_tbl_author_author_id",
+ column: x => x.author_id,
+ principalTable: "tbl_author",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "tbl_article_sticker",
+ columns: table => new
+ {
+ article_id = table.Column(type: "bigint", nullable: false),
+ sticker_id = table.Column(type: "bigint", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_tbl_article_sticker", x => new { x.article_id, x.sticker_id });
+ table.ForeignKey(
+ name: "FK_tbl_article_sticker_tbl_article_article_id",
+ column: x => x.article_id,
+ principalTable: "tbl_article",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_tbl_article_sticker_tbl_sticker_sticker_id",
+ column: x => x.sticker_id,
+ principalTable: "tbl_sticker",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "tbl_message",
+ columns: table => new
+ {
+ id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ article_id = table.Column(type: "bigint", nullable: false),
+ content = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_tbl_message", x => x.id);
+ table.ForeignKey(
+ name: "FK_tbl_message_tbl_article_article_id",
+ column: x => x.article_id,
+ principalTable: "tbl_article",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_tbl_article_author_id",
+ table: "tbl_article",
+ column: "author_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_tbl_article_sticker_sticker_id",
+ table: "tbl_article_sticker",
+ column: "sticker_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_tbl_message_article_id",
+ table: "tbl_message",
+ column: "article_id");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "tbl_article_sticker");
+
+ migrationBuilder.DropTable(
+ name: "tbl_message");
+
+ migrationBuilder.DropTable(
+ name: "tbl_sticker");
+
+ migrationBuilder.DropTable(
+ name: "tbl_article");
+
+ migrationBuilder.DropTable(
+ name: "tbl_author");
+ }
+ }
+}
diff --git a/351004/Kuchko/ServerApp/ServerApp/Migrations/20260504104157_fixUpdating.Designer.cs b/351004/Kuchko/ServerApp/ServerApp/Migrations/20260504104157_fixUpdating.Designer.cs
new file mode 100644
index 000000000..c5214e447
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Migrations/20260504104157_fixUpdating.Designer.cs
@@ -0,0 +1,211 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using ServerApp.Infrastructure;
+
+#nullable disable
+
+namespace ServerApp.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260504104157_fixUpdating")]
+ partial class fixUpdating
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Article", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AuthorId")
+ .HasColumnType("bigint")
+ .HasColumnName("author_id");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorId");
+
+ b.ToTable("tbl_article");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Author", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Firstname")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("firstname");
+
+ b.Property("Lastname")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("lastname");
+
+ b.Property("Login")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("login");
+
+ b.Property("Password")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Login")
+ .IsUnique();
+
+ b.ToTable("tbl_author");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Message", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ArticleId")
+ .HasColumnType("bigint")
+ .HasColumnName("article_id");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArticleId");
+
+ b.ToTable("tbl_message");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Sticker", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id");
+
+ b.ToTable("tbl_sticker");
+ });
+
+ modelBuilder.Entity("tbl_article_sticker", b =>
+ {
+ b.Property("article_id")
+ .HasColumnType("bigint");
+
+ b.Property("sticker_id")
+ .HasColumnType("bigint");
+
+ b.HasKey("article_id", "sticker_id");
+
+ b.HasIndex("sticker_id");
+
+ b.ToTable("tbl_article_sticker");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Article", b =>
+ {
+ b.HasOne("ServerApp.Models.Entities.Author", "Author")
+ .WithMany("Articles")
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Author");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Message", b =>
+ {
+ b.HasOne("ServerApp.Models.Entities.Article", "Article")
+ .WithMany("Messages")
+ .HasForeignKey("ArticleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Article");
+ });
+
+ modelBuilder.Entity("tbl_article_sticker", b =>
+ {
+ b.HasOne("ServerApp.Models.Entities.Article", null)
+ .WithMany()
+ .HasForeignKey("article_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ServerApp.Models.Entities.Sticker", null)
+ .WithMany()
+ .HasForeignKey("sticker_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Article", b =>
+ {
+ b.Navigation("Messages");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Author", b =>
+ {
+ b.Navigation("Articles");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/351004/Kuchko/ServerApp/ServerApp/Migrations/20260504104157_fixUpdating.cs b/351004/Kuchko/ServerApp/ServerApp/Migrations/20260504104157_fixUpdating.cs
new file mode 100644
index 000000000..76d5635ed
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Migrations/20260504104157_fixUpdating.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ServerApp.Migrations
+{
+ ///
+ public partial class fixUpdating : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateIndex(
+ name: "IX_tbl_author_login",
+ table: "tbl_author",
+ column: "login",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_tbl_author_login",
+ table: "tbl_author");
+ }
+ }
+}
diff --git a/351004/Kuchko/ServerApp/ServerApp/Migrations/AppDbContextModelSnapshot.cs b/351004/Kuchko/ServerApp/ServerApp/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 000000000..81cf756bd
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,208 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using ServerApp.Infrastructure;
+
+#nullable disable
+
+namespace ServerApp.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ partial class AppDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.14")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Article", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AuthorId")
+ .HasColumnType("bigint")
+ .HasColumnName("author_id");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorId");
+
+ b.ToTable("tbl_article");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Author", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Firstname")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("firstname");
+
+ b.Property("Lastname")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("lastname");
+
+ b.Property("Login")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("login");
+
+ b.Property("Password")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Login")
+ .IsUnique();
+
+ b.ToTable("tbl_author");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Message", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ArticleId")
+ .HasColumnType("bigint")
+ .HasColumnName("article_id");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArticleId");
+
+ b.ToTable("tbl_message");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Sticker", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id");
+
+ b.ToTable("tbl_sticker");
+ });
+
+ modelBuilder.Entity("tbl_article_sticker", b =>
+ {
+ b.Property("article_id")
+ .HasColumnType("bigint");
+
+ b.Property("sticker_id")
+ .HasColumnType("bigint");
+
+ b.HasKey("article_id", "sticker_id");
+
+ b.HasIndex("sticker_id");
+
+ b.ToTable("tbl_article_sticker");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Article", b =>
+ {
+ b.HasOne("ServerApp.Models.Entities.Author", "Author")
+ .WithMany("Articles")
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Author");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Message", b =>
+ {
+ b.HasOne("ServerApp.Models.Entities.Article", "Article")
+ .WithMany("Messages")
+ .HasForeignKey("ArticleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Article");
+ });
+
+ modelBuilder.Entity("tbl_article_sticker", b =>
+ {
+ b.HasOne("ServerApp.Models.Entities.Article", null)
+ .WithMany()
+ .HasForeignKey("article_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("ServerApp.Models.Entities.Sticker", null)
+ .WithMany()
+ .HasForeignKey("sticker_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Article", b =>
+ {
+ b.Navigation("Messages");
+ });
+
+ modelBuilder.Entity("ServerApp.Models.Entities.Author", b =>
+ {
+ b.Navigation("Articles");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/ErrorResponse.cs b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/ErrorResponse.cs
new file mode 100644
index 000000000..5ba7d9a49
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/ErrorResponse.cs
@@ -0,0 +1,6 @@
+namespace ServerApp.Models.DTOs;
+
+public record ErrorResponse(
+ string ErrorMessage,
+ int ErrorCode
+);
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Requests/ArticleRequestTo.cs b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Requests/ArticleRequestTo.cs
new file mode 100644
index 000000000..70d65fd44
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Requests/ArticleRequestTo.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ServerApp.Models.DTOs.Requests;
+
+public record ArticleRequestTo(
+ long? Id,
+ [Required] long AuthorId,
+ [Required]
+ [StringLength(64, MinimumLength = 2)]
+ string Title,
+ [Required]
+ [StringLength(2048, MinimumLength = 4)]
+ string Content,
+ ICollection? Stickers
+);
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Requests/AuthorRequestTo.cs b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Requests/AuthorRequestTo.cs
new file mode 100644
index 000000000..90109ff4e
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Requests/AuthorRequestTo.cs
@@ -0,0 +1,19 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ServerApp.Models.DTOs.Requests;
+
+public record AuthorRequestTo(
+ long? Id,
+ [Required]
+ [StringLength(64, MinimumLength = 2)]
+ string Login,
+ [Required]
+ [StringLength(128, MinimumLength = 8)]
+ string Password,
+ [Required]
+ [StringLength(64, MinimumLength = 2)]
+ string Firstname,
+ [Required]
+ [StringLength(64, MinimumLength = 2)]
+ string Lastname
+);
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Requests/StickerRequestTo.cs b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Requests/StickerRequestTo.cs
new file mode 100644
index 000000000..71d76f132
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Requests/StickerRequestTo.cs
@@ -0,0 +1,9 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ServerApp.Models.DTOs.Requests;
+
+public record StickerRequestTo(
+ [Required]
+ [StringLength(32, MinimumLength = 2, ErrorMessage = "Название стикера должно быть от 2 до 32 символов")]
+ string Name
+);
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Responses/ArticleResponseTo.cs b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Responses/ArticleResponseTo.cs
new file mode 100644
index 000000000..b52295988
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Responses/ArticleResponseTo.cs
@@ -0,0 +1,11 @@
+namespace ServerApp.Models.DTOs.Responses;
+
+public record ArticleResponseTo(
+ long Id,
+ long AuthorId,
+ string Title,
+ string Content,
+ DateTime Created,
+ DateTime Modified,
+ ICollection? Stickers
+);
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Responses/AuthorResponseTo.cs b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Responses/AuthorResponseTo.cs
new file mode 100644
index 000000000..500c28018
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Responses/AuthorResponseTo.cs
@@ -0,0 +1,8 @@
+namespace ServerApp.Models.DTOs.Responses;
+
+public record AuthorResponseTo(
+ long Id,
+ string Login,
+ string Firstname,
+ string Lastname
+);
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Responses/StickerResponseTo.cs b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Responses/StickerResponseTo.cs
new file mode 100644
index 000000000..7a4fbb75a
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/DTOs/Responses/StickerResponseTo.cs
@@ -0,0 +1,6 @@
+namespace ServerApp.Models.DTOs.Responses;
+
+public record StickerResponseTo(
+ long Id,
+ string Name
+);
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Article.cs b/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Article.cs
new file mode 100644
index 000000000..43d8de6d4
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Article.cs
@@ -0,0 +1,22 @@
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace ServerApp.Models.Entities;
+
+[Table("tbl_article")]
+public class Article : BaseEntity
+{
+ [Column("author_id")] public long AuthorId { get; set; }
+
+ [ForeignKey("AuthorId")] public virtual Author Author { get; set; } = null!;
+
+ [Column("title")] public string Title { get; set; } = null!;
+
+ [Column("content")] public string Content { get; set; } = null!;
+
+ [Column("created")] public DateTime Created { get; set; }
+
+ [Column("modified")] public DateTime Modified { get; set; }
+
+ public virtual ICollection Messages { get; set; } = new List();
+ public virtual ICollection Stickers { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Author.cs b/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Author.cs
new file mode 100644
index 000000000..c0703a18f
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Author.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace ServerApp.Models.Entities;
+
+[Table("tbl_author")]
+public class Author : BaseEntity
+{
+ [Column("login")] public string Login { get; set; } = null!;
+
+ [Column("password")] public string Password { get; set; } = null!;
+
+ [Column("firstname")] public string Firstname { get; set; } = null!;
+
+ [Column("lastname")] public string Lastname { get; set; } = null!;
+
+ // связь 1 ко многим
+ public virtual ICollection Articles { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/Entities/BaseEntity.cs b/351004/Kuchko/ServerApp/ServerApp/Models/Entities/BaseEntity.cs
new file mode 100644
index 000000000..8ba05d204
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/Entities/BaseEntity.cs
@@ -0,0 +1,12 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace ServerApp.Models.Entities;
+
+public class BaseEntity
+{
+ [Key]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ [Column("id")]
+ public long Id { get; set; }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Message.cs b/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Message.cs
new file mode 100644
index 000000000..775ffc96e
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Message.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace ServerApp.Models.Entities;
+
+[Table("tbl_message")]
+public class Message : BaseEntity
+{
+ [Column("article_id")] public long ArticleId { get; set; }
+
+ [ForeignKey("ArticleId")] // Указываем связь по внешнему ключу
+ public virtual Article Article { get; set; } = null!;
+
+ [Column("content")] public string Content { get; set; } = null!;
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Sticker.cs b/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Sticker.cs
new file mode 100644
index 000000000..3eefec1e0
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/Entities/Sticker.cs
@@ -0,0 +1,11 @@
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace ServerApp.Models.Entities;
+
+[Table("tbl_sticker")]
+public class Sticker : BaseEntity
+{
+ [Column("name")] public string Name { get; set; } = null!;
+
+ public virtual ICollection Articles { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/MyApiResponse.cs b/351004/Kuchko/ServerApp/ServerApp/Models/MyApiResponse.cs
new file mode 100644
index 000000000..829f15df8
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/MyApiResponse.cs
@@ -0,0 +1,8 @@
+namespace ServerApp.Models;
+
+public class ApiResponse
+{
+ public string Message { get; set; } = string.Empty;
+ public string Version { get; set; } = "1.0";
+ public DateTime ServerTime { get; set; } = DateTime.Now;
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Models/QueryParams.cs b/351004/Kuchko/ServerApp/ServerApp/Models/QueryParams.cs
new file mode 100644
index 000000000..dc550bee5
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Models/QueryParams.cs
@@ -0,0 +1,8 @@
+namespace ServerApp.Models;
+
+public record QueryParams(
+ int PageNumber = 1,
+ int PageSize = 10,
+ string? SortBy = "Id",
+ string? SortOrder = "asc"
+);
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Program.cs b/351004/Kuchko/ServerApp/ServerApp/Program.cs
new file mode 100644
index 000000000..7e124cacf
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Program.cs
@@ -0,0 +1,125 @@
+using System.Reflection;
+using Mapster;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Scalar.AspNetCore;
+using ServerApp.Infrastructure;
+using ServerApp.Models.DTOs;
+using ServerApp.Models.Entities;
+using ServerApp.Repository;
+using ServerApp.Services;
+using ServerApp.Services.Implementations;
+using ServerApp.Services.Interfaces;
+
+var builder = WebApplication.CreateBuilder(args);
+
+var config = TypeAdapterConfig.GlobalSettings;
+config.Scan(Assembly.GetExecutingAssembly());
+
+var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
+builder.Services.AddDbContext(options =>
+ options.UseNpgsql(connectionString));
+
+builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+builder.Services.AddSingleton();
+builder.Services.AddHostedService(); // Код из предыдущего ответа
+builder.Services.AddScoped();
+
+builder.Services.AddHttpClient(client =>
+{
+ // Берем URL из docker-compose
+ var discussionUrl = builder.Configuration["DiscussionServiceUrl"] ?? "http://localhost:24130";
+ client.BaseAddress = new Uri(discussionUrl);
+});
+
+builder.Services.AddControllers(options =>
+ {
+ options.Conventions.Add(new ApiPrefixConvention(new RouteAttribute("api/v1.0")));
+ options.Filters.Add();
+ })
+ .ConfigureApiBehaviorOptions(options =>
+ {
+ options.InvalidModelStateResponseFactory = context =>
+ {
+ var errorMsg = string.Join(" | ", context.ModelState.Values
+ .SelectMany(v => v.Errors)
+ .Select(e => e.ErrorMessage));
+
+ return new BadRequestObjectResult(new ErrorResponse(errorMsg, 40001));
+ };
+ });
+
+builder.Services.AddOpenApi(options =>
+{
+ options.AddDocumentTransformer((document, context, cancellationToken) =>
+ {
+ document.Servers.Clear();
+ return Task.CompletedTask;
+ });
+});
+
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy =>
+ {
+ policy.AllowAnyOrigin()
+ .AllowAnyMethod()
+ .AllowAnyHeader();
+ });
+});
+
+var app = builder.Build();
+app.UseCors();
+
+if (app.Environment.IsDevelopment())
+{
+ app.MapOpenApi();
+
+ app.MapScalarApiReference(options =>
+ {
+ options
+ .WithTitle("My Project API v1.0")
+ .WithTheme(ScalarTheme.DeepSpace);
+ });
+}
+
+
+// app.UseHttpsRedirection();
+app.UseAuthorization();
+app.MapControllers();
+
+using (var scope = app.Services.CreateScope())
+{
+ var context = scope.ServiceProvider.GetRequiredService();
+
+ try
+ {
+ // 1. Применяет все не примененные миграции (создает таблицы tbl_...)
+ context.Database.Migrate();
+
+ // 2. Добавляет начальную запись по ТЗ, если база пустая
+ if (!context.Authors.Any())
+ {
+ context.Authors.Add(new Author
+ {
+ Login = "kuchkomaxim2527@gmail.com",
+ Password = "password123",
+ Firstname = "Максим",
+ Lastname = "Кучко"
+ });
+ context.SaveChanges();
+ }
+ }
+ catch (Exception ex)
+ {
+ // Выведет ошибку в консоль Docker, если БД недоступна
+ Console.WriteLine($"Ошибка при инициализации БД: {ex.Message}");
+ }
+}
+
+app.Run();
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Properties/launchSettings.json b/351004/Kuchko/ServerApp/ServerApp/Properties/launchSettings.json
new file mode 100644
index 000000000..5d8337171
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Properties/launchSettings.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "launchUrl": "scalar/v1",
+ "applicationUrl": "http://localhost:24110",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7045;http://localhost:24110",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/351004/Kuchko/ServerApp/ServerApp/Repository/EfRepository.cs b/351004/Kuchko/ServerApp/ServerApp/Repository/EfRepository.cs
new file mode 100644
index 000000000..8b5b62816
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Repository/EfRepository.cs
@@ -0,0 +1,67 @@
+using System.Linq.Dynamic.Core;
+using Microsoft.EntityFrameworkCore;
+using ServerApp.Infrastructure;
+using ServerApp.Models;
+using ServerApp.Models.Entities;
+
+namespace ServerApp.Repository;
+
+public class EfRepository(AppDbContext context) : IRepository where T : BaseEntity
+{
+ public IEnumerable GetPaged(QueryParams p)
+ {
+ var query = context.Set().AsNoTracking();
+
+ // Сортировка ("Id asc" или "Id desc")
+ var sortExpression = $"{p.SortBy} {p.SortOrder}";
+ query = query.OrderBy(sortExpression);
+
+ // Пагинация
+ return query
+ .Skip((p.PageNumber - 1) * p.PageSize)
+ .Take(p.PageSize)
+ .ToList();
+ }
+
+ public IEnumerable GetAll()
+ {
+ return context.Set().AsNoTracking().ToList();
+ }
+
+ public T? GetById(long id)
+ {
+ var query = context.Set().AsQueryable();
+
+ if (typeof(T) == typeof(Article))
+ query = query.Include("Stickers");
+ else if (typeof(T) == typeof(Sticker))
+ query = query.Include("Articles");
+ else if (typeof(T) == typeof(Author)) query = query.Include("Articles").Include("Articles.Stickers");
+
+ return query.FirstOrDefault(e => e.Id == id);
+ }
+
+ public T Create(T entity)
+ {
+ context.Set().Add(entity);
+ context.SaveChanges();
+ return entity;
+ }
+
+ public T Update(T entity)
+ {
+ context.Set().Update(entity);
+ context.SaveChanges();
+ return entity;
+ }
+
+ public bool Delete(long id)
+ {
+ var entity = GetById(id);
+ if (entity == null) return false;
+
+ context.Set().Remove(entity);
+ context.SaveChanges();
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Repository/IRepository.cs b/351004/Kuchko/ServerApp/ServerApp/Repository/IRepository.cs
new file mode 100644
index 000000000..8afd02f18
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Repository/IRepository.cs
@@ -0,0 +1,14 @@
+using ServerApp.Models;
+using ServerApp.Models.Entities;
+
+namespace ServerApp.Repository;
+
+public interface IRepository where T : BaseEntity
+{
+ IEnumerable GetPaged(QueryParams @params);
+ IEnumerable GetAll();
+ T? GetById(long id);
+ T Create(T entity);
+ T Update(T entity);
+ bool Delete(long id);
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/ServerApp.csproj b/351004/Kuchko/ServerApp/ServerApp/ServerApp.csproj
new file mode 100644
index 000000000..b7ba96ce6
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/ServerApp.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net9.0
+ enable
+ enable
+ Linux
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+ .dockerignore
+
+
+
+
+
+
+
+
diff --git a/351004/Kuchko/ServerApp/ServerApp/ServerApp.http b/351004/Kuchko/ServerApp/ServerApp/ServerApp.http
new file mode 100644
index 000000000..dd904e8c0
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/ServerApp.http
@@ -0,0 +1,6 @@
+@ServerApp_HostAddress = http://localhost:5070
+
+GET {{ServerApp_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/ArticleService.cs b/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/ArticleService.cs
new file mode 100644
index 000000000..f0fd8c2d9
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/ArticleService.cs
@@ -0,0 +1,111 @@
+using Mapster;
+using ServerApp.Models;
+using ServerApp.Models.DTOs.Requests;
+using ServerApp.Models.DTOs.Responses;
+using ServerApp.Models.Entities;
+using ServerApp.Repository;
+using ServerApp.Services.Interfaces;
+
+namespace ServerApp.Services.Implementations;
+
+public class ArticleService(
+ IRepository stickerRepo,
+ IRepository articleRepo,
+ IRepository authorRepo) : IArticleService
+{
+ public IEnumerable GetPaged(QueryParams parametrs)
+ {
+ return articleRepo.GetPaged(parametrs).Adapt>();
+ }
+
+ public ArticleResponseTo GetById(long id)
+ {
+ var article = articleRepo.GetById(id) ?? throw new KeyNotFoundException($"Article {id} not found");
+ return article.Adapt();
+ }
+
+ public ArticleResponseTo Create(ArticleRequestTo request)
+ {
+ // Валидация связи: существует ли автор?
+ if (authorRepo.GetById(request.AuthorId) == null)
+ throw new ArgumentException($"Author with ID {request.AuthorId} not found");
+
+ var titleExists = articleRepo.GetAll()
+ .Any(a => a.Title.Equals(request.Title, StringComparison.OrdinalIgnoreCase));
+
+ if (titleExists) throw new InvalidOperationException($"Title '{request.Title}' is already taken");
+
+ var article = request.Adapt();
+ article.Created = article.Modified = DateTime.UtcNow;
+
+ article.Stickers = new List();
+
+ if (request.Stickers != null && request.Stickers.Any())
+ {
+ var existingStickers = stickerRepo.GetAll().ToList();
+
+ foreach (var stickerName in request.Stickers)
+ {
+ var found = existingStickers.FirstOrDefault(s => s.Name == stickerName);
+
+ if (found != null)
+ article.Stickers.Add(found);
+ else
+ article.Stickers.Add(new Sticker { Name = stickerName });
+ }
+ }
+
+ var created = articleRepo.Create(article);
+ return created.Adapt();
+ }
+
+ public ArticleResponseTo Update(long id, ArticleRequestTo request)
+ {
+ var existing = articleRepo.GetById(id) ?? throw new KeyNotFoundException($"Article {id} not found");
+
+ if (authorRepo.GetById(request.AuthorId) == null)
+ throw new ArgumentException($"Author {request.AuthorId} not found");
+
+ request.Adapt(existing);
+ existing.Id = id;
+ existing.Modified = DateTime.UtcNow; // Обновляем только дату изменения
+
+ if (request.Stickers != null)
+ {
+ var allStickersInDb = stickerRepo.GetAll().ToList();
+ existing.Stickers.Clear();
+ foreach (var stickerName in request.Stickers)
+ {
+ var foundSticker = allStickersInDb.FirstOrDefault(s => s.Name == stickerName);
+
+ if (foundSticker != null)
+ {
+ existing.Stickers.Add(foundSticker);
+ }
+ else
+ {
+ var newSticker = new Sticker { Name = stickerName };
+ existing.Stickers.Add(newSticker);
+
+ allStickersInDb.Add(newSticker);
+ }
+ }
+ }
+
+ articleRepo.Update(existing);
+ return existing.Adapt();
+ }
+
+ public void Delete(long id)
+ {
+ var article = articleRepo.GetById(id) ?? throw new KeyNotFoundException($"Article {id} not found");
+ var stickersToCheck = article.Stickers.ToList();
+ articleRepo.Delete(id);
+
+ foreach (var sticker in stickersToCheck)
+ {
+ var currentSticker = stickerRepo.GetById(sticker.Id);
+ if (currentSticker != null && currentSticker.Articles.Count == 0) stickerRepo.Delete(currentSticker.Id);
+ }
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/AuthorService.cs b/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/AuthorService.cs
new file mode 100644
index 000000000..0fa6318e1
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/AuthorService.cs
@@ -0,0 +1,49 @@
+using Mapster;
+using ServerApp.Models;
+using ServerApp.Models.DTOs.Requests;
+using ServerApp.Models.DTOs.Responses;
+using ServerApp.Models.Entities;
+using ServerApp.Repository;
+using ServerApp.Services.Interfaces;
+
+namespace ServerApp.Services.Implementations;
+
+public class AuthorService(IRepository repository) : IAuthorService
+{
+ public IEnumerable GetPaged(QueryParams parameters)
+ {
+ return repository.GetPaged(parameters).Adapt>();
+ }
+
+ public AuthorResponseTo GetById(long id)
+ {
+ var author = repository.GetById(id) ?? throw new KeyNotFoundException($"Author with ID {id} not found");
+ return author.Adapt();
+ }
+
+ public AuthorResponseTo Create(AuthorRequestTo request)
+ {
+ var loginExists = repository.GetAll()
+ .Any(a => a.Login.Equals(request.Login, StringComparison.OrdinalIgnoreCase));
+
+ if (loginExists) throw new InvalidOperationException($"Login '{request.Login}' is already taken");
+
+ var author = request.Adapt();
+ var created = repository.Create(author);
+ return created.Adapt();
+ }
+
+ public AuthorResponseTo Update(long id, AuthorRequestTo request)
+ {
+ var existing = repository.GetById(id) ?? throw new KeyNotFoundException($"Author {id} not found");
+ request.Adapt(existing);
+ existing.Id = id;
+ repository.Update(existing);
+ return existing.Adapt();
+ }
+
+ public void Delete(long id)
+ {
+ if (!repository.Delete(id)) throw new KeyNotFoundException($"Author {id} not found");
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/MessageService.cs b/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/MessageService.cs
new file mode 100644
index 000000000..346234324
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/MessageService.cs
@@ -0,0 +1,97 @@
+using System.Text.Json;
+using Confluent.Kafka;
+using ServerApp.Models.Entities;
+using ServerApp.Repository;
+using ServerApp.Services.Interfaces;
+using SharedModels;
+
+namespace ServerApp.Services.Implementations;
+
+public class MessageService(
+ KafkaRequestManager rms,
+ IConfiguration config,
+ IRepository articleRepo) : IMessageService
+{
+ private readonly JsonSerializerOptions _options = new() { PropertyNameCaseInsensitive = true };
+
+ private readonly ProducerConfig _pConf = new()
+ { BootstrapServers = config["Kafka:BootstrapServers"] ?? "kafka:29092" };
+
+ public MessageResponseTo Create(MessageRequestTo request)
+ {
+ if (articleRepo.GetById(request.ArticleId) == null) throw new ArgumentException("Article not found");
+
+ var msg = new MessageResponseTo(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), request.ArticleId,
+ request.Content, MessageState.Pending);
+ var ev = new KafkaEvent
+ { Action = "CREATE", ArticleId = request.ArticleId, Payload = JsonSerializer.Serialize(msg) };
+
+ using var p = new ProducerBuilder(_pConf).Build();
+ p.Produce("InTopic",
+ new Message { Key = ev.ArticleId.ToString(), Value = JsonSerializer.Serialize(ev) });
+ return msg; // Сразу возвращаем PENDING
+ }
+
+ public MessageResponseTo Update(long id, MessageRequestTo request)
+ {
+ var msg = new MessageResponseTo(id, request.ArticleId, request.Content, MessageState.Pending);
+ var ev = new KafkaEvent
+ {
+ Action = "UPDATE",
+ ArticleId = request.ArticleId,
+ Payload = JsonSerializer.Serialize(msg),
+ CorrelationId = Guid.NewGuid().ToString()
+ };
+
+ var result = SendRequestReply(ev).Result;
+ return JsonSerializer.Deserialize(result.Payload, _options)!;
+ }
+
+ public MessageResponseTo GetById(long id)
+ {
+ // Для упрощения в этом задании ищем по ID (с ALLOW FILTERING в Cassandra)
+ var res = RequestReply(new KafkaEvent { Action = "GET", Payload = id.ToString() }).Result;
+ return JsonSerializer.Deserialize(res.Payload)!;
+ }
+
+ public IEnumerable GetAll()
+ {
+ var res = RequestReply(new KafkaEvent { Action = "GET_ALL" }).Result;
+ return JsonSerializer.Deserialize>(res.Payload)!;
+ }
+
+ public void Delete(long id)
+ {
+ // Нам нужно знать articleId для удаления в Cassandra.
+ // В реальном API он должен передаваться, тут получим его через GET
+ var msg = GetById(id);
+ _ = RequestReply(new KafkaEvent { Action = "DELETE", ArticleId = msg.ArticleId, Payload = id.ToString() })
+ .Result;
+ }
+
+ private async Task RequestReply(KafkaEvent ev)
+ {
+ var tcs = new TaskCompletionSource();
+ rms.Add(ev.CorrelationId, tcs);
+
+ using var p = new ProducerBuilder(_pConf).Build();
+ await p.ProduceAsync("InTopic",
+ new Message { Key = ev.ArticleId.ToString(), Value = JsonSerializer.Serialize(ev) });
+
+ if (await Task.WhenAny(tcs.Task, Task.Delay(1000)) == tcs.Task) return await tcs.Task;
+ throw new TimeoutException("Discussion service timeout");
+ }
+
+ private async Task SendRequestReply(KafkaEvent ev)
+ {
+ var tcs = new TaskCompletionSource();
+ rms.Add(ev.CorrelationId, tcs);
+
+ using var p = new ProducerBuilder(_pConf).Build();
+ await p.ProduceAsync("InTopic",
+ new Message { Key = ev.ArticleId.ToString(), Value = JsonSerializer.Serialize(ev) });
+
+ if (await Task.WhenAny(tcs.Task, Task.Delay(1000)) == tcs.Task) return await tcs.Task;
+ throw new TimeoutException("Discussion service timeout (1s)");
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/StickerService.cs b/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/StickerService.cs
new file mode 100644
index 000000000..43e18f271
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/Implementations/StickerService.cs
@@ -0,0 +1,43 @@
+using Mapster;
+using ServerApp.Models.DTOs.Requests;
+using ServerApp.Models.DTOs.Responses;
+using ServerApp.Models.Entities;
+using ServerApp.Repository;
+using ServerApp.Services.Interfaces;
+
+namespace ServerApp.Services.Implementations;
+
+public class StickerService(IRepository repository) : IStickerService
+{
+ public IEnumerable GetAll()
+ {
+ return repository.GetAll().Adapt>();
+ }
+
+ public StickerResponseTo GetById(long id)
+ {
+ var sticker = repository.GetById(id) ?? throw new KeyNotFoundException($"Sticker {id} not found");
+ return sticker.Adapt();
+ }
+
+ public StickerResponseTo Create(StickerRequestTo request)
+ {
+ var sticker = request.Adapt();
+ var created = repository.Create(sticker);
+ return created.Adapt();
+ }
+
+ public StickerResponseTo Update(long id, StickerRequestTo request)
+ {
+ var existing = repository.GetById(id) ?? throw new KeyNotFoundException($"Sticker {id} not found");
+ request.Adapt(existing);
+ existing.Id = id;
+ repository.Update(existing);
+ return existing.Adapt();
+ }
+
+ public void Delete(long id)
+ {
+ if (!repository.Delete(id)) throw new KeyNotFoundException($"Sticker {id} not found");
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IArticleService.cs b/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IArticleService.cs
new file mode 100644
index 000000000..75a9cb1bd
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IArticleService.cs
@@ -0,0 +1,14 @@
+using ServerApp.Models;
+using ServerApp.Models.DTOs.Requests;
+using ServerApp.Models.DTOs.Responses;
+
+namespace ServerApp.Services.Interfaces;
+
+public interface IArticleService
+{
+ IEnumerable GetPaged(QueryParams parameters);
+ ArticleResponseTo GetById(long id);
+ ArticleResponseTo Create(ArticleRequestTo request);
+ ArticleResponseTo Update(long id, ArticleRequestTo request);
+ void Delete(long id);
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IAuthorService.cs b/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IAuthorService.cs
new file mode 100644
index 000000000..889285f28
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IAuthorService.cs
@@ -0,0 +1,14 @@
+using ServerApp.Models;
+using ServerApp.Models.DTOs.Requests;
+using ServerApp.Models.DTOs.Responses;
+
+namespace ServerApp.Services.Interfaces;
+
+public interface IAuthorService
+{
+ IEnumerable GetPaged(QueryParams parameters);
+ AuthorResponseTo GetById(long id);
+ AuthorResponseTo Create(AuthorRequestTo request);
+ AuthorResponseTo Update(long id, AuthorRequestTo request);
+ void Delete(long id);
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IMessageService.cs b/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IMessageService.cs
new file mode 100644
index 000000000..fa7f1f800
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IMessageService.cs
@@ -0,0 +1,12 @@
+using SharedModels;
+
+namespace ServerApp.Services.Interfaces;
+
+public interface IMessageService
+{
+ IEnumerable GetAll();
+ MessageResponseTo GetById(long id);
+ MessageResponseTo Create(MessageRequestTo request);
+ MessageResponseTo Update(long id, MessageRequestTo request);
+ void Delete(long id);
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IStickerService.cs b/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IStickerService.cs
new file mode 100644
index 000000000..48422f730
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/Interfaces/IStickerService.cs
@@ -0,0 +1,13 @@
+using ServerApp.Models.DTOs.Requests;
+using ServerApp.Models.DTOs.Responses;
+
+namespace ServerApp.Services.Interfaces;
+
+public interface IStickerService
+{
+ IEnumerable GetAll();
+ StickerResponseTo GetById(long id);
+ StickerResponseTo Create(StickerRequestTo request);
+ StickerResponseTo Update(long id, StickerRequestTo request);
+ void Delete(long id);
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/KafkaRequestManager.cs b/351004/Kuchko/ServerApp/ServerApp/Services/KafkaRequestManager.cs
new file mode 100644
index 000000000..ce0f680f2
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/KafkaRequestManager.cs
@@ -0,0 +1,19 @@
+using System.Collections.Concurrent;
+using SharedModels;
+
+namespace ServerApp.Services;
+
+public class KafkaRequestManager
+{
+ private readonly ConcurrentDictionary> _requests = new();
+
+ public void Add(string id, TaskCompletionSource tcs)
+ {
+ _requests.TryAdd(id, tcs);
+ }
+
+ public void Resolve(KafkaEvent ev)
+ {
+ if (_requests.TryRemove(ev.CorrelationId, out var tcs)) tcs.TrySetResult(ev);
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/KafkaResponseListener.cs b/351004/Kuchko/ServerApp/ServerApp/Services/KafkaResponseListener.cs
new file mode 100644
index 000000000..603c470ac
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/KafkaResponseListener.cs
@@ -0,0 +1,36 @@
+using System.Text.Json;
+using Confluent.Kafka;
+using SharedModels;
+
+namespace ServerApp.Services;
+
+public class KafkaResponseListener(KafkaRequestManager requestManager, IConfiguration config) : BackgroundService
+{
+ protected override Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ return Task.Run(() =>
+ {
+ var conf = new ConsumerConfig
+ {
+ BootstrapServers = config["Kafka:BootstrapServers"] ?? "kafka:29092",
+ GroupId = "publisher-response-group",
+ AutoOffsetReset = AutoOffsetReset.Earliest
+ };
+
+ using var consumer = new ConsumerBuilder(conf).Build();
+ consumer.Subscribe("OutTopic");
+
+ while (!stoppingToken.IsCancellationRequested)
+ try
+ {
+ var result = consumer.Consume(stoppingToken);
+ var @event = JsonSerializer.Deserialize(result.Message.Value);
+ if (@event != null) requestManager.Resolve(@event);
+ }
+ catch
+ {
+ /* log error */
+ }
+ }, stoppingToken);
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/Services/Mapping/MappingConfig.cs b/351004/Kuchko/ServerApp/ServerApp/Services/Mapping/MappingConfig.cs
new file mode 100644
index 000000000..248d715d9
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/Services/Mapping/MappingConfig.cs
@@ -0,0 +1,39 @@
+using Mapster;
+using ServerApp.Models.DTOs.Requests;
+using ServerApp.Models.DTOs.Responses;
+using ServerApp.Models.Entities;
+using SharedModels;
+
+namespace ServerApp.Services.Mapping;
+
+public class MappingConfig : IRegister
+{
+ public void Register(TypeAdapterConfig config)
+ {
+ // 1. Маппинг для Author
+ config.NewConfig();
+ config.NewConfig();
+
+ // 2. Маппинг для Article
+ config.NewConfig()
+ .Ignore(dest => dest.Stickers)
+ .AfterMapping(dest =>
+ {
+ // При создании статьи устанавливаем даты
+ var now = DateTime.UtcNow;
+ dest.Stickers = new List();
+ dest.Created = now;
+ dest.Modified = now;
+ });
+
+ config.NewConfig();
+
+ // 3. Маппинг для Message
+ config.NewConfig();
+ config.NewConfig();
+
+ // 4. Маппинг для Sticker
+ config.NewConfig();
+ config.NewConfig();
+ }
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/ServerApp/appsettings.json b/351004/Kuchko/ServerApp/ServerApp/appsettings.json
new file mode 100644
index 000000000..dd4408856
--- /dev/null
+++ b/351004/Kuchko/ServerApp/ServerApp/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "ConnectionString": {
+ "DefaultConnection": "Host=localhost;Port=5432;Database=distcomp;Username=postgres;Password=postgres"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/351004/Kuchko/ServerApp/SharedModels/KafkaEvent.cs b/351004/Kuchko/ServerApp/SharedModels/KafkaEvent.cs
new file mode 100644
index 000000000..e4c872778
--- /dev/null
+++ b/351004/Kuchko/ServerApp/SharedModels/KafkaEvent.cs
@@ -0,0 +1,17 @@
+namespace SharedModels;
+
+public class KafkaEvent
+{
+ public string CorrelationId { get; set; } = Guid.NewGuid().ToString(); // Уникальный ID для Request-Reply
+ public string Action { get; set; } = string.Empty; // CREATE, GET, UPDATE, DELETE
+ public long ArticleId { get; set; } // Ключ для партиционирования
+ public string Payload { get; set; } = string.Empty; // Сами данные (JSON)
+ public string ErrorMessage { get; set; } = string.Empty;
+}
+
+public static class MessageState
+{
+ public const string Pending = "PENDING";
+ public const string Approve = "APPROVE";
+ public const string Decline = "DECLINE";
+}
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/SharedModels/MessagesDtos.cs b/351004/Kuchko/ServerApp/SharedModels/MessagesDtos.cs
new file mode 100644
index 000000000..4d3f306f8
--- /dev/null
+++ b/351004/Kuchko/ServerApp/SharedModels/MessagesDtos.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace SharedModels;
+
+public record MessageRequestTo(
+ [Required] long ArticleId,
+ [Required]
+ [StringLength(2048, MinimumLength = 2)]
+ string Content
+);
+
+// Добавили поле State, как требует ТЗ
+public record MessageResponseTo(
+ long Id,
+ long ArticleId,
+ string Content,
+ string State
+);
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/SharedModels/SharedModels.csproj b/351004/Kuchko/ServerApp/SharedModels/SharedModels.csproj
new file mode 100644
index 000000000..17b910f6d
--- /dev/null
+++ b/351004/Kuchko/ServerApp/SharedModels/SharedModels.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/351004/Kuchko/ServerApp/docker-compose.yml b/351004/Kuchko/ServerApp/docker-compose.yml
new file mode 100644
index 000000000..36571e4ab
--- /dev/null
+++ b/351004/Kuchko/ServerApp/docker-compose.yml
@@ -0,0 +1,105 @@
+services:
+ db:
+ image: postgres:17
+ container_name: postgres_db
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: distcomp
+ ports:
+ - "5432:5432"
+ volumes:
+ - db_data:/var/lib/postgresql
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U postgres" ]
+ interval: 10s
+ timeout: 5s
+ retries: 15
+ start_period: 10s
+
+ cassandra_db:
+ image: cassandra:latest
+ container_name: cassandra_db
+ ports: [ "9042:9042" ]
+ environment:
+ - CASSANDRA_CLUSTER_NAME=DistcompCluster
+ healthcheck:
+ test: [ "CMD-SHELL", "cqlsh -e 'DESCRIBE KEYSPACES'" ]
+ interval: 15s
+
+ publisher_api:
+ build:
+ context: .
+ dockerfile: ServerApp/Dockerfile
+ container_name: publisher_api
+ depends_on:
+ kafka:
+ condition: service_healthy
+ db:
+ condition: service_healthy
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ - ASPNETCORE_URLS=http://+:24110
+ - ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=distcomp;Username=postgres;Password=postgres
+ - DiscussionServiceUrl=http://discussion_api:24130
+ ports:
+ - "24110:24110"
+
+ discussion_api:
+ build:
+ context: .
+ dockerfile: DiscussionApp/Dockerfile
+ container_name: discussion_api
+ depends_on:
+ kafka:
+ condition: service_healthy
+ cassandra_db:
+ condition: service_healthy
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ - ASPNETCORE_URLS=http://+:24130
+ - Cassandra__ContactPoint=cassandra_db
+ ports:
+ - "24130:24130"
+
+ zookeeper:
+ image: confluentinc/cp-zookeeper:7.4.4
+ container_name: zookeeper
+ environment:
+ ZOOKEEPER_CLIENT_PORT: 2181
+ ZOOKEEPER_TICK_TIME: 2000
+ ports:
+ - "2181:2181"
+ healthcheck:
+ test: [ "CMD", "nc", "-z", "localhost", "2181" ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ kafka:
+ # Зафиксирована версия 7.4.4
+ image: confluentinc/cp-kafka:7.4.4
+ container_name: kafka
+ depends_on:
+ zookeeper:
+ condition: service_healthy
+ ports:
+ - "9092:9092"
+ environment:
+ KAFKA_BROKER_ID: 1
+ KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
+ # 29092 для внутреннего Docker, 9092 для тестов с хоста (localhost)
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
+ KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+ # Автосоздание топиков, чтобы не создавать их вручную (ТЗ это разрешает)
+ KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
+ healthcheck:
+ test: [ "CMD", "nc", "-z", "localhost", "9092" ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ db_data:
\ No newline at end of file
diff --git a/351004/Kuchko/ServerApp/global.json b/351004/Kuchko/ServerApp/global.json
new file mode 100644
index 000000000..93681ff86
--- /dev/null
+++ b/351004/Kuchko/ServerApp/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "9.0.0",
+ "rollForward": "latestMinor",
+ "allowPrerelease": false
+ }
+}
\ No newline at end of file