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