first commit

This commit is contained in:
Igor Kozlov
2025-12-21 00:24:31 +03:00
commit b91ca68b4d
1668 changed files with 156887 additions and 0 deletions

63
.gitattributes vendored Normal file
View File

@@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

375
.gitignore vendored Normal file
View File

@@ -0,0 +1,375 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# 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/
[Oo]bj**/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
.vscode/
.amazonq/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
*vite.config.ts.timestamp*.mjs
src/docker-compose.yaml
.windsurfrules
*.env
*.env.*
scripts/*
todo.txt
.github/*

161
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,161 @@
include:
- local: ".gitlab/ci/templates/build-test.yml"
- local: ".gitlab/ci/templates/build-push.yml"
- local: ".gitlab/ci/templates/deploy.yml"
stages:
- build
- test
- deploy
# Джобы для сборки и тестирования при создании MR
monitoring_backend_test:
extends: .build_test_template
variables:
IMAGE_NAME: "monitoring.backend"
MODULE_DIR: "src"
CI_DOCKERFILE: "Monitoring/Monitoring.Api/Dockerfile"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(release|test)$/'
changes:
- "src/Monitoring/Monitoring.Api/**/*"
- "src/Monitoring/Monitoring.Infrastructure/**/*"
- "src/Monitoring/Monitoring.Common/**/*"
- "src/Monitoring/Monitoring.Services/**/*"
- "src/Monitoring/Monitoring.ServiceDefaults/**/*"
when: on_success
- when: never
monitoring_notifications_test:
extends: .build_test_template
variables:
IMAGE_NAME: "monitoring.notifications"
MODULE_DIR: "src"
CI_DOCKERFILE: "Monitoring/Monitoring.Notifications/Dockerfile"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(release|test)$/'
changes:
- "src/Monitoring/Monitoring.Notifications/**/*"
- "src/Monitoring/Monitoring.Infrastructure/**/*"
- "src/Monitoring/Monitoring.Common/**/*"
- "src/Monitoring/Monitoring.Services/**/*"
- "src/Monitoring/Monitoring.ServiceDefaults/**/*"
when: on_success
- when: never
monitoring_frontend_test:
extends: .build_test_template
variables:
IMAGE_NAME: "monitoring.frontend"
MODULE_DIR: "src/Monitoring/Monitoring.Web"
CI_DOCKERFILE: "Dockerfile"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(release|test)$/'
changes:
- "src/Monitoring/Monitoring.Web/**/*"
when: on_success
- when: never
ping_test:
extends: .build_test_template
variables:
IMAGE_NAME: "monitoring.ping"
MODULE_DIR: "src"
CI_DOCKERFILE: "Ping/Dockerfile"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(release|test)$/'
changes:
- "src/Ping/**/*"
- "src/Monitoring/Monitoring.Infrastructure/**/*"
- "src/Monitoring/Monitoring.Common/**/*"
- "src/Monitoring/Monitoring.Services/**/*"
- "src/Monitoring/Monitoring.ServiceDefaults/**/*"
- "src/Monitoring/Monitoring.Notifications.Client/**/*"
when: on_success
- when: never
# Джобы для сборки с пушем в реестр при коммите/слиянии в test/release
monitoring_backend_build:
extends: .build_push_template
variables:
IMAGE_NAME: "monitoring.backend"
MODULE_DIR: "src"
CI_DOCKERFILE: "Monitoring/Monitoring.Api/Dockerfile"
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH =~ /^(release|test)$/'
changes:
- "src/Monitoring/Monitoring.Api/**/*"
- "src/Monitoring/Monitoring.Infrastructure/**/*"
- "src/Monitoring/Monitoring.Common/**/*"
- "src/Monitoring/Monitoring.Services/**/*"
- "src/Monitoring/Monitoring.ServiceDefaults/**/*"
when: on_success
- when: never
monitoring_notifications_build:
extends: .build_push_template
variables:
IMAGE_NAME: "monitoring.notifications"
MODULE_DIR: "src"
CI_DOCKERFILE: "Monitoring/Monitoring.Notifications/Dockerfile"
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH =~ /^(release|test)$/'
changes:
- "src/Monitoring/Monitoring.Notifications/**/*"
- "src/Monitoring/Monitoring.Infrastructure/**/*"
- "src/Monitoring/Monitoring.Common/**/*"
- "src/Monitoring/Monitoring.Services/**/*"
- "src/Monitoring/Monitoring.ServiceDefaults/**/*"
when: on_success
- when: never
monitoring_frontend_build:
extends: .build_push_template
variables:
IMAGE_NAME: "monitoring.frontend"
MODULE_DIR: "src/Monitoring/Monitoring.Web"
CI_DOCKERFILE: "Dockerfile"
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH =~ /^(release|test)$/'
changes:
- "src/Monitoring/Monitoring.Web/**/*"
when: on_success
- when: never
ping_build:
extends: .build_push_template
variables:
IMAGE_NAME: "monitoring.ping"
MODULE_DIR: "src"
CI_DOCKERFILE: "Ping/Dockerfile"
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH =~ /^(release|test)$/'
changes:
- "src/Ping/**/*"
- "src/Monitoring/Monitoring.Infrastructure/**/*"
- "src/Monitoring/Monitoring.Common/**/*"
- "src/Monitoring/Monitoring.Services/**/*"
- "src/Monitoring/Monitoring.ServiceDefaults/**/*"
- "src/Monitoring/Monitoring.Notifications.Client/**/*"
when: on_success
- when: never
monitoring_backend_deploy:
extends: .deploy_template
variables:
IMAGE_NAME: "monitoring.backend"
monitoring_notifications_deploy:
extends: .deploy_template
variables:
IMAGE_NAME: "monitoring.notifications"
monitoring_frontend_deploy:
extends: .deploy_template
variables:
IMAGE_NAME: "monitoring.frontend"
ping_deploy:
extends: .deploy_template
variables:
IMAGE_NAME: "monitoring.ping"

17
AGENTS.md Normal file
View File

@@ -0,0 +1,17 @@
# Правила для Агентов
## Общие требования
- Отвечай на русском языке и кратко.
- Не нужно создавать отчеты после выполнения задач. (.md)
- Давай краткий ответ о проделанной работе.
- Используй лучшие практики, чистоту кода, правильную архитектуру, SOLID, DRY, KISS, YAGNI, DI.
- Не создавай миграции вручную, а используй dotnet ef.
- Используй текущий существующий стиль кодирования.
## Monitoring.Web - фронтенд
### Требования
- При изменении бекенда, используй orval для генерации типов и запросов.
- Используй i18n для перевода текстов.

156
README.md Normal file
View File

@@ -0,0 +1,156 @@
# Развертывание в Docker Swarm
## Требования
- Docker Engine версии 20.10.0 или выше
- Docker Swarm инициализированный на целевых серверах
- Доступ к Docker registry
## Образы приложения
В системе используются следующие контейнеры:
- `76457942/monitoring.backend` - бэкенд мониторинга
- `76457942/monitoring.frontend` - фронтенд мониторинга
- `76457942/forwarder.backend` - бэкенд форвардера
- `76457942/forwarder.frontend` - фронтенд форвардера
## Подготовка к развертыванию
1. Убедитесь, что Docker Swarm инициализирован:
```bash
docker swarm init
```
2. Присоединение рабочих узлов (при необходимости):
```bash
docker swarm join --token <token> <manager-ip>:2377
```
## Развертывание приложения
1. Создайте docker-compose.yml файл:
```yaml
version: "3.8"
services:
monitoring-backend:
image: 76457942/monitoring.backend
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
ports:
- "5000:80"
networks:
- monitoring-network
monitoring-frontend:
image: 76457942/monitoring.frontend
deploy:
replicas: 2
ports:
- "80:80"
networks:
- monitoring-network
forwarder-backend:
image: 76457942/forwarder.backend
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
ports:
- "5001:80"
networks:
- monitoring-network
forwarder-frontend:
image: 76457942/forwarder.frontend
deploy:
replicas: 2
ports:
- "81:80"
networks:
- monitoring-network
networks:
monitoring-network:
driver: overlay
```
2. Разверните стек:
```bash
docker stack deploy -c docker-compose.yml monitoring-stack
```
## Управление развернутым приложением
### Просмотр статуса сервисов
```bash
docker service ls
```
### Масштабирование сервиса
```bash
docker service scale monitoring-stack_monitoring-backend=3
```
### Обновление сервиса
```bash
docker service update --image 76457942/monitoring.backend:new-version monitoring-stack_monitoring-backend
```
### Просмотр логов
```bash
docker service logs monitoring-stack_monitoring-backend
```
## Удаление развернутого приложения
```bash
docker stack rm monitoring-stack
```
## Мониторинг
После развертывания сервисы доступны по следующим адресам:
- Мониторинг Frontend: http://localhost:80
- Мониторинг Backend: http://localhost:5000
- Форвардер Frontend: http://localhost:81
- Форвардер Backend: http://localhost:5001
## Устранение неполадок
1. Проверка статуса узлов:
```bash
docker node ls
```
2. Проверка задач сервиса:
```bash
docker service ps monitoring-stack_monitoring-backend
```
3. Проверка сетей:
```bash
docker network ls
```

View File

@@ -0,0 +1,74 @@
trigger:
branches:
include:
- test
- release
paths:
include:
- src/Monitoring/Monitoring.Web/*
variables:
- ${{ if eq(variables['Build.SourceBranchName'], 'test') }}:
- group: Test
- name: imageTag
value: "test"
- name: deployEnvironment
value: "Test"
- name: agentPool
value: "Fire Service Pool Agent"
- ${{ if eq(variables['Build.SourceBranchName'], 'release') }}:
- group: Prod
- name: imageTag
value: "release"
- name: deployEnvironment
value: "Prod"
- name: agentPool
value: "IBTests"
- name: versionTag
value: "1.0.$(Build.BuildId)"
stages:
- stage: Build
displayName: "Build frontend"
pool:
name: $(agentPool)
jobs:
- job: Build
displayName: "Build and push Docker images"
steps:
- task: Docker@2
displayName: "Build and push frontend image"
inputs:
command: "buildAndPush"
Dockerfile: "src/Monitoring/Monitoring.Web/Dockerfile"
repository: "$(REGISTRY_USERNAME)/$(MONITORING_FRONTEND_IMAGE_NAME)"
tags: |
$(imageTag)
$(versionTag)
buildContext: "src/Monitoring/Monitoring.Web"
containerRegistry: $(REGISTRY_NAME)
- stage: Deploy
displayName: "Deploy"
dependsOn:
- Build
pool:
name: $(agentPool)
jobs:
- deployment: Deploy
displayName: "Deploy"
environment: $(deployEnvironment)
strategy:
runOnce:
deploy:
steps:
- script: |
# Pull latest images
docker pull $(REGISTRY_ADDRESS)/$(REGISTRY_USERNAME)/$(MONITORING_FRONTEND_IMAGE_NAME):$(imageTag)
# Update services with new images
docker service update \
--image $(REGISTRY_ADDRESS)/$(REGISTRY_USERNAME)/$(MONITORING_FRONTEND_IMAGE_NAME):$(imageTag) \
--force \
$(MONITORING_FRONTEND_SERVICE_NAME)
displayName: "Update Docker services"

33
src/.dockerignore Normal file
View File

@@ -0,0 +1,33 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/.continue
**/.windsurf
**/design
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
!.git/refs/heads/**

289
src/.editorconfig Normal file
View File

@@ -0,0 +1,289 @@
[*]
indent_size = 4
indent_style = tab
[*.yaml]
indent_size = 2
indent_style = space
# Файлы C#
[*.cs]
#### Основные параметры EditorConfig ####
# Отступы и интервалы
indent_size = 4
indent_style = tab
tab_width = 4
# Предпочтения для новых строк
end_of_line = crlf
insert_final_newline = false
#### Рекомендации по написанию кода .NET ####
# Упорядочение Using
dotnet_separate_import_directive_groups = true
dotnet_sort_system_directives_first = true
file_header_template = unset
# Предпочтения для this. и Me.
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Параметры использования ключевых слов языка и типов BCL
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Предпочтения для скобок
dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Предпочтения модификатора
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Выражения уровень предпочтения
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true
dotnet_style_prefer_collection_expression = when_types_loosely_match
dotnet_style_prefer_compound_assignment = true
dotnet_style_prefer_conditional_expression_over_assignment = true
dotnet_style_prefer_conditional_expression_over_return = true
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Предпочтения для полей
dotnet_style_readonly_field = true
# Настройки параметров
dotnet_code_quality_unused_parameters = all
# Параметры подавления
dotnet_remove_unnecessary_suppression_exclusions = none
# Предпочтения для новых строк
dotnet_style_allow_multiple_blank_lines_experimental = false
dotnet_style_allow_statement_immediately_after_block_experimental = false
#### Рекомендации по написанию кода C# ####
# Предпочтения var
csharp_style_var_elsewhere = true:warning
csharp_style_var_for_built_in_types = true:warning
csharp_style_var_when_type_is_apparent = true:warning
# Члены, заданные выражениями
csharp_style_expression_bodied_accessors = true
csharp_style_expression_bodied_constructors = false
csharp_style_expression_bodied_indexers = true
csharp_style_expression_bodied_lambdas = true
csharp_style_expression_bodied_local_functions = false
csharp_style_expression_bodied_methods = false
csharp_style_expression_bodied_operators = false
csharp_style_expression_bodied_properties = true
# Настройки соответствия шаблонов
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_extended_property_pattern = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_pattern_matching = true:silent
csharp_style_prefer_switch_expression = true:suggestion
# Настройки проверки на null
csharp_style_conditional_delegate_call = true:suggestion
# Предпочтения модификатора
csharp_prefer_static_anonymous_function = true
csharp_prefer_static_local_function = true
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
csharp_style_prefer_readonly_struct = true
csharp_style_prefer_readonly_struct_member = true
# Предпочтения для блоков кода
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_prefer_top_level_statements = true:silent
# Выражения уровень предпочтения
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# предпочтения для директивы using
csharp_using_directive_placement = outside_namespace:silent
# Предпочтения для новых строк
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false
csharp_style_allow_embedded_statements_on_same_line_experimental = false:silent
#### Правила форматирования C# ####
# Предпочтения для новых строк
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Предпочтения для отступов
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = false
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Предпочтения для интервалов
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Предпочтения переноса
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = false
#### Стили именования ####
# Правила именования
dotnet_naming_rule.interface_should_be_begins_with_i.severity = error
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = error
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = error
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.private_or_internal_static_field_should_be_pascal_case.severity = error
dotnet_naming_rule.private_or_internal_static_field_should_be_pascal_case.symbols = private_or_internal_static_field
dotnet_naming_rule.private_or_internal_static_field_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.private_or_internal_field_should_be___start.severity = error
dotnet_naming_rule.private_or_internal_field_should_be___start.symbols = private_or_internal_field
dotnet_naming_rule.private_or_internal_field_should_be___start.style = __start
# Спецификации символов
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected
dotnet_naming_symbols.private_or_internal_field.required_modifiers =
dotnet_naming_symbols.private_or_internal_static_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_static_field.applicable_accessibilities = internal, private, private_protected
dotnet_naming_symbols.private_or_internal_static_field.required_modifiers = static
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Стили именования
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.__start.required_prefix = _
dotnet_naming_style.__start.required_suffix =
dotnet_naming_style.__start.word_separator =
dotnet_naming_style.__start.capitalization = camel_case
[*.{cs,vb}]
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = false:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = false:silent
dotnet_style_prefer_conditional_expression_over_return = false:silent
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_style_namespace_match_folder = true:suggestion
end_of_line = crlf
dotnet_code_quality_unused_parameters = all:suggestion
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_predefined_type_for_member_access = true:silent
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_property = false:silent
dotnet_style_qualification_for_method = false:silent
dotnet_style_qualification_for_event = false:silent
dotnet_style_allow_multiple_blank_lines_experimental = false:silent
dotnet_style_allow_statement_immediately_after_block_experimental = false:silent

1
src/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
azure-pipelines.yml merge=ours

365
src/.gitignore vendored Normal file
View File

@@ -0,0 +1,365 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# 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/
# Visual Studio 2015/2017 cache/options directory
.vs/
.vscode/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
mover.stack.env
stack.env

13
src/.idea/.idea.Monitoring/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,13 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/.idea.Monitoring.iml
/modules.xml
/contentModel.xml
/projectSettingsUpdater.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

1
src/.idea/.idea.Monitoring/.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
Monitoring

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders>
<Path>../../monitoringall</Path>
</attachedFolders>
<explicitIncludes />
<explicitExcludes />
</component>
</project>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

3
src/.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"singleQuote": false
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,6 @@
namespace BolidIntegrator;
public class Class1
{
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Elastic.Serilog.Sinks" Version="9.*" />
<PackageReference Include="FastEndpoints" Version="7.*" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,23 @@
using FastEndpoints;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace Common.Api;
public static class DependencyInjectionExtensions
{
public static IServiceCollection AddEndpoints(this IServiceCollection services)
{
_ = services.AddFastEndpoints();
return services;
}
public static IApplicationBuilder UseEndpoints(this IApplicationBuilder app)
{
_ = app.UseFastEndpoints();
return app;
}
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.*" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Common.Postgres;
public static class DependencyInjectionExtensions
{
public static IServiceCollection AddPostgres<T>(
this IServiceCollection services,
string connectionString)
where T : DbContext
{
_ = services.AddDbContext<T>(options =>
{
_ = options.UseNpgsql(connectionString)
.LogTo(Console.WriteLine, LogLevel.Warning)
.EnableSensitiveDataLogging(false);
});
return services;
}
}

View File

@@ -0,0 +1,6 @@
namespace Common.Endpoints;
public class Class1
{
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using Forwarder.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Forwarder.Data.Configurations;
/// <summary>
/// Конфигурация сообщений.
/// </summary>
public class AssetMapConfiguration : IEntityTypeConfiguration<AssetMap>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<AssetMap> builder)
{
_ = builder
.HasIndex(x => x.Abonent).IsUnique();
}
}

View File

@@ -0,0 +1,24 @@
using Forwarder.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Forwarder.Data.Configurations;
/// <summary>
/// Конфигурация сообщений.
/// </summary>
public class MessageConfiguration : IEntityTypeConfiguration<Message>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<Message> builder)
{
_ = builder
.Property(x => x.Abonent)
.IsRequired()
.HasMaxLength(32);
_ = builder
.HasIndex(x => x.CreatedAt);
}
}

View File

@@ -0,0 +1,23 @@
using Forwarder.Data.Configurations;
using Forwarder.Domain;
using Microsoft.EntityFrameworkCore;
namespace Forwarder.Data;
/// <inheritdoc />
public class DataContext(DbContextOptions<DataContext> options)
: DbContext(options), IDataContext
{
/// <inheritdoc />
public required DbSet<AssetMap> Maps { get; set; }
/// <inheritdoc />
public required DbSet<Message> Messages { get; set; }
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
_ = modelBuilder.ApplyConfiguration(new AssetMapConfiguration());
_ = modelBuilder.ApplyConfiguration(new MessageConfiguration());
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.*" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Forwarder.Domain\Forwarder.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
using Forwarder.Domain;
using Microsoft.EntityFrameworkCore;
namespace Forwarder.Data;
/// <summary>
/// Интерфейс контекста данных.
/// </summary>
public interface IDataContext
{
/// <summary>
/// Маппинг идентификаторов объектов.
/// </summary>
DbSet<AssetMap> Maps { get; }
/// <summary>
/// Сообщения.
/// </summary>
DbSet<Message> Messages { get; }
/// <summary>
/// Сохранение изменений.
/// </summary>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Число записанных с базу данных изменений.</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,90 @@
// <auto-generated />
using System;
using Forwarder.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Forwarder.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20240803210721_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Forwarder.Domain.AssetMap", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Abonent")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Abonent")
.IsUnique();
b.ToTable("Maps");
});
modelBuilder.Entity("Forwarder.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Abonent")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("Code")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Line")
.HasColumnType("integer");
b.Property<int>("Receiver")
.HasColumnType("integer");
b.Property<int>("Sector")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<int>("Zone")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Forwarder.Data.Migrations;
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
_ = migrationBuilder.CreateTable(
name: "Maps",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Abonent = table.Column<string>(type: "text", nullable: false),
AssetId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
_ = table.PrimaryKey("PK_Maps", x => x.Id);
});
_ = migrationBuilder.CreateTable(
name: "Messages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Receiver = table.Column<int>(type: "integer", nullable: false),
Line = table.Column<int>(type: "integer", nullable: false),
Abonent = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Code = table.Column<int>(type: "integer", nullable: false),
Sector = table.Column<int>(type: "integer", nullable: false),
Zone = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
_ = table.PrimaryKey("PK_Messages", x => x.Id);
});
_ = migrationBuilder.CreateIndex(
name: "IX_Maps_Abonent",
table: "Maps",
column: "Abonent",
unique: true);
_ = migrationBuilder.CreateIndex(
name: "IX_Messages_CreatedAt",
table: "Messages",
column: "CreatedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
_ = migrationBuilder.DropTable(
name: "Maps");
_ = migrationBuilder.DropTable(
name: "Messages");
}
}

View File

@@ -0,0 +1,93 @@
// <auto-generated />
using System;
using Forwarder.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Forwarder.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20250620221220_AddedIsOnlineToMaps")]
partial class AddedIsOnlineToMaps
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Forwarder.Domain.AssetMap", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Abonent")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<bool?>("IsOnline")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("Abonent")
.IsUnique();
b.ToTable("Maps");
});
modelBuilder.Entity("Forwarder.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Abonent")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("Code")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Line")
.HasColumnType("integer");
b.Property<int>("Receiver")
.HasColumnType("integer");
b.Property<int>("Sector")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<int>("Zone")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Forwarder.Data.Migrations
{
/// <inheritdoc />
public partial class AddedIsOnlineToMaps : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsOnline",
table: "Maps",
type: "boolean",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsOnline",
table: "Maps");
}
}
}

View File

@@ -0,0 +1,90 @@
// <auto-generated />
using System;
using Forwarder.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Forwarder.Data.Migrations
{
[DbContext(typeof(DataContext))]
partial class DataContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Forwarder.Domain.AssetMap", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Abonent")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<bool?>("IsOnline")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("Abonent")
.IsUnique();
b.ToTable("Maps");
});
modelBuilder.Entity("Forwarder.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Abonent")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("Code")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Line")
.HasColumnType("integer");
b.Property<int>("Receiver")
.HasColumnType("integer");
b.Property<int>("Sector")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<int>("Zone")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,27 @@
namespace Forwarder.Domain;
/// <summary>
/// Маппинг внешних идентификаторов объектов на внутренние
/// </summary>
public class AssetMap
{
/// <summary>
/// Идентификатор
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Внешний идентификатор
/// </summary>
public string Abonent { get; set; } = string.Empty;
/// <summary>
/// Внутренний идентификатор
/// </summary>
public Guid? AssetId { get; set; }
/// <summary>
/// Статус подключения объекта к серверу
/// </summary>
public bool? IsOnline { get; set; }
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,16 @@
namespace Forwarder.Domain;
/// <summary>
/// Сообщение
/// </summary>
public record Message(
Guid Id,
int Receiver,
int Line,
string Abonent,
MessageType Type,
int Code,
int Sector,
int Zone,
DateTime CreatedAt
);

View File

@@ -0,0 +1,27 @@
namespace Forwarder.Domain;
/// <summary>
/// Тип сообщения
/// </summary>
public enum MessageType
{
/// <summary>
/// Неизвестное
/// </summary>
Unknown,
/// <summary>
/// Тревога или снятие с охраны
/// </summary>
AlarmDisarming,
/// <summary>
/// Восстановление или взятие на охрану
/// </summary>
RecoveryArming,
/// <summary>
/// Тестовое сообщение
/// </summary>
StatePing,
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="MSTest.Sdk/3.6.4">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseVSTest>true</UseVSTest>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1 @@
[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]

View File

@@ -0,0 +1,46 @@
namespace Forwarder.Tests;
[TestClass]
public sealed class Test1
{
[AssemblyInitialize]
public static void AssemblyInit(TestContext context)
{
// This method is called once for the test assembly, before any tests are run.
}
[AssemblyCleanup]
public static void AssemblyCleanup()
{
// This method is called once for the test assembly, after all tests are run.
}
[ClassInitialize]
public static void ClassInit(TestContext context)
{
// This method is called once for the test class, before any tests of the class are run.
}
[ClassCleanup]
public static void ClassCleanup()
{
// This method is called once for the test class, after all tests of the class are run.
}
[TestInitialize]
public void TestInit()
{
// This method is called before each test method.
}
[TestCleanup]
public void TestCleanup()
{
// This method is called after each test method.
}
[TestMethod]
public void TestMethod1()
{
}
}

View File

@@ -0,0 +1,14 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY . .
RUN dotnet restore "Forwarder/Forwarder/Forwarder.csproj" && \
dotnet publish "Forwarder/Forwarder/Forwarder.csproj" -c $BUILD_CONFIGURATION -o /app/publish --no-restore
# Финальная стадия
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Forwarder.dll"]

View File

@@ -0,0 +1,10 @@
namespace Forwarder.Exceptions;
/// <summary>
/// Исключение происходит при отсутствии переменной окружения
/// </summary>
/// <param name="name">Имя переменной окружения</param>
public class EnvironmentVariableNotFoundException(string name)
: Exception($"Environment variable {name} not found")
{
}

View File

@@ -0,0 +1,9 @@
namespace Forwarder.Exceptions;
/// <summary>
/// Исключение, требующее пересоздания
/// </summary>
public class NeedRecreateException()
: Exception("Требуется пересоздание")
{
}

View File

@@ -0,0 +1,119 @@
using Forwarder.Features.AssetMapManagement.CreateAssetMap;
using Forwarder.Repositories;
using Microsoft.AspNetCore.Mvc;
namespace Forwarder.Features.AssetMapManagement;
/// <summary>
/// Контроллер для работы с маппингами.
/// </summary>
[Route("api/asset-maps")]
[ApiController]
public class AssetMapsController(
IAssetMapRepository assetMapRepository
) : ControllerBase
{
/// <summary>
/// Получает список маппингов.
/// </summary>
/// <param name="request">Запрос</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Список маппингов.</returns>
[HttpGet]
public async Task<IActionResult> GetList(
[FromQuery] GetMapListRequest request,
CancellationToken cancellationToken
)
{
var query = assetMapRepository
.GetQuery();
if (request.UseOnlyEmptyAssetId == true)
{
query = query
.Where(x => !x.AssetId.HasValue);
}
var result = await assetMapRepository
.GetTable(query, request, cancellationToken);
if (result.IsFail)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
/// <summary>
/// Получает маппинг по идентификатору.
/// </summary>
/// <param name="id">Идентификатор.</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Маппинг.</returns>
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id, CancellationToken cancellationToken)
{
var result = await assetMapRepository.Get(id, cancellationToken);
if (result.IsFail)
{
return BadRequest(result.Error);
}
return Ok(result.Value);
}
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateAssetMapRequest request,
CancellationToken cancellationToken)
{
var result = await assetMapRepository.AddAsync(request.Abonent, request.AssetId, cancellationToken);
if (result.IsFail)
{
return BadRequest(result.Error);
}
return Created($"/api/asset-maps/{result.Value.Id}", result.Value);
}
/// <summary>
/// Обновляет маппинг.
/// </summary>
/// <param name="id">Идентификатор.</param>
/// <param name="request">Запрос.</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns></returns>
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id,
[FromBody] UpdateAssetMapRequest request,
CancellationToken cancellationToken)
{
var result = await assetMapRepository.UpdateAsync(id, request.AssetId, cancellationToken);
if (result.IsFail)
{
return BadRequest(result.Error);
}
return NoContent();
}
/// <summary>
/// Удаляет маппинг.
/// </summary>
/// <param name="id">Идентификатор.</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns></returns>
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id,
CancellationToken cancellationToken)
{
var result = await assetMapRepository.DeleteAsync(id, cancellationToken);
if (result.IsFail)
{
return BadRequest(result.Error);
}
return NoContent();
}
}

View File

@@ -0,0 +1,11 @@
namespace Forwarder.Features.AssetMapManagement.CreateAssetMap;
/// <summary>
/// Запрос на создание маппинга между объектом и абонентом.
/// </summary>
/// <param name="Abonent">Абонент.</param>
/// <param name="AssetId">Идентификатор объекта.</param>
public record CreateAssetMapRequest(
string Abonent,
Guid? AssetId
);

View File

@@ -0,0 +1,9 @@
namespace Forwarder.Features.AssetMapManagement.CreateAssetMap;
/// <summary>
/// Ответ на создание маппинга.
/// </summary>
/// <param name="Id">Идентификатор маппинга.</param>
public record CreateAssetMapResponse(
Guid Id
);

View File

@@ -0,0 +1,17 @@
using FluentValidation;
namespace Forwarder.Features.AssetMapManagement.CreateAssetMap;
public class CreateAssetMapValidator : AbstractValidator<CreateAssetMapRequest>
{
public CreateAssetMapValidator()
{
RuleFor(x => x.Abonent)
.NotNull()
.NotEmpty()
.Matches(@"^\d{8}$").WithMessage("Abonent must be 8 digits.");
RuleFor(x => x.AssetId)
.NotEmpty().When(x => x.AssetId.HasValue);
}
}

View File

@@ -0,0 +1,15 @@
using Forwarder.Models;
namespace Forwarder.Features.AssetMapManagement;
/// <summary>
/// Запрос для получения списка маппингов.
/// </summary>
/// <param name="Skip">Смещение.</param>
/// <param name="Take">Кол-во.</param>
/// <param name="UseOnlyEmptyAssetId">Выбрать только маппинги с пустым assetId.</param>
public record GetMapListRequest(
int? Skip,
int? Take,
bool? UseOnlyEmptyAssetId
) : ITableStateRequest;

View File

@@ -0,0 +1,9 @@
namespace Forwarder.Features.AssetMapManagement;
/// <summary>
/// Запрос для обновления маппинга.
/// </summary>
/// <param name="AssetId">Идентификатор объекта.</param>
public record UpdateAssetMapRequest(
Guid? AssetId
);

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.5.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.*" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.*" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.*" />
<PackageReference Include="NSwag.MSBuild" Version="14.4.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Polly" Version="8.6.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.4.13" />
<PackageReference Include="Serilog.AspNetCore" Version="9.*" />
<PackageReference Include="Serilog.Enrichers.CallerInfo" Version="1.0.6" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.11.0.117924">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.7" />
<PackageReference Include="Quartz.AspNetCore" Version="3.15.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common.Api\Common.Api.csproj" />
<ProjectReference Include="..\..\Common.Postgres\Common.Postgres.csproj" />
<ProjectReference Include="..\..\Forwarder.Data\Forwarder.Data.csproj" />
<ProjectReference Include="..\..\Monitoring\Monitoring.Common\Monitoring.Common.csproj" />
<ProjectReference Include="..\..\Monitoring\Monitoring.Contracts\Monitoring.Contracts.csproj" />
<ProjectReference Include="..\..\Monitoring\Monitoring.ServiceDefaults\Monitoring.ServiceDefaults.csproj" />
<ProjectReference Include="..\..\Telegram\TelegramSender\TelegramSender.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,17 @@
namespace Forwarder;
/// <summary>
/// Клиенты HTTP.
/// </summary>
public static class HttpClients
{
/// <summary>
/// Клиент API.
/// </summary>
public const string Api = "api";
/// <summary>
/// Клиент Geo RITM API.
/// </summary>
public const string GeoRitm = "geo-ritm";
}

View File

@@ -0,0 +1,31 @@
namespace Forwarder.Models;
/// <summary>
/// Настройки передатчика.
/// </summary>
public sealed class ForwarderOptions
{
[ConfigurationKeyName("TCP_PORT")]
public ushort TcpPort { get; init; }
[ConfigurationKeyName("UDP_PORT")]
public ushort UdpPort { get; init; }
[ConfigurationKeyName("MESSAGE_BULK_SIZE")]
public int MessageBulkSize { get; init; }
[ConfigurationKeyName("API_TOKEN")]
public string ApiToken { get; init; } = string.Empty;
[ConfigurationKeyName("API_URL")]
public string ApiUrl { get; init; } = string.Empty;
[ConfigurationKeyName("SENDING_INTERVAL_IN_SECONDS")]
public int SendingIntervalInSeconds { get; init; }
[ConfigurationKeyName("ENABLE_TEST_MESSAGE_SENDING")]
public bool EnableTestMessageSending { get; init; } = true;
[ConfigurationKeyName("TEST_MESSAGE_EVENT_CODE")]
public int TestMessageEventCode { get; init; } = 6011;
}

View File

@@ -0,0 +1,25 @@
namespace Forwarder.Models;
/// <summary>
/// Настройки подключения к geo-ritm.
/// </summary>
public sealed class GeoRitmConnectionOptions
{
/// <summary>
/// URL для подключения к geo-ritm.
/// </summary>
[ConfigurationKeyName("GEO_RITM_API_URL")]
public string Url { get; init; }
/// <summary>
/// Имя пользователя для подключения к geo-ritm.
/// </summary>
[ConfigurationKeyName("GEO_RITM_USERNAME")]
public string Username { get; init; }
/// <summary>
/// Пароль для подключения к geo-ritm.
/// </summary>
[ConfigurationKeyName("GEO_RITM_PASSWORD")]
public string Password { get; init; }
}

View File

@@ -0,0 +1,134 @@
namespace Forwarder.Models;
/// <summary>
/// Модель объекта.
/// </summary>
public class GetObjectListResponse
{
/// <summary>
/// Страна, в которой находится объект
/// </summary>
public string Country { get; set; } = string.Empty;
/// <summary>
/// Ревизия объекта (может быть null)
/// </summary>
public object? Rev { get; set; } = null;
/// <summary>
/// Состояние объекта
/// </summary>
public ObjectState ObjectState { get; set; } = new();
/// <summary>
/// Флаг, указывающий, что объект из IDP
/// </summary>
public bool IsFromIdp { get; set; } = false;
/// <summary>
/// Список устройств объекта
/// </summary>
public List<Device> Devices { get; set; } = [];
/// <summary>
/// Город, в котором находится объект
/// </summary>
public string City { get; set; } = string.Empty;
/// <summary>
/// Флаг онлайн-статуса GSM
/// </summary>
public bool IsGsmOnline { get; set; } = false;
/// <summary>
/// Долгота местоположения объекта
/// </summary>
public double Lon { get; set; } = 0.0;
/// <summary>
/// Статус объекта
/// </summary>
public int ObjStatus { get; set; } = 0;
/// <summary>
/// Наименование объекта
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// IMEI устройства
/// </summary>
public string Imei { get; set; } = string.Empty;
/// <summary>
/// Краткий адрес объекта
/// </summary>
public string AddressShort { get; set; } = string.Empty;
/// <summary>
/// Идентификатор объекта
/// </summary>
public int Id { get; set; } = 0;
/// <summary>
/// Внешний идентификатор объекта
/// </summary>
public int ExtId { get; set; } = 0;
/// <summary>
/// Регион, в котором находится объект
/// </summary>
public string Region { get; set; } = string.Empty;
/// <summary>
/// Тип объекта
/// </summary>
public string ObjType { get; set; } = string.Empty;
/// <summary>
/// Широта местоположения объекта
/// </summary>
public double Lat { get; set; } = 0.0;
/// <summary>
/// Флаг онлайн-статуса
/// </summary>
public bool IsOnline => ObjectState.IsOnline == 1;
}
/// <summary>
/// Состояние объекта.
/// </summary>
public class ObjectState
{
/// <summary>
/// Флаг наличия неисправности (0 - нет, 1 - есть)
/// </summary>
public int HasFault { get; set; } = 0;
/// <summary>
/// Флаг онлайн-статуса (0 - оффлайн, 1 - онлайн)
/// </summary>
public int IsOnline { get; set; } = 0;
/// <summary>
/// Флаг охраны (0 - не охраняется, 1 - охраняется)
/// </summary>
public int IsGuarded { get; set; } = 0;
/// <summary>
/// Флаг тревоги (0 - нет тревоги, 1 - тревога)
/// </summary>
public int HasAlarm { get; set; } = 0;
}
/// <summary>
/// Устройство объекта.
/// </summary>
public class Device
{
/// <summary>
/// IMEI номер устройства
/// </summary>
public string Imei { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,18 @@
namespace Forwarder.Models;
/// <summary>
/// Табличные данные.
/// </summary>
/// <typeparam name="T">Тип данных.</typeparam>
public interface ITableStateResponse<out T>
{
/// <summary>
/// Список элементов.
/// </summary>
IEnumerable<T> Items { get; }
/// <summary>
/// Общее кол-во элементов.
/// </summary>
int Total { get; }
}

View File

@@ -0,0 +1,151 @@
namespace Forwarder.Models;
/// <summary>
/// Результат выполнения операции.
/// </summary>
public readonly struct Result
{
/// <summary>
/// Выполнена ли операция неуспешно.
/// </summary>
public bool IsFail { get; }
/// <summary>
/// Выполнена ли операция успешно.
/// </summary>
public bool IsSuccess => !IsFail;
/// <summary>
/// Ошибка.
/// </summary>
public string Error { get; } = string.Empty;
/// <summary>
/// Создает результат с ошибкой.
/// </summary>
/// <param name="error">Ошибка.</param>
/// <returns>Результат.</returns>
public static Result Fail(string error)
{
return new Result(error);
}
/// <summary>
/// Создает успешный результат.
/// </summary>
public static Result Success()
{
return new Result();
}
/// <summary>
/// Создает неуспешный результат с ошибкой.
/// </summary>
/// <param name="error">Ошибка.</param>
public Result(string error)
{
Error = error;
IsFail = true;
}
/// <summary>
/// Создает успешный результат.
/// </summary>
public Result()
{
IsFail = false;
}
/// <summary>
/// Создает неуспешный результат с ошибкой.
/// </summary>
/// <param name="error">Ошибка.</param>
public static implicit operator Result(string error)
{
return Fail(error);
}
}
/// <summary>
/// Результат выполнения операции с данными.
/// </summary>
/// <typeparam name="T">Тип данных.</typeparam>
public readonly struct Result<T>
{
/// <summary>
/// Выполнена ли операция неуспешно.
/// </summary>
public bool IsFail { get; }
/// <summary>
/// Выполнена ли операция успешно.
/// </summary>
public bool IsSuccess => !IsFail;
/// <summary>
/// Ошибка.
/// </summary>
public string Error { get; } = string.Empty;
/// <summary>
/// Создает результат с ошибкой.
/// </summary>
/// <param name="error">Ошибка.</param>
/// <returns>Результат.</returns>
public static Result<T> Fail(string error)
{
return new Result<T>(error);
}
/// <summary>
/// Значение.
/// </summary>
public T Value { get; } = default!;
/// <summary>
/// Создает неуспешный результат с ошибкой.
/// </summary>
/// <param name="error">Ошибка.</param>
public Result(string error)
{
Error = error;
IsFail = true;
}
/// <summary>
/// Создает успешный результат.
/// </summary>
/// <param name="value">Значение.</param>
private Result(T value)
{
Value = value;
}
/// <summary>
/// Создает успешный результат.
/// </summary>
/// <param name="value">Значение.</param>
/// <returns>Результат.</returns>
public static Result<T> Success(T value)
{
return new Result<T>(value);
}
/// <summary>
/// Создает неуспешный результат с ошибкой.
/// </summary>
/// <param name="error">Ошибка.</param>
public static implicit operator Result<T>(string error)
{
return Fail(error);
}
/// <summary>
/// Создает успешный результат.
/// </summary>
/// <param name="value">Ошибка.</param>
public static implicit operator Result<T>(T value)
{
return Success(value);
}
}

View File

@@ -0,0 +1,17 @@
namespace Forwarder.Models;
/// <summary>
/// Запрос табличных данных.
/// </summary>
public interface ITableStateRequest
{
/// <summary>
/// Кол-во записей для отображения.
/// </summary>
int? Take { get; }
/// <summary>
/// Кол-во записей для пропуска.
/// </summary>
int? Skip { get; }
}

View File

@@ -0,0 +1,6 @@
namespace Forwarder.Models;
public record TableStateResponse<T>(
IEnumerable<T> Items,
int Total
) : ITableStateResponse<T>;

View File

@@ -0,0 +1,16 @@
namespace Forwarder.Models;
/// <summary>
/// Настройки Telegram.
/// </summary>
public class TelegramSendOptions
{
[ConfigurationKeyName("TELEGRAM_BOT_TOKEN")]
public string Token { get; set; } = string.Empty;
[ConfigurationKeyName("TELEGRAM_BATCH_SIZE")]
public uint BatchSize { get; set; } = 100;
[ConfigurationKeyName("TELEGRAM_CHAT_ID")]
public string ChatId { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,75 @@
using System.Globalization;
using System.Text.RegularExpressions;
using CSharpFunctionalExtensions;
using Forwarder.Domain;
using Serilog;
using Serilog.Filters;
namespace Forwarder.Modules.Ademco;
internal partial class Ademco685MessageParser
{
public record Output(bool IsTest, Message? Message);
private readonly Regex _messageTemplate = RealMessagePattern();
private readonly Regex _testTemplate = TestMessagePattern();
public Result<Output> Parse(string input)
{
// [LF]RLsAAAAs18sQXYZsGGsUCCC[CR]
if (_testTemplate.IsMatch(input))
{
return Result.Success(new Output(true, null));
}
var matching = _messageTemplate.Match(input);
if (!matching.Success)
{
return Result.Failure<Output>("Not matched");
}
return Result.Success(
new Output(
false,
new Message(
Guid.CreateVersion7(),
int.Parse(matching.Groups["receiver"].Value),
int.Parse(matching.Groups["line"].Value),
ConvertAbonent(matching.Groups["abonent"].Value),
//$"1{matching.Groups["abonent"].Value:D15}",
GetMessageType(matching.Groups["type"].Value),
int.Parse(matching.Groups["code"].Value),
int.Parse(matching.Groups["area"].Value),
int.Parse(matching.Groups["zone"].Value),
DateTime.UtcNow
)
)
);
}
private static string ConvertAbonent(string value)
{
var hex = int.Parse(value, NumberStyles.HexNumber);
Log.Information("Abonent: 0x{Hex} ({Decimal})", hex, value);
return $"{hex:D8}";
}
private static MessageType GetMessageType(string s)
{
return s switch
{
"E" => MessageType.AlarmDisarming,
"R" => MessageType.RecoveryArming,
"P" => MessageType.StatePing,
_ => MessageType.Unknown,
};
}
[GeneratedRegex(
@"(?<receiver>\d{1})(?<line>\d{1})\s{1}(?<abonent>\d{4})\s{1}18\s{1}(?<type>\w{1})(?<code>\d{3})\s{1}(?<area>\d{2})\s{1}(\w{1})(?<zone>\d{3})"
)]
private static partial Regex RealMessagePattern();
[GeneratedRegex(@"(\d{2})\s{1}OKAY\s{1}@")]
private static partial Regex TestMessagePattern();
}

View File

@@ -0,0 +1,55 @@
using Microsoft.Extensions.Options;
using Serilog;
namespace Forwarder.Modules.Ademco;
/// <summary>
/// Менеджер слушателей.
/// </summary>
/// <param name="serviceProvider">Сервис-провайдер.</param>
internal sealed class AdemcoHost(
IServiceProvider serviceProvider)
: BackgroundService
{
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = serviceProvider.CreateScope();
var options = scope.ServiceProvider.GetRequiredService<IOptions<AdemcoOptions>>().Value;
var taskList = new List<Task>();
var posts = options.Ports.Split(',')
.Select(x => ushort.Parse(x.Trim()));
foreach (var port in posts)
{
taskList.Add(this.RunListener(port, stoppingToken));
}
await Task.WhenAll(taskList);
}
}
private async Task RunListener(ushort port, CancellationToken stoppingToken)
{
try
{
using var scope = serviceProvider.CreateScope();
Log.Information("Create listener for port {Port}", port);
var listener = scope.ServiceProvider.GetRequiredService<UdpPortListener>();
await listener.Start(port, stoppingToken);
}
catch (Exception ex)
{
Log.Error(ex, "Manager execution error when listener running");
}
}
}

View File

@@ -0,0 +1,13 @@
namespace Forwarder.Modules.Ademco;
/// <summary>
/// Параметры запуска для Ademco.
/// </summary>
public class AdemcoOptions
{
/// <summary>
/// Порты, через запятую.
/// </summary>
[ConfigurationKeyName("ADEMCO_PORTS")]
public string Ports { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,7 @@
using System.Net;
namespace Forwarder.Modules.Ademco;
internal sealed record DataReadResult(
IPEndPoint EndPoint,
byte[] Data
);

View File

@@ -0,0 +1,15 @@
namespace Forwarder.Modules.Ademco;
public static class DependencyInjectionExtensions
{
public static IHostApplicationBuilder AddAdemcoModule(this IHostApplicationBuilder builder)
{
builder.Services
.Configure<AdemcoOptions>(builder.Configuration)
.AddScoped<Ademco685MessageParser>()
.AddScoped<UdpPortListener>()
.AddHostedService<AdemcoHost>();
return builder;
}
}

View File

@@ -0,0 +1,116 @@
// <copyright file="UdpPortListener.cs" company="IGORock Software">
// Copyright (c) IGORock Software. All rights reserved.
// </copyright>
// <author>Igor Kozlov</author>
using System.Net;
using System.Text;
using System.Text.Json;
using Forwarder.Data;
using Forwarder.Repositories;
using Microsoft.Extensions.Logging;
using Serilog;
namespace Forwarder.Modules.Ademco;
/// <summary>
/// Слушает UDP порт.
/// </summary>
internal sealed class UdpPortListener(
ILogger<UdpPortListener> logger,
Ademco685MessageParser parser,
IMessageRepository messageRepository)
{
/// <summary>
/// Начинает слушать UDP порт.
/// </summary>
/// <param name="port">Порт.</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Задача.</returns>
public async Task Start(ushort port, CancellationToken cancellationToken)
{
logger.LogInformation("Start listener for port {Port}", port);
while (!cancellationToken.IsCancellationRequested)
{
try
{
using var udpTransfer = new UdpReader(port);
await Listen(udpTransfer, cancellationToken);
}
catch (Exception ex)
{
Log.Error(ex, "Error in Listener.Start()");
}
await Task.Delay(1000, cancellationToken);
}
}
private async Task Listen(UdpReader udpTransfer, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var result = await udpTransfer.Read(cancellationToken);
if (result.IsFailure)
{
Log.Error("Read UDP data is failure: {Error}", result.Error);
await Task.Delay(1000, cancellationToken);
continue;
}
await OnDataReadAsync(result.Value, udpTransfer, cancellationToken);
}
}
private async Task OnDataReadAsync(
DataReadResult input,
UdpReader udpReader,
CancellationToken cancellationToken)
{
var data = input.Data;
var sourceString = Encoding.ASCII.GetString(data, 0, data.Length);
Log.Information(
"Ademco [{Port}]: {Address} -> {Message}",
udpReader.Port,
input.EndPoint,
sourceString.Replace("\n", "[LF]").Replace("\r", "[CR]")
);
udpReader.Write([0x06], input.EndPoint);
Log.Information("Reply the 0x06 answer");
var parsed = parser.Parse(sourceString);
// если сообщение тестовое - возвращаем ответ
if (parsed.IsFailure)
{
Log.Information("Ademco: Message fail to parse.");
return;
}
if (parsed.Value.IsTest)
{
Log.Information("Ademco: Test message received.");
return;
}
// если сообщение нормальное - пересылаем в тсп
var message = parsed.Value.Message
?? throw new InvalidOperationException("Parsed message is null");
Log.Information("Message: {Message}", JsonSerializer.Serialize(message));
var addMessageResult = await messageRepository.AddAsync(message, cancellationToken);
if (addMessageResult.IsFail)
{
Log.Error("Error by message add: {Error}", addMessageResult.Error);
}
Log.Information("The message: {Message} saved in database", sourceString);
}
}

View File

@@ -0,0 +1,73 @@
// <copyright file="UdpReader.cs" company="IGORock Software">
// Copyright (c) IGORock Software. All rights reserved.
// </copyright>
// <author>Igor Kozlov</author>
namespace Forwarder.Modules.Ademco;
using System.Net;
using System.Net.Sockets;
using CSharpFunctionalExtensions;
using Serilog;
/// <summary>
/// Читает сообщения из UDP порта.
/// </summary>
internal sealed class UdpReader : IDisposable
{
private readonly UdpClient _client;
/// <summary>
/// Прослушиваемый порт.
/// </summary>
public ushort Port { get; }
/// <summary>
/// Initializes a new instance of the <see cref="UdpReader"/> class.
/// </summary>
/// <param name="port">Порт прослушивания.</param>
public UdpReader(ushort port)
{
_client = new UdpClient(port);
_client.Client.ReceiveTimeout = 10_000;
_client.Client.SendTimeout = 10_000;
Port = port;
}
/// <summary>
/// Читает сообщение из UDP порта.
/// </summary>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Результат чтения.</returns>
public async Task<Result<DataReadResult>> Read(CancellationToken cancellationToken)
{
try
{
var data = await _client.ReceiveAsync(cancellationToken);
return Result.Success(new DataReadResult(data.RemoteEndPoint, data.Buffer));
}
catch (Exception ex)
{
Log.Error(ex, "UdpReader reading error");
return Result.Failure<DataReadResult>(ex.Message);
}
}
/// <summary>
/// Отправляет сообщение в UDP порт.
/// </summary>
/// <param name="data">Данные.</param>
/// <param name="endPoint">Конечная точка.</param>
public void Write(byte[] data, IPEndPoint endPoint)
{
_ = _client.Send(data, data.Length, endPoint);
}
/// <inheritdoc />
public void Dispose()
{
_client?.Dispose();
}
}

View File

@@ -0,0 +1,187 @@
using FluentValidation;
using Forwarder;
using Forwarder.Data;
using Forwarder.Exceptions;
using Forwarder.Models;
using Forwarder.Modules.Ademco;
using Forwarder.Repositories;
using Forwarder.Repositories.Implementations;
using Forwarder.Services;
using Forwarder.Services.Implementations;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Monitoring.Common;
using Polly;
using Serilog;
using Serilog.Enrichers.CallerInfo;
using TelegramSender;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddControllers();
builder.AddServiceDefaults();
builder.Services.AddOpenApi();
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.WithCallerInfo(
includeFileInfo: true,
assemblyPrefix: "forwarder_")
.MinimumLevel.Information()
.WriteTo.Console()
.CreateLogger();
builder.Host.UseSerilog();
builder.Logging.SetMinimumLevel(LogLevel.Debug);
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(builder =>
{
_ = builder.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddControllers();
if (EnvironmentVariables.Has("DATABASE_ASPIRE_NAME"))
{
var connectionName = EnvironmentVariables.Get("DATABASE_ASPIRE_NAME");
builder.AddNpgsqlDbContext<DataContext>(connectionName: connectionName);
builder.Services.AddScoped<IDataContext, DataContext>(serviceProvider => serviceProvider.GetRequiredService<DataContext>());
}
else
{
var connectionString = EnvironmentVariables.Get("DATABASE_CONNECTION_STRING");
builder.Services.AddDbContext<IDataContext, DataContext>(options =>
{
_ = options.UseNpgsql(connectionString)
.UseLoggerFactory(LoggerFactory.Create(x => x.SetMinimumLevel(LogLevel.Warning)));
_ = options.EnableSensitiveDataLogging(false);
});
}
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
var pollyPolicy = GetRetryPolicy();
builder.Services.AddHttpClient(HttpClients.Api, (serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptions<ForwarderOptions>>().Value;
client.BaseAddress = new Uri(options.ApiUrl);
client.DefaultRequestHeaders.Add("User-Agent", "HttpClientFactory");
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5))
.AddPolicyHandler(pollyPolicy);
builder.Services.Configure<GeoRitmConnectionOptions>(builder.Configuration);
builder.Services.AddHttpClient(HttpClients.GeoRitm, (sp, client) =>
{
var options = sp.GetRequiredService<IOptions<GeoRitmConnectionOptions>>().Value;
client.BaseAddress = new Uri(options.Url);
client.DefaultRequestHeaders.Add("User-Agent", "HttpClientFactory");
var scope = sp.CreateScope();
var basicAuthService = scope.ServiceProvider.GetRequiredService<IBasicAuthService>();
client.DefaultRequestHeaders.Add(
"Authorization",
basicAuthService.GetBasicAuth(options.Username, options.Password));
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5))
.AddPolicyHandler(pollyPolicy);
builder.Services.Configure<ForwarderOptions>(builder.Configuration);
builder.Services.AddHostedService<TcpWatcher>();
builder.Services.AddHostedService<UdpWatcher>();
builder.Services.AddHostedService<SendJob>();
// builder.Services.AddHostedService<OnlineStatusJob>();
// Репозитории
builder.Services.AddScoped<IMessageRepository, MessageRepository>();
builder.Services.AddScoped<IAssetMapRepository, AssetMapRepository>();
builder.Services.AddScoped<ICommonRepository, CommonRepository>();
// Отправители
builder.Services.AddScoped<ISendService, SendService>();
builder.Services.AddScoped<IGeoRitmClient, GeoRitmClient>();
builder.Services.AddScoped<IBasicAuthService, BasicAuthService>();
// Протоколы
builder.Services.AddScoped<IMessageConverterService, MessageConverterService>();
// Уведомления для пользователей
builder.Services.Configure<TelegramSendOptions>(builder.Configuration);
if (EnvironmentVariables.Has("TELEGRAM_BOT_TOKEN"))
{
builder.Services.AddTelegramSender(
GetEnvironmentVariable("TELEGRAM_BOT_TOKEN"),
int.Parse(GetEnvironmentVariable("TELEGRAM_BATCH_SIZE")),
long.Parse(GetEnvironmentVariable("TELEGRAM_CHAT_ID")));
}
else
{
builder.Services.AddTelegramSender(string.Empty, default, default);
}
// Ademco
builder.AddAdemcoModule();
var app = builder.Build();
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment())
{
_ = app.MapOpenApi();
//_ = app.MapScalarApiReference()
// .WithName("Forwarder API");
}
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapDefaultEndpoints();
await ApplyMigrations(app);
await app.RunAsync();
static async Task ApplyMigrations(WebApplication app)
{
var context = app.Services.CreateScope().ServiceProvider.GetRequiredService<DataContext>();
await context.Database.MigrateAsync();
}
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return Policy
.Handle<HttpRequestException>()
.WaitAndRetryAsync(
retryCount: 10,
sleepDurationProvider: _ => TimeSpan.FromSeconds(1),
onRetry: (exception, timeSpan, attempt, context) =>
{
Log.Warning("Request failed #{Attempt}. Waiting {TimeSpan} before next retry. Retry due to: {Exception}.", attempt, timeSpan, exception.Message);
})
.AsAsyncPolicy<HttpResponseMessage>();
}
static string GetEnvironmentVariable(string name)
{
return Environment.GetEnvironmentVariable(name)
?? throw new EnvironmentVariableNotFoundException(name);
}

View File

@@ -0,0 +1,22 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "scalar/v1",
"applicationUrl": "http://127.0.0.1:5010",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DATABASE_CONNECTION_STRING": "***",
"API_URL": "http://localhost:5001",
"API_TOKEN": "***",
"TCP_PORT": "10000",
"UDP_PORT": "10001",
"MESSAGE_BULK_SIZE": "100",
"SENDING_INTERVAL_IN_SECONDS": "10"
}
}
}
}

View File

@@ -0,0 +1,82 @@
using Forwarder.Domain;
using Forwarder.Models;
namespace Forwarder.Repositories;
/// <summary>
/// Интерфейс репозитория для работы с маппингом идентификаторов объектов.
/// </summary>
public interface IAssetMapRepository
{
/// <summary>
/// Добавляет маппинг в репозиторий.
/// </summary>
/// <param name="abonent"> Абонент. </param>
/// <param name="assetId"> Идентификатор объекта. </param>
/// <param name="cancellationToken"> Токен отмены. </param>
/// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
Task<Result<AssetMap>> AddAsync(string abonent, Guid? assetId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Получает маппинги со обязательными заполненными идентификаторами объектов по списку абонентов.
/// Если маппинги не существуют, то создает их.
/// </summary>
/// <param name="abonentList">Список абонентов.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task<Result<List<AssetMap>>> GetRequiredMapsAndCreateIsNewAsync(IEnumerable<string> abonentList, CancellationToken cancellationToken);
/// <summary>
/// Возвращает страницу таблицы с маппингами.
/// </summary>
/// <param name="request">Запрос.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task<Result<ITableStateResponse<AssetMap>>> GetTable(ITableStateRequest request, CancellationToken cancellationToken);
/// <summary>
/// Возвращает страницу таблицы с маппингами.
/// </summary>
/// <param name="query">Запрос на выборку.</param>
/// <param name="request">Запрос.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task<Result<ITableStateResponse<AssetMap>>> GetTable(IQueryable<AssetMap> query, ITableStateRequest request, CancellationToken cancellationToken);
/// <summary>
/// Обновляет маппинг.
/// </summary>
/// <param name="id">Идентификатор.</param>
/// <param name="assetId">Идентификатор объекта.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task<Result> UpdateAsync(Guid id, Guid? assetId, CancellationToken cancellationToken);
/// <summary>
/// Обновляет статус онлайн.
/// </summary>
/// <param name="id">Идентификатор.</param>
/// <param name="isOnline">Статус онлайн.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task<Result> UpdateIsOnlineStatusAsync(
Guid id,
bool isOnline,
CancellationToken cancellationToken);
/// <summary>
/// Возвращает маппинг по идентификатору.
/// </summary>
/// <param name="id">Идентификатор.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task<Result<AssetMap>> Get(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Возвращает запрос на получение маппингов.
/// </summary>
IQueryable<AssetMap> GetQuery();
/// <summary>
/// Удаляет маппинг.
/// </summary>
/// <param name="id">Идентификатор.</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Результат удаления.</returns>
/// <remarks>Если удаление прошло успешно, возвращает Result без значения.</remarks>
Task<Result> DeleteAsync(Guid id, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,13 @@
using Forwarder.Domain;
using Forwarder.Models;
namespace Forwarder.Repositories;
/// <summary>
/// Интерфейс репозитория для работы с маппингом идентификаторов объектов и с сообщениями.
/// </summary>
public interface ICommonRepository
{
Task<Result<IEnumerable<Message>>> GetFirstListWhereMapReady(
int takeCount, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,39 @@
using Forwarder.Domain;
using Forwarder.Models;
namespace Forwarder.Repositories;
/// <summary>
/// Интерфейс репозитория для работы с сообщениями.
/// </summary>
public interface IMessageRepository
{
/// <summary>
/// Добавляет сообщение в репозиторий.
/// </summary>
/// <param name="message"> Сообщение. </param>
/// <param name="cancellationToken"> Токен отмены. </param>
/// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
Task<Result> AddAsync(Message message, CancellationToken cancellationToken);
/// <summary>
/// Получает первые <paramref name="takeCount"/> сообщений из репозитория.
/// </summary>
/// <param name="takeCount">Размер пачки сообщений.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task<List<Message>> GetFirstListAsync(int takeCount, CancellationToken cancellationToken);
/// <summary>
/// Удаляет сообщение из репозитория.
/// </summary>
/// <param name="message">Сообщение.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task<Result> RemoveAsync(Message message, CancellationToken cancellationToken);
/// <summary>
/// Удаляет сообщения из репозитория.
/// </summary>
/// <param name="messageIdList">Список ID сообщений.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task<Result> RemoveBatch(List<Guid> messageIdList, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,225 @@
using Forwarder.Data;
using Forwarder.Domain;
using Forwarder.Models;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace Forwarder.Repositories.Implementations;
/// <inheritdoc />
public class AssetMapRepository(
IDataContext dataContext
) : IAssetMapRepository
{
private const string NotFoundMessage = "Маппинг не найден";
private const string AlreadyExistsMessage = "Маппинг с таким ИД объекта уже существует";
/// <inheritdoc />
public async Task<Result<AssetMap>> AddAsync(
string abonent,
Guid? assetId = null,
CancellationToken cancellationToken = default)
{
try
{
var map = await dataContext.Maps
.FirstOrDefaultAsync(x => x.Abonent == abonent, cancellationToken);
if (map is not null)
{
return $"Абонент уже существует в маппинге. ИД маппнига: {map.Id}";
}
if (assetId.HasValue)
{
map = await dataContext.Maps
.FirstOrDefaultAsync(x => x.AssetId == assetId.Value, cancellationToken);
if (map is not null)
{
return $"ИД объекта уже существует в маппинге. ИД маппнига: {map.Id}";
}
}
map = new AssetMap()
{
Abonent = abonent,
AssetId = assetId,
};
_ = await dataContext.Maps.AddAsync(map, cancellationToken);
_ = await dataContext.SaveChangesAsync(cancellationToken);
return map;
}
catch (Exception ex)
{
return ex.Message;
}
}
public async Task<Result> DeleteAsync(Guid id, CancellationToken cancellationToken)
{
try
{
var assetMap = await dataContext.Maps
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (assetMap is null)
{
return Result.Fail("Маппинг не найден");
}
dataContext.Maps.Remove(assetMap);
_ = await dataContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
/// <inheritdoc />
public async Task<Result<AssetMap>> Get(Guid id, CancellationToken cancellationToken)
{
var assetMap = await dataContext.Maps
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (assetMap is null)
{
return Result<AssetMap>.Fail("Маппинг не найден");
}
return Result<AssetMap>.Success(assetMap);
}
/// <inheritdoc />
public IQueryable<AssetMap> GetQuery()
{
return dataContext.Maps
.AsNoTracking();
}
/// <inheritdoc />
public async Task<Result<List<AssetMap>>> GetRequiredMapsAndCreateIsNewAsync(IEnumerable<string> abonentList, CancellationToken cancellationToken)
{
try
{
var maps = await dataContext.Maps
.Where(x => abonentList.Contains(x.Abonent))
.ToListAsync(cancellationToken);
var mapsWithAssetIds = maps.Where(x => x.AssetId.HasValue);
if (mapsWithAssetIds.Count() == abonentList.Count())
{
return Result<List<AssetMap>>.Success(maps);
}
var newMaps = abonentList
.Where(x => !maps.Exists(y => y.Abonent == x))
.Select(x => new AssetMap
{
Abonent = x
})
.ToList();
await dataContext.Maps.AddRangeAsync(newMaps, cancellationToken);
_ = await dataContext.SaveChangesAsync(cancellationToken);
return Result<List<AssetMap>>.Success([.. maps, .. newMaps]);
}
catch (Exception ex)
{
return Result<List<AssetMap>>.Fail(ex.Message);
}
}
/// <inheritdoc />
public async Task<Result<ITableStateResponse<AssetMap>>> GetTable(ITableStateRequest request, CancellationToken cancellationToken)
{
var query = GetQuery();
return await GetTable(query, request, cancellationToken);
}
/// <inheritdoc />
public async Task<Result<ITableStateResponse<AssetMap>>> GetTable(IQueryable<AssetMap> query, ITableStateRequest request, CancellationToken cancellationToken)
{
var count = await query.CountAsync(cancellationToken);
if (request.Skip.HasValue)
{
query = query.Skip(request.Skip.Value);
}
if (request.Take.HasValue)
{
query = query.Take(request.Take.Value);
}
var items = await query.ToListAsync(cancellationToken);
return new TableStateResponse<AssetMap>(items, count);
}
/// <inheritdoc />
public async Task<Result> UpdateAsync(Guid id, Guid? assetId, CancellationToken cancellationToken)
{
try
{
var assetMap = await dataContext.Maps
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (assetMap is null)
{
Log.Information(NotFoundMessage);
return Result.Fail(NotFoundMessage);
}
var sameAssetId = await dataContext.Maps
.FirstOrDefaultAsync(x => x.Id != id && x.AssetId == assetId, cancellationToken);
if (sameAssetId is not null)
{
Log.Information(AlreadyExistsMessage);
return Result.Fail(AlreadyExistsMessage);
}
assetMap.AssetId = assetId;
_ = await dataContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
/// <inheritdoc />
public async Task<Result> UpdateIsOnlineStatusAsync(
Guid id,
bool isOnline,
CancellationToken cancellationToken)
{
try
{
var assetMap = await dataContext.Maps
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (assetMap is null)
{
Log.Information(NotFoundMessage);
return Result.Fail(NotFoundMessage);
}
assetMap.IsOnline = isOnline;
_ = await dataContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
}

View File

@@ -0,0 +1,38 @@
using Forwarder.Data;
using Forwarder.Domain;
using Forwarder.Models;
using Microsoft.EntityFrameworkCore;
namespace Forwarder.Repositories.Implementations;
/// <inheritdoc />
public sealed class CommonRepository(
DataContext dataContext
) : ICommonRepository
{
/// <inheritdoc />
public async Task<Result<IEnumerable<Message>>> GetFirstListWhereMapReady(
int takeCount,
CancellationToken cancellationToken)
{
try
{
return await dataContext.Messages
.Join(dataContext.Maps,
m => m.Abonent,
a => a.Abonent,
(m, a) => new { Message = m, Map = a })
.Where(x => x.Map.AssetId.HasValue)
.Select(x => x.Message)
.OrderBy(x => x.CreatedAt)
.Take(takeCount)
.ToListAsync(cancellationToken);
}
catch (Exception ex)
{
return Result<IEnumerable<Message>>.Fail(ex.Message);
}
}
}

View File

@@ -0,0 +1,63 @@
using Forwarder.Data;
using Forwarder.Domain;
using Forwarder.Models;
using Microsoft.EntityFrameworkCore;
namespace Forwarder.Repositories.Implementations;
/// <inheritdoc />
public sealed class MessageRepository(
DataContext dataContext
) : IMessageRepository
{
/// <inheritdoc />
public async Task<Result> AddAsync(Message message, CancellationToken cancellationToken)
{
try
{
_ = await dataContext.Messages.AddAsync(message, cancellationToken);
_ = await dataContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
/// <inheritdoc />
public Task<List<Message>> GetFirstListAsync(int takeCount, CancellationToken cancellationToken)
{
return dataContext.Messages
.Take(takeCount)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<Result> RemoveAsync(Message message, CancellationToken cancellationToken)
{
var existingMessage = await dataContext.Messages
.FirstOrDefaultAsync(x => x.Id == message.Id, cancellationToken);
if (existingMessage is null)
{
return Result.Fail("Сообщение не найдено");
}
_ = dataContext.Messages.Remove(existingMessage);
_ = await dataContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
/// <inheritdoc />
public async Task<Result> RemoveBatch(List<Guid> messageIdList, CancellationToken cancellationToken)
{
_ = await dataContext.Messages
.Where(x => messageIdList.Contains(x.Id))
.ExecuteDeleteAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -0,0 +1,10 @@
$env:DATABASE_CONNECTION_STRING="Server=localhost;Port=5432;Database=Forwarder;User ID=postgres;Password=sql;"
$env:API_URL="http://localhost:6001"
$env:API_TOKEN="ejrwerhwqkerqkjwehrkjw"
$env:TCP_PORT="9000"
$env:UDP_PORT="9000"
$env:MESSAGE_BULK_SIZE="1"
dotnet ef migrations add $args[0] -o Data\Migrations
exit

View File

@@ -0,0 +1,8 @@
$env:JWT_ISSUER="qwe"
$env:JWT_AUDIENCE="qwe"
$env:JWT_KEY="qwe"
$env:DATABASE_CONNECTION_STRING="Server=localhost;Port=5432;Database=Monolith;User ID=postgres;Password=sql;"
$env:FILE_CONNECTION_STRING="mongodb://mongo:mongo@localhost:27017/monitoring"
dotnet ef migrations remove

View File

@@ -0,0 +1,8 @@
$env:JWT_ISSUER="qwe"
$env:JWT_AUDIENCE="qwe"
$env:JWT_KEY="qwe"
$env:DATABASE_CONNECTION_STRING="Server=localhost;Port=5432;Database=Monolith;User ID=postgres;Password=sql;"
$env:FILE_CONNECTION_STRING="mongodb://mongo:mongo@localhost:27017/monitoring"
dotnet ef database update

View File

@@ -0,0 +1,15 @@
namespace Forwarder.Services;
/// <summary>
/// Сервис для генерации заголовка авторизации.
/// </summary>
public interface IBasicAuthService
{
/// <summary>
/// Генерирует заголовок авторизации.
/// </summary>
/// <param name="username">Имя пользователя.</param>
/// <param name="password">Пароль.</param>
/// <returns>Заголовок авторизации.</returns>
string GetBasicAuth(string username, string password);
}

View File

@@ -0,0 +1,16 @@
using Forwarder.Models;
namespace Forwarder.Services;
/// <summary>
/// Клиент для работы с API GeoRitm.
/// </summary>
public interface IGeoRitmClient
{
/// <summary>
/// Получает список объектов с информацией об их состоянии.
/// </summary>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Список объектов.</returns>
Task<Result<GetObjectListResponse[]>> GetObjectList(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,43 @@
using Forwarder.Domain;
using Forwarder.Models;
namespace Forwarder.Services;
/// <summary>
/// Конвертер сообщений.
/// </summary>
public interface IMessageConverter
{
/// <summary>
/// Конвертирует сообщение в текст.
/// </summary>
/// <param name="message">Сообщение.</param>
/// <returns>Текст сообщения.</returns>
Result<string> ToText(Message message);
/// <summary>
/// Конвертирует текст в сообщение.
/// </summary>
/// <param name="source">Текст.</param>
/// <returns>Сообщение.</returns>
Result<Message> Parse(string source);
/// <summary>
/// Проверяет, может ли конвертер обработать сообщение.
/// </summary>
/// <param name="messageText">Текс сообщения.</param>
/// <returns> <see langword="true"/> - если конвертер может обработать сообщение. <see langword="false"/> - в противном случае. </returns>
bool CanHandle(string messageText);
/// <summary>
/// Отправляет ответ.
/// </summary>
/// <param name="networkSender">Отправитель сообщений по сети.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task<Result> SendAnswer(INetworkSender networkSender, CancellationToken cancellationToken);
/// <summary>
/// Имя конвертера.
/// </summary>
string Name { get; }
}

View File

@@ -0,0 +1,22 @@
using Forwarder.Models;
namespace Forwarder.Services;
/// <summary>
/// Работает с конвертерами.
/// </summary>
public interface IMessageConverterService
{
/// <summary>
/// Находит конвертер для данного сообщения.
/// </summary>
/// <returns>Конвертер.</returns>
Result<IMessageConverter> FindMessageConverter(string messageText);
/// <summary>
/// Возвращает отформатированное сообщение.
/// </summary>
/// <param name="input">Исходное сообщение.</param>
/// <returns>Отформатированное сообщение.</returns>
string FormatMessage(string input);
}

View File

@@ -0,0 +1,14 @@
namespace Forwarder.Services;
/// <summary>
/// Отправитель сообщений по сети.
/// </summary>
public interface INetworkSender
{
/// <summary>
/// Отправляет сообщение по сети.
/// </summary>
/// <param name="message">Сообщение.</param>
/// <param name="cancellationToken">Токен отмены.</param>
Task SendAsync(byte[] message, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,31 @@
using Forwarder.Domain;
using Forwarder.Models;
namespace Forwarder.Services;
/// <summary>
/// Сервиса отправки.
/// </summary>
public interface ISendService
{
/// <summary>
/// Отправляет сообщение.
/// </summary>
/// <param name="message">Сообщение.</param>
/// <param name="assetId">Идентификатор ассета.</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Результат выполнения запроса.</returns>
Task<Result> Send(Message message, Guid assetId, CancellationToken cancellationToken);
/// <summary>
/// Отправляет статус подключения объекта.
/// </summary>
/// <param name="isOnline">Статус подключения.</param>
/// <param name="assetId">Идентификатор ассета.</param>
/// <param name="cancellationToken">Токен отмены.</param>
/// <returns>Результат выполнения запроса.</returns>
Task<Result> SendOnlineStatus(bool isOnline, Guid assetId, CancellationToken cancellationToken);
Task<Result> SendTestMessage(Message message, Guid assetId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,14 @@
using System.Text;
namespace Forwarder.Services.Implementations;
/// <inheritdoc />
public class BasicAuthService : IBasicAuthService
{
/// <inheritdoc />
public string GetBasicAuth(string username, string password)
{
return $"Basic {Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{username}:{password}"))}";
}
}

View File

@@ -0,0 +1,28 @@
using Forwarder.Models;
namespace Forwarder.Services.Implementations;
/// <inheritdoc />
public class GeoRitmClient(
IHttpClientFactory httpClientFactory
) : IGeoRitmClient
{
public async Task<Result<GetObjectListResponse[]>> GetObjectList(CancellationToken cancellationToken)
{
try
{
var client = httpClientFactory.CreateClient(HttpClients.GeoRitm);
var response = await client.PostAsJsonAsync(
"/restapi/objects/obj-search/",
new object(),
cancellationToken);
_ = response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<GetObjectListResponse[]>(cancellationToken);
return Result<GetObjectListResponse[]>.Success(result!);
}
catch (Exception ex)
{
return Result<GetObjectListResponse[]>.Fail(ex.Message);
}
}
}

View File

@@ -0,0 +1,78 @@
using System.Text.RegularExpressions;
using Forwarder.Models;
using Forwarder.Services.Implementations.MessageConverters;
namespace Forwarder.Services.Implementations;
/// <inheritdoc />
public partial class MessageConverterService : IMessageConverterService
{
private readonly IEnumerable<IMessageConverter> _converters = [
new SurGardVersion4MessageConverter(),
new Ademco685MessageConverter(),
];
// Словарь для сопоставления символов с их именами
private static readonly Dictionary<char, string> ControlCharNames = new()
{
{ '\u0000', "" },
{ '\u0001', "[SOH]" },
{ '\u0002', "[STX]" },
{ '\u0003', "[ETX]" },
{ '\u0004', "[EOT]" },
{ '\u0005', "[ENQ]" },
{ '\u0006', "[ACK]" },
{ '\u0007', "[BEL]" },
{ '\u0008', "[BS]" },
{ '\u0009', "[TAB]" },
{ '\u000A', "[LF]" },
{ '\u000B', "[VT]" },
{ '\u000C', "[FF]" },
{ '\u000D', "[CR]" },
{ '\u000E', "[SO]" },
{ '\u000F', "[SI]" },
{ '\u0010', "[DLE]" },
{ '\u0011', "[DC1]" },
{ '\u0012', "[DC2]" },
{ '\u0013', "[DC3]" },
{ '\u0014', "[DC4]" },
{ '\u0015', "[NAK]" },
{ '\u0016', "[SYN]" },
{ '\u0017', "[ETB]" },
{ '\u0018', "[CAN]" },
{ '\u0019', "[EM]" },
{ '\u001A', "[SUB]" },
{ '\u001B', "[ESC]" },
{ '\u001C', "[FS]" },
{ '\u001D', "[GS]" },
{ '\u001E', "[RS]" },
{ '\u001F', "[US]" }
};
/// <inheritdoc />
public Result<IMessageConverter> FindMessageConverter(string messageText)
{
var converter = _converters.FirstOrDefault(x => x.CanHandle(messageText));
if (converter is null)
{
return "Converter is not found";
}
return Result<IMessageConverter>.Success(converter);
}
/// <inheritdoc />
public string FormatMessage(string input)
{
// Используем регулярное выражение для поиска и замены
return AsciiSymbols().Replace(input, match =>
{
var controlChar = match.Value[0];
return ControlCharNames.TryGetValue(controlChar, out var value) ? value : match.Value;
});
}
[GeneratedRegex(@"[\x00-\x1F]")]
private static partial Regex AsciiSymbols();
}

View File

@@ -0,0 +1,120 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Forwarder.Domain;
using Forwarder.Models;
using Serilog;
namespace Forwarder.Services.Implementations.MessageConverters;
internal class Ademco685MessageConverter : IMessageConverter
{
private static readonly Regex Regex = new(
$@"{(char)10}(\d{{1}})(\d{{1}})\s{{1}}(\d{{4}})\s{{1}}18\s{{1}}(\w{{1}})(\d{{3}})\s{{1}}(\d{{2}})\s{{1}}(\w{{1}})(\d{{3}})\s{{1}}{(char)13}"
);
private static readonly Regex RegexTest = new(
$@"{(char)10}(\d{{2}})\s{{1}}OKAY\s{{1}}@{(char)13}"
);
private static readonly IEnumerable<Regex> AllRegex = [Regex, RegexTest];
private const byte AcknowledgeSurGardResponse = 0x06;
private static readonly byte[] AcknowledgeSurGardResponseData = [AcknowledgeSurGardResponse];
/// <inheritdoc />
public string Name => "Ademco 685";
private static string GetMessageTypeSymbol(MessageType type)
{
return type switch
{
MessageType.AlarmDisarming => "E",
MessageType.RecoveryArming => "R",
MessageType.StatePing => "P",
_ => "",
};
}
private static MessageType GetMessageType(string s)
{
return s switch
{
"E" => MessageType.AlarmDisarming,
"R" => MessageType.RecoveryArming,
"P" => MessageType.StatePing,
_ => MessageType.Unknown,
};
}
public Result<string> ToText(Message message)
{
// [LF]RLsAAAAs18sQXYZsGGsUCCC[CR]
return Result<string>.Success(
$"{(char)10}{message.Receiver:0}{message.Line:0} {message.Abonent:0000} 18 {GetMessageTypeSymbol(message.Type):P}{message.Code:000} {message.Sector:00} C{message.Zone:000}{(char)13}"
);
}
public Result<Message> Parse(string source)
{
var match = RegexTest.Match(source);
if (match.Success)
{
return new Message(
Guid.NewGuid(),
int.Parse(match.Groups[1].Value),
int.Parse(match.Groups[2].Value),
"",
MessageType.StatePing,
0,
0,
0,
DateTime.UtcNow
);
}
match = Regex.Match(source);
if (!match.Success)
{
return "Invalid message format";
}
// [LF]RLsAAAAs18sQXYZsGGsUCCC[CR]
return new Message(
Guid.NewGuid(),
int.Parse(match.Groups[1].Value),
int.Parse(match.Groups[2].Value),
ConvertAbonent(match.Groups[3].Value),
GetMessageType(match.Groups[4].Value),
int.Parse(match.Groups[5].Value),
int.Parse(match.Groups[6].Value),
int.Parse(match.Groups[8].Value),
DateTime.UtcNow
);
}
private static string ConvertAbonent(string value)
{
var hex = int.Parse(value, NumberStyles.HexNumber);
Log.Warning("Parsed: {Hex}", hex);
return hex.ToString();
}
public bool CanHandle(string messageText)
{
return AllRegex.Any(x => x.IsMatch(messageText));
}
public async Task<Result> SendAnswer(
INetworkSender networkSender,
CancellationToken cancellationToken
)
{
try
{
await networkSender.SendAsync(AcknowledgeSurGardResponseData, cancellationToken);
return Result.Success();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
}

View File

@@ -0,0 +1,113 @@
using System.Text.RegularExpressions;
using Forwarder.Domain;
using Forwarder.Models;
namespace Forwarder.Services.Implementations.MessageConverters;
/// <summary>
/// SurGard Version 4 message converter.
/// </summary>
internal class SurGardVersion4MessageConverter : IMessageConverter
{
private static readonly Regex Regex = new(@"5(\d{2})(\d{1})\s{1}18(\d{4,16})(\w{1})(\d{3})(\d{2})(\d{3})" + (char)20);
private static readonly Regex RegexTest = new($@"1011(\s+)@" + (char)20);
private static readonly IEnumerable<Regex> AllRegex = [Regex, RegexTest];
private static readonly byte[] AcknowledgeSurGardResponseData = [0x06]; // Encoding.ASCII.GetBytes(AcknowledgeSurGardResponse);
/// <inheritdoc />
public string Name => "Sur-Gard Version 4";
public Result<string> ToText(Message message)
{
// 5RRLs18AAAAQCCCSSZZZ[DC4]
// 5000 1800000281R37300001
if (message.Type is MessageType.StatePing or MessageType.Unknown)
{
return Result<string>.Success($"1011 {(char)20}");
}
return Result<string>.Success(
$"5{message.Receiver:00}{message.Line:0} 18{message.Abonent:0000000000000000}{GetMessageTypeSymbol(message.Type):P}{message.Code:000}{message.Sector:00}{message.Zone:000}{(char)20}");
}
private static string GetMessageTypeSymbol(MessageType type)
{
return type switch
{
MessageType.AlarmDisarming => "E",
MessageType.RecoveryArming => "R",
MessageType.StatePing => "P",
_ => "",
};
}
public Result<Message> Parse(string source)
{
// 012345678901234567890
// 5RRLs18AAAAaaaaQXYZGGCCC[DC4]
var match = RegexTest.Match(source);
if (match.Success)
{
return new Message(
Guid.NewGuid(),
int.Parse(match.Groups[1].Value),
int.Parse(match.Groups[2].Value),
"",
MessageType.StatePing,
0,
0,
0,
DateTime.UtcNow
);
}
match = Regex.Match(source);
if (!match.Success)
{
return "Invalid message format";
}
return new Message(
Guid.NewGuid(),
int.Parse(match.Groups[1].Value),
int.Parse(match.Groups[2].Value),
match.Groups[3].Value,
GetMessageType(match.Groups[4].Value),
int.Parse(match.Groups[5].Value),
int.Parse(match.Groups[6].Value),
int.Parse(match.Groups[7].Value),
DateTime.UtcNow
);
}
private static MessageType GetMessageType(string s)
{
return s switch
{
"E" => MessageType.AlarmDisarming,
"R" => MessageType.RecoveryArming,
"P" => MessageType.StatePing,
_ => MessageType.Unknown,
};
}
public async Task<Result> SendAnswer(INetworkSender networkSender, CancellationToken cancellationToken)
{
try
{
await networkSender.SendAsync(AcknowledgeSurGardResponseData, cancellationToken);
return Result.Success();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public bool CanHandle(string messageText)
{
return AllRegex.Any(x => x.IsMatch(messageText));
}
}

View File

@@ -0,0 +1,21 @@
using System.Net.Sockets;
namespace Forwarder.Services.Implementations.NetworkSenders;
/// <summary>
/// Отправляет сообщения по TCP.
/// </summary>
/// <param name="networkStream">UDP-клиент.</param>
internal class TcpNetworkSender(
NetworkStream networkStream
) : INetworkSender
{
/// <inheritdoc />
public async Task SendAsync(byte[] message, CancellationToken cancellationToken)
{
await networkStream.WriteAsync(
message,
cancellationToken
);
}
}

View File

@@ -0,0 +1,25 @@
using System.Net;
using System.Net.Sockets;
namespace Forwarder.Services.Implementations.NetworkSenders;
/// <summary>
/// Отправляет сообщения по UDP.
/// </summary>
/// <param name="client">UDP-клиент.</param>
/// <param name="endPoint">Получатель.</param>
internal class UdpNetworkSender(
UdpClient client,
IPEndPoint endPoint
) : INetworkSender
{
/// <inheritdoc />
public async Task SendAsync(byte[] message, CancellationToken cancellationToken)
{
_ = await client.SendAsync(
message,
endPoint,
cancellationToken
);
}
}

View File

@@ -0,0 +1,171 @@
using Forwarder.Domain;
using Forwarder.Models;
using Microsoft.Extensions.Options;
using Monitoring.Contracts;
using Serilog;
using TelegramSender;
namespace Forwarder.Services.Implementations;
/// <inheritdoc />
public class SendService(
IOptions<ForwarderOptions> options,
IHttpClientFactory httpClientFactory,
INotificationBroker notificationBroker
) : ISendService
{
/// <inheritdoc />
public async Task<Result> Send(Message message, Guid assetId, CancellationToken cancellationToken)
{
try
{
var client = httpClientFactory.CreateClient(HttpClients.Api);
var code = GetCode(message);
var response = await client.PostAsJsonAsync(
"/api/events",
new CreateEventRequest(
options.Value.ApiToken,
assetId,
code,
message.Sector,
message.Zone,
message.CreatedAt,
new AutoAssetCreating(message.Abonent)),
cancellationToken);
_ = response.EnsureSuccessStatusCode();
var eventId = await response.Content.ReadAsStringAsync(cancellationToken);
Log.Information("Получен ответ от API, EventId: {EventId}", eventId);
return Result.Success();
}
catch (HttpRequestException ex)
{
// Ловим HttpRequestException
Log.Error(ex, "Request failed: {Message}", ex.Message);
// Если есть статусный код в ответе, можем его получить
if (ex.StatusCode.HasValue)
{
Log.Error("Status code: {Code}: {Desc}", (int)ex.StatusCode, ex.StatusCode);
}
// Получаем весь ответ, если он есть (нужно расширять в .NET 5 и выше)
if (ex.Data["HttpResponseMessage"] is HttpResponseMessage innerResponse)
{
var errorContent = await innerResponse.Content.ReadAsStringAsync(cancellationToken);
Log.Error("Error response: {ErrorContent}", errorContent);
}
await notificationBroker.Enqueue("Forwarder cannot send message: " + ex.Message);
return Result.Fail(ex.Message);
}
catch (Exception ex)
{
Log.Error(ex, "Не удалось отправить сообщение: {Type}: {Message}", ex.GetType(), ex.Message);
return Result.Fail(ex.Message);
}
}
/// <inheritdoc />
public async Task<Result> SendOnlineStatus(
bool isOnline,
Guid assetId,
CancellationToken cancellationToken)
{
try
{
var client = httpClientFactory.CreateClient(HttpClients.Api);
var response = await client.PostAsJsonAsync(
"/api/assets/online-status",
new UpdateAssetOnlineStatusRequest(
options.Value.ApiToken,
assetId,
isOnline),
cancellationToken);
_ = response.EnsureSuccessStatusCode();
Log.Information("Статус успешно обновлен");
return Result.Success();
}
catch (HttpRequestException ex)
{
// Ловим HttpRequestException
Log.Error(ex, "Request failed: {Message}", ex.Message);
// Если есть статусный код в ответе, можем его получить
if (ex.StatusCode.HasValue)
{
Log.Error("Status code: {Code}: {Desc}", (int)ex.StatusCode, ex.StatusCode);
}
// Получаем весь ответ, если он есть (нужно расширять в .NET 5 и выше)
if (ex.Data["HttpResponseMessage"] is HttpResponseMessage innerResponse)
{
var errorContent = await innerResponse.Content.ReadAsStringAsync(cancellationToken);
Log.Error("Error response: {ErrorContent}", errorContent);
}
await notificationBroker.Enqueue("Forwarder cannot send message: " + ex.Message);
return Result.Fail(ex.Message);
}
catch (Exception ex)
{
Log.Error(ex, "Не удалось отправить сообщение: {Type}: {Message}", ex.GetType(), ex.Message);
return Result.Fail(ex.Message);
}
}
public async Task<Result> SendTestMessage(Message message, Guid assetId, CancellationToken cancellationToken)
{
try
{
var client = httpClientFactory.CreateClient(HttpClients.Api);
var response = await client.PostAsJsonAsync(
"/api/events",
new CreateEventRequest(
options.Value.ApiToken,
assetId,
options.Value.TestMessageEventCode,
message.Sector,
message.Zone,
message.CreatedAt,
new AutoAssetCreating(message.Abonent)),
cancellationToken);
_ = response.EnsureSuccessStatusCode();
Log.Information("Тестовое сообщение (код {Code}) отправлено, AssetId: {AssetId}", options.Value.TestMessageEventCode, assetId);
return Result.Success();
}
catch (Exception ex)
{
Log.Error(ex, "Не удалось отправить тестовое сообщение (код {Code})", options.Value.TestMessageEventCode);
return Result.Fail(ex.Message);
}
}
private static int GetCode(Message message)
{
var typePostfix = message.Type switch
{
MessageType.AlarmDisarming => 1,
_ => 3,
};
return message.Code * 10 + typePostfix;
}
}

View File

@@ -0,0 +1,176 @@
using Forwarder.Domain;
using Forwarder.Models;
using Forwarder.Repositories;
using Microsoft.Extensions.Options;
using Serilog;
namespace Forwarder.Services;
/// <summary>
/// Обновляет статусы объектов.
/// </summary>
/// <param name="options">Опции.</param>
/// <param name="geoRitmOptions">Опции подключения к GeoRitm.</param>
/// <param name="serviceProvider">Сервис-провайдер DI контейнер.</param>
internal class OnlineStatusJob(
IOptions<ForwarderOptions> options,
IOptions<GeoRitmConnectionOptions> geoRitmOptions,
IServiceProvider serviceProvider
) : BackgroundService
{
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (string.IsNullOrEmpty(geoRitmOptions.Value.Url) ||
string.IsNullOrEmpty(geoRitmOptions.Value.Username) ||
string.IsNullOrEmpty(geoRitmOptions.Value.Password))
{
Log.Warning("GeoRitm connection options are not set. OnlineStatusJob will not run.");
return;
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ExecuteBatch(stoppingToken);
}
catch (Exception ex)
{
Log.Error(ex, "Error in ExecuteBatch");
}
await Task.Delay(options.Value.SendingIntervalInSeconds * 1000, stoppingToken);
}
}
private async Task ExecuteBatch(CancellationToken stoppingToken)
{
using var scope = serviceProvider.CreateScope();
var provider = scope.ServiceProvider;
var sendService = provider.GetRequiredService<ISendService>();
var assetMapRepository = provider.GetRequiredService<IAssetMapRepository>();
var geoRitmClient = provider.GetRequiredService<IGeoRitmClient>();
var assetMapList = await ReadAssetMaps(assetMapRepository, stoppingToken);
var objectInformationList = await GetObjectInformationList(
geoRitmClient,
stoppingToken
);
foreach (var newObjectStatus in objectInformationList)
{
foreach (var oldObjectStatus in assetMapList)
{
if (!CompareAbonents(newObjectStatus.Imei, oldObjectStatus.Abonent))
{
continue;
}
if (CompareOnlineStatuses(newObjectStatus, oldObjectStatus))
{
continue;
}
await UpdateObjectStatus(
assetMapRepository,
sendService,
oldObjectStatus,
newObjectStatus,
stoppingToken);
}
}
}
private static async Task UpdateObjectStatus(
IAssetMapRepository assetMapRepository,
ISendService sendService,
AssetMap oldObjectStatus,
GetObjectListResponse newObjectStatus,
CancellationToken cancellationToken)
{
try
{
Log.Information(
"Update object status: {Id}: {OldStatus} -> {NewStatus}",
oldObjectStatus.Id,
oldObjectStatus.IsOnline,
newObjectStatus.IsOnline);
await assetMapRepository.UpdateIsOnlineStatusAsync(
oldObjectStatus.Id,
newObjectStatus.IsOnline,
cancellationToken);
await sendService.SendOnlineStatus(
newObjectStatus.IsOnline,
oldObjectStatus.AssetId!.Value,
cancellationToken);
}
catch (Exception ex)
{
Log.Error(ex, "Error in UpdateObjectStatus");
}
}
private static async Task<IEnumerable<AssetMap>> ReadAssetMaps(
IAssetMapRepository assetMapRepository,
CancellationToken cancellationToken)
{
var result = await assetMapRepository.GetTable(
new AssetTableStateRequest(null, null),
cancellationToken);
if (result.IsFail)
{
Log.Error("Get asset map table is failed: {Error}", result.Error);
return [];
}
return result.Value.Items;
}
private static async Task<IEnumerable<GetObjectListResponse>> GetObjectInformationList(
IGeoRitmClient geoRitmClient,
CancellationToken cancellationToken)
{
try
{
var response = await geoRitmClient.GetObjectList(cancellationToken);
if (response.IsFail)
{
Log.Error("Get object list is failed: {Error}", response.Error);
return [];
}
return response.Value;
}
catch (Exception ex)
{
Log.Error(ex, "Get object list is failed");
return [];
}
}
private sealed record AssetTableStateRequest(int? Skip, int? Take) : ITableStateRequest;
private static bool CompareOnlineStatuses(GetObjectListResponse newObjectStatus, AssetMap oldObjectStatus)
{
return newObjectStatus.IsOnline == (oldObjectStatus.IsOnline ?? false);
}
private static bool CompareAbonents(string abonent1, string abonent2)
{
return GetLastAbonentSymbols(abonent1) == GetLastAbonentSymbols(abonent2);
}
private static string GetLastAbonentSymbols(string abonent)
{
const int abonentLength = 8;
return abonent.Length > abonentLength
? abonent[^abonentLength..]
: abonent;
}
}

View File

@@ -0,0 +1,160 @@
using Forwarder.Domain;
using Forwarder.Models;
using Forwarder.Repositories;
using Microsoft.Extensions.Options;
using Serilog;
namespace Forwarder.Services;
/// <summary>
/// Отправляет сообщения.
/// </summary>
/// <param name="options">Опции.</param>
internal class SendJob(
IOptions<ForwarderOptions> options,
IServiceProvider serviceProvider
) : BackgroundService
{
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ExecuteBatch(stoppingToken);
}
catch (Exception ex)
{
Log.Error(ex, "Error in ExecuteBatch");
}
await Task.Delay(options.Value.SendingIntervalInSeconds * 1000, stoppingToken);
}
}
private async Task ExecuteBatch(CancellationToken stoppingToken)
{
using var scope = serviceProvider.CreateScope();
var provider = scope.ServiceProvider;
var sendService = provider.GetRequiredService<ISendService>();
var commonRepository = provider.GetRequiredService<ICommonRepository>();
var messageRepository = provider.GetRequiredService<IMessageRepository>();
var assetMapRepository = provider.GetRequiredService<IAssetMapRepository>();
var messages = await ReadMessages(commonRepository, stoppingToken);
var maps = await GetMapsByAbonentList(
assetMapRepository,
[.. messages.Select(x => x.Abonent).Distinct()],
stoppingToken);
if (maps.IsFail)
{
Log.Error("GetMapsByAbonentList failed: {Error}", maps.Error);
return;
}
var tasks = new List<Task<Result>>();
var messageIdListForRemoving = new List<Guid>();
foreach (var message in messages)
{
if (message.Type == MessageType.StatePing)
{
var map = maps.Value.FirstOrDefault(x => x.Abonent == message.Abonent);
if (map?.AssetId is not null)
{
var task = sendService.SendTestMessage(message, map.AssetId.Value, stoppingToken);
tasks.Add(task);
}
}
else
{
// Существующая логика для других типов сообщений
var map = maps.Value.FirstOrDefault(x => x.Abonent == message.Abonent);
if (map?.AssetId is not null)
{
var task = sendService.Send(message, map.AssetId.Value, stoppingToken);
tasks.Add(task);
}
}
messageIdListForRemoving.Add(message.Id);
var mapResult = GetMapForMessage(message, maps.Value);
if (mapResult.IsFail)
{
continue;
}
tasks.Add(Task.Run(async () =>
{
var sendResult = await TrySendMessage(sendService, message, mapResult.Value, stoppingToken);
if (sendResult.IsFail)
{
return Result.Fail(sendResult.Error);
}
messageIdListForRemoving.Add(message.Id);
return Result.Success();
}));
}
await Task.WhenAll(tasks);
if (tasks.Count != 0)
{
_ = await messageRepository.RemoveBatch(messageIdListForRemoving, stoppingToken);
}
}
private static Result<AssetMap> GetMapForMessage(Message message, List<AssetMap> maps)
{
var map = maps.Find(x => x.Abonent == message.Abonent);
return map != null
? Result<AssetMap>.Success(map)
: Result<AssetMap>.Fail($"Не удалось найти маппинг для абонента {message.Abonent}");
}
private async Task<Result<List<AssetMap>>> GetMapsByAbonentList(IAssetMapRepository assetMapRepository, List<string> abonentList, CancellationToken cancellationToken)
{
return await assetMapRepository.GetRequiredMapsAndCreateIsNewAsync(abonentList, cancellationToken);
}
private async Task<Result> TrySendMessage(ISendService sendService, Message message, AssetMap map, CancellationToken cancellationToken)
{
Log.Information("Попытка отправки сообщения: {Message}", message);
if (!map.AssetId.HasValue)
{
Log.Information("AssetId не определен. Пропуск.");
return Result.Fail("AssetId is null");
}
var sendResult = await sendService.Send(message, map.AssetId.Value, cancellationToken);
if (sendResult.IsFail)
{
Log.Error(sendResult.Error);
}
return sendResult;
}
private async Task<List<Message>> ReadMessages(ICommonRepository commonRepository, CancellationToken cancellationToken)
{
var result = await commonRepository.GetFirstListWhereMapReady(
options.Value.MessageBulkSize,
cancellationToken);
if (result.IsFail)
{
Log.Error("GetFirstListWhereMapReady failed: {Error}", result.Error);
return [];
}
return [.. result.Value];
}
}

View File

@@ -0,0 +1,184 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Forwarder.Domain;
using Forwarder.Exceptions;
using Forwarder.Models;
using Forwarder.Repositories;
using Forwarder.Services.Implementations.NetworkSenders;
using Microsoft.Extensions.Options;
using Serilog;
namespace Forwarder.Services;
/// <summary>
/// Слушатель TCP-соединений.
/// </summary>
/// <param name="options">Опции.</param>
/// <param name="serviceProvider">Сервис-провайдер.</param>
internal class TcpWatcher(
IOptions<ForwarderOptions> options,
IServiceProvider serviceProvider
) : IHostedService
{
private bool _enabled;
private Task? _task;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_enabled = true;
_task = Task.Run(() => Listening(cancellationToken), cancellationToken);
return Task.CompletedTask;
}
private async Task Listening(CancellationToken cancellationToken)
{
while (_enabled)
{
try
{
await Start(cancellationToken);
}
catch (Exception ex)
{
Log.Error(ex, "Error listening");
}
finally
{
await Log.CloseAndFlushAsync();
}
}
}
private async Task Start(CancellationToken cancellationToken)
{
using var tcpListener = new TcpListener(IPAddress.Any, options.Value.TcpPort);
tcpListener.Start();
Log.Information("TCP-server is running. Port: {Port}", options.Value.TcpPort);
while (_enabled)
{
try
{
Log.Information("Ожидание соединения...");
var client = await tcpListener.AcceptTcpClientAsync(cancellationToken);
_ = HandleClientAsync(client, cancellationToken);
}
catch (Exception ex)
{
Log.Error(ex, "Ошибка при обработке клиента");
throw new NeedRecreateException();
}
}
}
private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
{
Log.Information("Соединение с клиентом установлено.");
using (client)
{
using var stream = client.GetStream();
try
{
while (true)
{
var messageResult = await ReadData(stream, cancellationToken);
if (messageResult.IsFail)
{
break;
}
var messageConverterService = serviceProvider
.CreateScope()
.ServiceProvider
.GetRequiredService<IMessageConverterService>();
Log.Information("Получено сообщение по TCP: {Message}",
messageConverterService.FormatMessage(messageResult.Value));
var converter = messageConverterService.FindMessageConverter(messageResult.Value);
if (converter.IsFail)
{
Log.Error(converter.Error);
break;
}
Log.Information("Найден конвертер сообщения: {Converter}", converter.Value.Name);
var message = converter.Value.Parse(messageResult.Value);
if (message.IsFail)
{
Log.Error(message.Error);
break;
}
if (string.IsNullOrWhiteSpace(message.Value.Abonent))
{
Log.Information("Обнаружен пустой абонент. Пинг.");
break;
}
await WriteMessage(message.Value, cancellationToken);
_ = await converter.Value.SendAnswer(
new TcpNetworkSender(stream),
cancellationToken
);
Log.Information("Сообщение обработано.");
}
}
catch (Exception ex)
{
Log.Error(ex, "Ошибка при обработке клиента");
}
finally
{
stream.Close();
client.Close();
Log.Information("Соединение с клиентом закрыто.");
}
}
}
private static async Task<Result<string>> ReadData(NetworkStream stream, CancellationToken cancellationToken)
{
var buffer = new Memory<byte>(new byte[1024]);
var bytesRead = await stream.ReadAsync(buffer, cancellationToken);
if (bytesRead == 0)
{
Log.Information("Клиент отключился.");
return Result<string>.Fail("Клиент отключился.");
}
var message = Encoding.ASCII.GetString(buffer.Span);
return Result<string>.Success(message);
}
private async Task WriteMessage(Message message, CancellationToken cancellationToken)
{
Log.Information("TCP-сообщение: {Message}", message);
var messageRepository = serviceProvider
.CreateScope()
.ServiceProvider
.GetRequiredService<IMessageRepository>();
_ = await messageRepository.AddAsync(message, cancellationToken);
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_enabled = false;
return _task ?? Task.CompletedTask;
}
}

View File

@@ -0,0 +1,145 @@
using System.Net.Sockets;
using System.Text;
using Forwarder.Domain;
using Forwarder.Exceptions;
using Forwarder.Models;
using Forwarder.Repositories;
using Forwarder.Services.Implementations.NetworkSenders;
using Microsoft.Extensions.Options;
using Serilog;
namespace Forwarder.Services;
/// <summary>
/// Слушатель UCP-соединений.
/// </summary>
/// <param name="options">Опции.</param>
/// <param name="serviceProvider">Сервис-провайдер.</param>
internal class UdpWatcher(IOptions<ForwarderOptions> options, IServiceProvider serviceProvider)
: IHostedService
{
private bool _enabled;
private Task? _task;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_enabled = true;
_task = Task.Run(() => Listening(cancellationToken), cancellationToken);
return Task.CompletedTask;
}
private async Task Listening(CancellationToken cancellationToken)
{
while (_enabled)
{
try
{
await Start(cancellationToken);
}
catch (Exception ex)
{
Log.Error(ex, "Error listening");
}
finally
{
await Log.CloseAndFlushAsync();
}
}
}
private async Task Start(CancellationToken cancellationToken)
{
Log.Information("UDP-server is running. Port: {Port}", options.Value.UdpPort);
using var udpClient = new UdpClient(options.Value.UdpPort);
while (_enabled)
{
try
{
_ = await HandleClientAsync(udpClient, cancellationToken);
}
catch (Exception ex)
{
Log.Information("Ошибка при обработке сообщения: {Error}", ex.Message);
Log.Error(ex, "Ошибка при обработке сообщения.");
throw new NeedRecreateException();
}
}
}
private async Task<Result> HandleClientAsync(
UdpClient udpClient,
CancellationToken cancellationToken
)
{
var receiveResult = await udpClient.ReceiveAsync(cancellationToken);
var messageText = Encoding.ASCII.GetString(receiveResult.Buffer);
var messageConverterService = serviceProvider
.CreateScope()
.ServiceProvider.GetRequiredService<IMessageConverterService>();
Log.Information(
"Получено сообщение по UDP: {Message}.",
messageConverterService.FormatMessage(messageText)
);
var converter = messageConverterService.FindMessageConverter(messageText);
if (converter.IsFail)
{
Log.Error(converter.Error);
return converter.Error;
}
Log.Information("Найден конвертер сообщения: {Converter}", converter.Value.Name);
var message = converter.Value.Parse(messageText);
Log.Information(
"Результат распознавания: {Message}",
message.IsFail ? message.Error : message.Value.ToString()
);
if (message.IsFail)
{
Log.Error(message.Error);
return message.Error;
}
Log.Information("Сообщение распознано.");
if (string.IsNullOrWhiteSpace(message.Value.Abonent))
{
Log.Information("Обнаружен пустой абонент. Пинг.");
return Result.Success();
}
await WriteMessage(message.Value, cancellationToken);
_ = await converter.Value.SendAnswer(
new UdpNetworkSender(udpClient, receiveResult.RemoteEndPoint),
cancellationToken
);
Log.Information("Сообщение обработано.");
return Result.Success();
}
private async Task WriteMessage(Message message, CancellationToken cancellationToken)
{
Log.Information("UDP-сообщение: {Message}", message);
var messageRepository = serviceProvider
.CreateScope()
.ServiceProvider.GetRequiredService<IMessageRepository>();
_ = await messageRepository.AddAsync(message, cancellationToken);
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_enabled = false;
return _task ?? Task.CompletedTask;
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,127 @@
<mxfile host="65bd71144e">
<diagram id="VUB8qhCUUltujLIevwC5" name="Page-1">
<mxGraphModel dx="1382" dy="916" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="4" value="" style="edgeStyle=none;rounded=1;html=1;noEdgeStyle=1;orthogonal=1;" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="82.5" y="182"/>
<mxPoint x="82.5" y="258"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="2" value="External Source" style="html=1;rounded=1;" vertex="1" parent="1">
<mxGeometry x="27.5" y="130" width="110" height="40" as="geometry"/>
</mxCell>
<mxCell id="6" value="" style="edgeStyle=none;rounded=1;html=1;noEdgeStyle=1;orthogonal=1;" edge="1" parent="1" source="3" target="5">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="82.5" y="322"/>
<mxPoint x="82.5" y="398"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="3" value="string of protocol" style="html=1;rounded=1;" vertex="1" parent="1">
<mxGeometry x="27.5" y="270" width="110" height="40" as="geometry"/>
</mxCell>
<mxCell id="8" value="" style="edgeStyle=none;rounded=1;html=1;noEdgeStyle=1;orthogonal=1;" edge="1" parent="1" source="5" target="7">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="82.5" y="482"/>
<mxPoint x="82.5" y="558"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="5" value="Parse input string" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;rounded=1;" vertex="1" parent="1">
<mxGeometry y="410" width="165" height="60" as="geometry"/>
</mxCell>
<mxCell id="11" value="" style="edgeStyle=none;rounded=1;html=1;noEdgeStyle=1;orthogonal=1;" edge="1" parent="1" source="7" target="10">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="82.5" y="642"/>
<mxPoint x="82.5" y="718"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="7" value="Event" style="whiteSpace=wrap;html=1;rounded=1;" vertex="1" parent="1">
<mxGeometry x="22.5" y="570" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="10" value="Save to DB" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;rounded=1;" vertex="1" parent="1">
<mxGeometry x="22.5" y="730" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="18" value="" style="edgeStyle=none;rounded=1;html=1;noEdgeStyle=1;orthogonal=1;" edge="1" parent="1" source="15" target="19">
<mxGeometry relative="1" as="geometry">
<mxPoint x="310" y="270" as="sourcePoint"/>
<Array as="points">
<mxPoint x="307.5" y="202"/>
<mxPoint x="307.5" y="278"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="15" value="Get events from DB" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;rounded=1;" vertex="1" parent="1">
<mxGeometry x="225" y="130" width="165" height="60" as="geometry"/>
</mxCell>
<mxCell id="17" value="Send to API" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;rounded=1;" vertex="1" parent="1">
<mxGeometry x="225" y="630" width="165" height="60" as="geometry"/>
</mxCell>
<mxCell id="21" value="" style="edgeStyle=none;rounded=1;html=1;noEdgeStyle=1;orthogonal=1;" edge="1" parent="1" source="19" target="22">
<mxGeometry relative="1" as="geometry">
<mxPoint x="740" y="160" as="targetPoint"/>
<Array as="points">
<mxPoint x="307.5" y="362"/>
<mxPoint x="307.5" y="438"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="19" value="Get mappings" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;rounded=1;" vertex="1" parent="1">
<mxGeometry x="225" y="290" width="165" height="60" as="geometry"/>
</mxCell>
<mxCell id="24" value="Yes" style="edgeStyle=none;rounded=1;html=1;noEdgeStyle=1;orthogonal=1;" edge="1" parent="1" source="22" target="17">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="307.5" y="542"/>
<mxPoint x="307.5" y="618"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="29" value="No" style="edgeStyle=none;rounded=1;html=1;" edge="1" parent="1" source="22" target="30">
<mxGeometry relative="1" as="geometry">
<mxPoint x="470" y="490" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="22" value="map is existing" style="rhombus;whiteSpace=wrap;html=1;rounded=1;" vertex="1" parent="1">
<mxGeometry x="267.5" y="450" width="80" height="80" as="geometry"/>
</mxCell>
<mxCell id="25" value="Input streams" style="ellipse;html=1;shape=startState;fillColor=#000000;strokeColor=#ff0000;rounded=1;" vertex="1" parent="1">
<mxGeometry x="67.5" width="30" height="30" as="geometry"/>
</mxCell>
<mxCell id="26" value="" style="edgeStyle=orthogonalEdgeStyle;html=1;verticalAlign=bottom;endArrow=open;endSize=8;strokeColor=#ff0000;rounded=1;noEdgeStyle=1;orthogonal=1;" edge="1" source="25" parent="1" target="2">
<mxGeometry relative="1" as="geometry">
<mxPoint x="405" y="210" as="targetPoint"/>
<Array as="points">
<mxPoint x="82.5" y="42"/>
<mxPoint x="82.5" y="118"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="27" value="Schedulling" style="ellipse;html=1;shape=startState;fillColor=#000000;strokeColor=#ff0000;rounded=1;" vertex="1" parent="1">
<mxGeometry x="292.5" width="30" height="30" as="geometry"/>
</mxCell>
<mxCell id="28" value="" style="edgeStyle=orthogonalEdgeStyle;html=1;verticalAlign=bottom;endArrow=open;endSize=8;strokeColor=#ff0000;rounded=1;noEdgeStyle=1;orthogonal=1;" edge="1" source="27" parent="1" target="15">
<mxGeometry relative="1" as="geometry">
<mxPoint x="435" y="140" as="targetPoint"/>
<Array as="points">
<mxPoint x="307.5" y="42"/>
<mxPoint x="307.5" y="118"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="30" value="" style="ellipse;html=1;shape=endState;fillColor=#000000;strokeColor=#ff0000;rounded=1;" vertex="1" parent="1">
<mxGeometry x="410" y="475" width="30" height="30" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@@ -0,0 +1,25 @@
{
"runtime": "Net90",
"documentGenerator": {
"openApiToOpenApi": {
"url": "http://localhost:5000/openapi/v1.json",
"output": "temp-openapi.json"
}
},
"codeGenerators": {
"openApiToCSharpClient": {
"className": "MonitoringClient",
"namespace": "Forwarder.Clients",
"output": "Clients/GeneratedApiClient.cs",
"injectHttpClient": true,
"generateBaseUrlProperty": true,
"generateSyncMethods": false,
"generateOptionalParameters": true,
"generateDtoTypes": true,
"exceptionClass": "ApiException",
"wrapResponses": false,
"useBaseUrl": false,
"typeAccessModifier": "public"
}
}
}

Some files were not shown because too many files have changed in this diff Show More